доставка в плавающем
This commit is contained in:
@@ -45,6 +45,56 @@ final class CatalogController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function promo(): void
|
||||||
|
{
|
||||||
|
$categoryRepository = new CategoryRepository();
|
||||||
|
$productRepository = new ProductRepository();
|
||||||
|
$pagination = $this->paginationInput();
|
||||||
|
$listing = $productRepository->promoListing($pagination['limit'], $pagination['offset']);
|
||||||
|
|
||||||
|
view('catalog/index', [
|
||||||
|
'title' => 'Акции Рыбсток - продукты по стоковым ценам с доставкой',
|
||||||
|
'metaDescription' => 'Акционные товары Рыбсток: рыба, морепродукты, мясо, сыры, икра, полуфабрикаты и бакалея по стоковым ценам с доставкой по Москве и Московской области.',
|
||||||
|
'metaKeywords' => 'акции Рыбсток, скидки на рыбу, продукты по акции Москва, рыба морепродукты мясо сыры по стоковым ценам',
|
||||||
|
'breadcrumbs' => [
|
||||||
|
['title' => 'Главная', 'url' => '/'],
|
||||||
|
['title' => 'Акции'],
|
||||||
|
],
|
||||||
|
'categories' => $categoryRepository->tree(),
|
||||||
|
'currentCategory' => null,
|
||||||
|
'catalogHeading' => 'Акции',
|
||||||
|
'catalogEyebrow' => 'Подборка',
|
||||||
|
'catalogLead' => 'Товары с акционной витриной и перечеркнутой ценой. Итоговая цена в корзине дополнительно зависит от выбранного способа оплаты.',
|
||||||
|
'products' => $listing['products'],
|
||||||
|
'pagination' => $this->paginationData('/promo', $listing['total'], count($listing['products']), $pagination),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newest(): void
|
||||||
|
{
|
||||||
|
$categoryRepository = new CategoryRepository();
|
||||||
|
$productRepository = new ProductRepository();
|
||||||
|
$pagination = $this->paginationInput();
|
||||||
|
$listing = $productRepository->newListing($pagination['limit'], $pagination['offset']);
|
||||||
|
|
||||||
|
view('catalog/index', [
|
||||||
|
'title' => 'Новинки Рыбсток - последние поступления на склад',
|
||||||
|
'metaDescription' => 'Новые поступления Рыбсток: свежие позиции каталога, новые фасовки и товары, недавно добавленные на склад с доставкой по Москве и Московской области.',
|
||||||
|
'metaKeywords' => 'новинки Рыбсток, новые поступления рыбы, свежие поступления морепродуктов, новые товары склад продуктов Москва',
|
||||||
|
'breadcrumbs' => [
|
||||||
|
['title' => 'Главная', 'url' => '/'],
|
||||||
|
['title' => 'Новинки'],
|
||||||
|
],
|
||||||
|
'categories' => $categoryRepository->tree(),
|
||||||
|
'currentCategory' => null,
|
||||||
|
'catalogHeading' => 'Новинки',
|
||||||
|
'catalogEyebrow' => 'Последние поступления',
|
||||||
|
'catalogLead' => 'Последние добавленные позиции и товары, у которых изменилась фасовка. Новинки помогают быстро увидеть, что недавно появилось на складе.',
|
||||||
|
'products' => $listing['products'],
|
||||||
|
'pagination' => $this->paginationData('/new', $listing['total'], count($listing['products']), $pagination),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function search(string $query): void
|
public function search(string $query): void
|
||||||
{
|
{
|
||||||
$_GET['q'] = trim($query);
|
$_GET['q'] = trim($query);
|
||||||
|
|||||||
@@ -34,15 +34,15 @@ final class HomeController
|
|||||||
$homeCatalogSections[] = [
|
$homeCatalogSections[] = [
|
||||||
'key' => 'promos',
|
'key' => 'promos',
|
||||||
'title' => 'Акции',
|
'title' => 'Акции',
|
||||||
'url' => '/catalog',
|
'url' => '/promo',
|
||||||
'count' => 10,
|
'count' => 50,
|
||||||
'kind' => 'promo',
|
'kind' => 'promo',
|
||||||
'products' => $productRepository->promoPreview(10),
|
'products' => $productRepository->promoPreview(10),
|
||||||
];
|
];
|
||||||
$homeCatalogSections[] = [
|
$homeCatalogSections[] = [
|
||||||
'key' => 'new',
|
'key' => 'new',
|
||||||
'title' => 'Новинки',
|
'title' => 'Новинки',
|
||||||
'url' => '/catalog',
|
'url' => '/new',
|
||||||
'count' => 50,
|
'count' => 50,
|
||||||
'kind' => 'new',
|
'kind' => 'new',
|
||||||
'products' => $productRepository->newPreview(10),
|
'products' => $productRepository->newPreview(10),
|
||||||
|
|||||||
@@ -30,7 +30,15 @@ final class ProductRepository extends BaseRepository
|
|||||||
*/
|
*/
|
||||||
public function promoPreview(int $limit = 10): array
|
public function promoPreview(int $limit = 10): array
|
||||||
{
|
{
|
||||||
$limit = max(1, min($limit, 10));
|
return array_slice($this->promoProducts(max(1, min($limit, 10))), 0, max(1, min($limit, 10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function promoProducts(int $limit = 50): array
|
||||||
|
{
|
||||||
|
$limit = max(1, min($limit, 50));
|
||||||
$products = $this->manualPromoPreview($limit);
|
$products = $this->manualPromoPreview($limit);
|
||||||
$usedIds = array_flip(array_map(static fn (array $product): int => (int) ($product['id'] ?? 0), $products));
|
$usedIds = array_flip(array_map(static fn (array $product): int => (int) ($product['id'] ?? 0), $products));
|
||||||
|
|
||||||
@@ -63,6 +71,20 @@ final class ProductRepository extends BaseRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{products: array<int, array<string, mixed>>, total: int}
|
||||||
|
*/
|
||||||
|
public function promoListing(int $limit = 48, int $offset = 0): array
|
||||||
|
{
|
||||||
|
$products = $this->promoProducts(50);
|
||||||
|
$total = count($products);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'products' => array_slice($products, max(0, $offset), max(1, min($limit, 48))),
|
||||||
|
'total' => $total,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -88,6 +110,20 @@ final class ProductRepository extends BaseRepository
|
|||||||
return $products;
|
return $products;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{products: array<int, array<string, mixed>>, total: int}
|
||||||
|
*/
|
||||||
|
public function newListing(int $limit = 48, int $offset = 0): array
|
||||||
|
{
|
||||||
|
$products = $this->newPreview(50);
|
||||||
|
$total = count($products);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'products' => array_slice($products, max(0, $offset), max(1, min($limit, 48))),
|
||||||
|
'total' => $total,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
@@ -151,6 +187,23 @@ final class ProductRepository extends BaseRepository
|
|||||||
return (int) $statement->fetchColumn();
|
return (int) $statement->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{slug: string, updated_at: string|null, published_at: string|null, created_at: string|null}>
|
||||||
|
*/
|
||||||
|
public function sitemapProducts(int $limit = 10000): array
|
||||||
|
{
|
||||||
|
$statement = $this->pdo()->query(
|
||||||
|
'SELECT slug, updated_at, published_at, created_at
|
||||||
|
FROM products
|
||||||
|
WHERE is_published = 1
|
||||||
|
AND is_available = 1
|
||||||
|
ORDER BY updated_at DESC, published_at DESC, created_at DESC, id DESC
|
||||||
|
LIMIT ' . max(1, min($limit, 10000))
|
||||||
|
);
|
||||||
|
|
||||||
|
return $statement->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{products: array<int, array<string, mixed>>, total: int}
|
* @return array{products: array<int, array<string, mixed>>, total: int}
|
||||||
*/
|
*/
|
||||||
@@ -291,7 +344,17 @@ final class ProductRepository extends BaseRepository
|
|||||||
);
|
);
|
||||||
$statement->execute(['product_id' => $productId]);
|
$statement->execute(['product_id' => $productId]);
|
||||||
|
|
||||||
return $statement->fetchAll();
|
return array_map(
|
||||||
|
static function (array $image): array {
|
||||||
|
$path = (string) ($image['path'] ?? '');
|
||||||
|
$image['preview_path'] = \function_exists('optimized_image_path')
|
||||||
|
? \optimized_image_path($path, 'preview')
|
||||||
|
: $path;
|
||||||
|
|
||||||
|
return $image;
|
||||||
|
},
|
||||||
|
$statement->fetchAll()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -435,7 +498,9 @@ final class ProductRepository extends BaseRepository
|
|||||||
'variantId' => !empty($row['variant_id']) ? (int) $row['variant_id'] : $variantId,
|
'variantId' => !empty($row['variant_id']) ? (int) $row['variant_id'] : $variantId,
|
||||||
'name' => (string) ($row['name'] ?? ''),
|
'name' => (string) ($row['name'] ?? ''),
|
||||||
'slug' => (string) ($row['slug'] ?? ''),
|
'slug' => (string) ($row['slug'] ?? ''),
|
||||||
'image' => (string) ($row['main_image_path'] ?? ''),
|
'image' => \function_exists('optimized_image_path')
|
||||||
|
? \optimized_image_path((string) ($row['main_image_path'] ?? ''), 'preview')
|
||||||
|
: (string) ($row['main_image_path'] ?? ''),
|
||||||
'variantName' => (string) ($row['variant_name'] ?? 'Фасовка'),
|
'variantName' => (string) ($row['variant_name'] ?? 'Фасовка'),
|
||||||
'unit' => (string) ($row['unit'] ?? ''),
|
'unit' => (string) ($row['unit'] ?? ''),
|
||||||
'packageQuantity' => (float) ($row['package_quantity'] ?? 0),
|
'packageQuantity' => (float) ($row['package_quantity'] ?? 0),
|
||||||
|
|||||||
@@ -36,6 +36,44 @@ function versioned_asset(string $path): string
|
|||||||
return $assetPath . $querySeparator . filemtime($publicPath);
|
return $assetPath . $querySeparator . filemtime($publicPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function public_asset_exists(string $path): bool
|
||||||
|
{
|
||||||
|
$assetPath = '/' . ltrim(strtok($path, '?') ?: $path, '/');
|
||||||
|
|
||||||
|
return is_file(base_path('public' . $assetPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function optimized_image_path(?string $path, string $mode = 'preview'): string
|
||||||
|
{
|
||||||
|
$assetPath = trim((string) $path);
|
||||||
|
if ($assetPath === '' || !str_starts_with($assetPath, '/assets/uploads/old/products/')) {
|
||||||
|
return $assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
$assetPath = '/' . ltrim(strtok($assetPath, '?') ?: $assetPath, '/');
|
||||||
|
$directory = rtrim(str_replace('\\', '/', dirname($assetPath)), '/');
|
||||||
|
$filename = basename($assetPath);
|
||||||
|
$isFull = str_starts_with($filename, 'full_');
|
||||||
|
$baseFilename = $isFull ? substr($filename, 5) : $filename;
|
||||||
|
|
||||||
|
$candidates = $mode === 'detail'
|
||||||
|
? [$baseFilename, 'thumb_' . $baseFilename]
|
||||||
|
: ['thumb_' . $baseFilename, $baseFilename];
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if ($candidate === '' || $candidate === $filename) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidatePath = $directory . '/' . $candidate;
|
||||||
|
if (public_asset_exists($candidatePath)) {
|
||||||
|
return $candidatePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $assetPath;
|
||||||
|
}
|
||||||
|
|
||||||
function view(string $template, array $data = []): void
|
function view(string $template, array $data = []): void
|
||||||
{
|
{
|
||||||
View::render($template, $data);
|
View::render($template, $data);
|
||||||
|
|||||||
+517
-58
@@ -1662,8 +1662,8 @@ a:hover {
|
|||||||
|
|
||||||
.home-catalog-showcase {
|
.home-catalog-showcase {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
margin-top: 24px;
|
margin-top: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-catalog-heading,
|
.home-catalog-heading,
|
||||||
@@ -1675,18 +1675,18 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-catalog-heading {
|
.home-catalog-heading {
|
||||||
border: 1px solid rgba(13, 93, 128, 0.12);
|
border: 1px solid rgba(13, 93, 128, 0.16);
|
||||||
border-radius: 16px;
|
border-radius: 12px;
|
||||||
padding: clamp(16px, 2vw, 24px);
|
padding: clamp(16px, 2vw, 22px);
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(13, 93, 128, 0.08), rgba(255, 255, 255, 0.78) 58%),
|
linear-gradient(135deg, rgba(13, 93, 128, 0.1), rgba(255, 255, 255, 0.82) 58%),
|
||||||
#fffefa;
|
#fffefa;
|
||||||
box-shadow: 0 14px 36px rgba(22, 60, 43, 0.06);
|
box-shadow: 0 14px 34px rgba(22, 60, 43, 0.055);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-catalog-heading h2 {
|
.home-catalog-heading h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(26px, 2.45vw, 36px);
|
font-size: clamp(24px, 2.2vw, 34px);
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1717,15 +1717,15 @@ a:hover {
|
|||||||
.home-product-section {
|
.home-product-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: clamp(14px, 1.8vw, 22px);
|
padding: clamp(12px, 1.6vw, 18px);
|
||||||
border: 1px solid rgba(19, 66, 45, 0.12);
|
border: 1px solid rgba(19, 66, 45, 0.12);
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 254, 250, 0.96)),
|
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 254, 250, 0.96)),
|
||||||
#fff;
|
#fff;
|
||||||
box-shadow: 0 16px 44px rgba(22, 60, 43, 0.08);
|
box-shadow: 0 12px 34px rgba(22, 60, 43, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-product-section::after {
|
.home-product-section::after {
|
||||||
@@ -1755,7 +1755,7 @@ a:hover {
|
|||||||
|
|
||||||
.home-product-section-head h3 {
|
.home-product-section-head h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(22px, 2vw, 30px);
|
font-size: clamp(20px, 1.75vw, 28px);
|
||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1809,8 +1809,8 @@ a:hover {
|
|||||||
.home-products-strip {
|
.home-products-strip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-auto-columns: clamp(250px, 18vw, 286px);
|
grid-auto-columns: clamp(218px, 15.5vw, 250px);
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
margin-inline: -2px;
|
margin-inline: -2px;
|
||||||
@@ -1828,9 +1828,9 @@ a:hover {
|
|||||||
|
|
||||||
.home-products-strip .product-card {
|
.home-products-strip .product-card {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 12px;
|
padding: 10px;
|
||||||
border-color: rgba(19, 66, 45, 0.1);
|
border-color: rgba(19, 66, 45, 0.1);
|
||||||
box-shadow: 0 12px 26px rgba(22, 60, 43, 0.055);
|
box-shadow: 0 10px 22px rgba(22, 60, 43, 0.05);
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1844,20 +1844,20 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-products-strip .product-card .product-category {
|
.home-products-strip .product-card .product-category {
|
||||||
height: 26px;
|
height: 24px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-products-strip .product-card h3 {
|
.home-products-strip .product-card h3 {
|
||||||
height: 54px;
|
height: 48px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
line-height: 1.18;
|
line-height: 1.15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-products-strip .variant-choice {
|
.home-products-strip .variant-choice {
|
||||||
height: 64px;
|
height: 58px;
|
||||||
grid-template-rows: auto 30px;
|
grid-template-rows: auto 30px;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -1867,7 +1867,7 @@ a:hover {
|
|||||||
.home-products-strip .variant-choice > span,
|
.home-products-strip .variant-choice > span,
|
||||||
.home-products-strip .price-line,
|
.home-products-strip .price-line,
|
||||||
.home-products-strip .package-price-line {
|
.home-products-strip .package-price-line {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-products-strip .package-price-note {
|
.home-products-strip .package-price-note {
|
||||||
@@ -1875,18 +1875,18 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-products-strip .price-line {
|
.home-products-strip .price-line {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 2px;
|
gap: 5px;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
min-height: 42px;
|
min-height: 32px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-products-strip .package-price-line {
|
.home-products-strip .package-price-line {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 2px;
|
gap: 5px;
|
||||||
align-items: start;
|
align-items: center;
|
||||||
min-height: 50px;
|
min-height: 42px;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1896,14 +1896,14 @@ a:hover {
|
|||||||
color: #546251;
|
color: #546251;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
text-overflow: clip;
|
text-overflow: ellipsis;
|
||||||
white-space: normal;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-products-strip .price-unit-values,
|
.home-products-strip .price-unit-values,
|
||||||
.home-products-strip .package-price-values {
|
.home-products-strip .package-price-values {
|
||||||
justify-content: flex-start;
|
justify-content: flex-end;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1930,7 +1930,7 @@ a:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.home-products-strip .product-card-actions {
|
.home-products-strip .product-card-actions {
|
||||||
grid-template-columns: 84px minmax(104px, 1fr);
|
grid-template-columns: 84px minmax(92px, 1fr);
|
||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2934,11 +2934,16 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
|||||||
width: min(var(--page-max), 100%);
|
width: min(var(--page-max), 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 0.82fr) minmax(0, 1.18fr);
|
grid-template-columns: minmax(320px, 0.78fr) minmax(0, 1.22fr);
|
||||||
gap: 18px;
|
gap: 22px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail > .panel {
|
||||||
|
border-color: rgba(23, 61, 44, 0.12);
|
||||||
|
box-shadow: 0 18px 48px rgba(23, 61, 44, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.product-gallery img {
|
.product-gallery img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2946,6 +2951,11 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-gallery > img {
|
||||||
|
aspect-ratio: 1 / 0.82;
|
||||||
|
background: #f4f6f1;
|
||||||
|
}
|
||||||
|
|
||||||
.product-thumbs {
|
.product-thumbs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
||||||
@@ -2969,7 +2979,11 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.product-detail-main h1 {
|
.product-detail-main h1 {
|
||||||
margin-top: 0;
|
max-width: 820px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(32px, 4vw, 58px);
|
||||||
|
line-height: 0.98;
|
||||||
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-lead {
|
.product-lead {
|
||||||
@@ -2978,6 +2992,26 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-notes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin: 18px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-notes span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #dbe6d7;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #143c2a;
|
||||||
|
background: linear-gradient(135deg, #f4f8ef 0%, #ffffff 100%);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
.variant-list {
|
.variant-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -2985,41 +3019,124 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.variant-list h2 {
|
.variant-list h2 {
|
||||||
margin-bottom: 2px;
|
margin: 0 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.variant-row {
|
.variant-row {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.2fr) minmax(130px, 0.7fr) minmax(110px, 0.55fr) minmax(220px, 0.75fr);
|
grid-template-columns: minmax(130px, 0.55fr) minmax(260px, 1fr) minmax(220px, 0.68fr);
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
align-items: center;
|
align-items: stretch;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid #dbe6d7;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 14px;
|
||||||
background: #f8faf5;
|
background: #fbfcf7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row-head {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 7px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row-head span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row-head strong {
|
||||||
|
min-height: 32px;
|
||||||
|
color: var(--brand-green);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row-prices {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
padding-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row .price-line,
|
||||||
|
.variant-row .package-price-line {
|
||||||
|
min-height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row .package-price-note {
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row .price-unit-label,
|
||||||
|
.variant-row .package-price-label {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row .price-unit-values,
|
||||||
|
.variant-row .package-price-values {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 118px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.variant-row .product-card-actions {
|
.variant-row .product-card-actions {
|
||||||
|
align-self: end;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(108px, 0.62fr) minmax(118px, 0.78fr);
|
||||||
|
gap: 10px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.variant-row b {
|
.variant-row .quantity-stepper button,
|
||||||
display: grid;
|
.variant-row .quantity-stepper input {
|
||||||
gap: 3px;
|
min-height: 42px;
|
||||||
color: var(--brand-green);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.variant-row b small {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.variant-row .add-to-cart-button {
|
.variant-row .add-to-cart-button {
|
||||||
|
min-height: 42px;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.variant-row .price-tier-popover {
|
||||||
|
inset: auto 0 calc(100% + 10px) 0;
|
||||||
|
max-width: 430px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-payment-mode="cash"] .variant-row .price-line:has(.price-tier-trigger:hover) ~ .price-tier-popover,
|
||||||
|
body[data-payment-mode="cash"] .variant-row .package-price-line:has(.price-tier-trigger:hover) ~ .price-tier-popover,
|
||||||
|
body[data-payment-mode="cash"] .variant-row .price-tier-popover:hover {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-preorder {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-left: 4px solid var(--brand-red);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: #355141;
|
||||||
|
background: #f6f1e8;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail-main .eyebrow {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.cart-page {
|
.cart-page {
|
||||||
width: min(var(--page-max), 100%);
|
width: min(var(--page-max), 100%);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -3057,6 +3174,14 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
|||||||
background: #fff8f4;
|
background: #fff8f4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-live-notice {
|
||||||
|
border-color: rgba(23, 61, 44, 0.16);
|
||||||
|
border-left-color: var(--brand-green);
|
||||||
|
margin-top: 10px;
|
||||||
|
color: #486050;
|
||||||
|
background: #f6fbf3;
|
||||||
|
}
|
||||||
|
|
||||||
.cart-layout {
|
.cart-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.36fr);
|
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.36fr);
|
||||||
@@ -3162,6 +3287,44 @@ body[data-payment-mode="cash"] .price-tier-popover:hover {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cart-row-states {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 24px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row-states:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row-state {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row-state.is-price-drop {
|
||||||
|
color: #14623b;
|
||||||
|
background: #dff4dc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row-state.is-package-change {
|
||||||
|
color: #7b471a;
|
||||||
|
background: #f9e4c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-row-state.is-unavailable {
|
||||||
|
color: #6b2b23;
|
||||||
|
background: #f5d7d1;
|
||||||
|
}
|
||||||
|
|
||||||
.cart-row-main p {
|
.cart-row-main p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -3661,6 +3824,14 @@ body[data-payment-mode="invoice"] .cart-invoice-details {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-heading-lead {
|
||||||
|
max-width: 820px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
.payment-benefit-banner {
|
.payment-benefit-banner {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.38fr);
|
grid-template-columns: minmax(0, 1fr) minmax(260px, 0.38fr);
|
||||||
@@ -4665,6 +4836,10 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.product-detail-notes {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-media {
|
.hero-media {
|
||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
}
|
}
|
||||||
@@ -4971,6 +5146,26 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner {
|
|||||||
|
|
||||||
.variant-row {
|
.variant-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row-head strong {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row .price-unit-values,
|
||||||
|
.variant-row .package-price-values {
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row .price-tier-popover {
|
||||||
|
inset: auto 0 calc(100% + 8px) 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-row .product-card-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-card-actions {
|
.product-card-actions {
|
||||||
@@ -5005,3 +5200,267 @@ body[data-payment-mode="invoice"] .invoice-benefit-banner {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Final overrides for the current step. Keep this block at the end of the file. */
|
||||||
|
.start-screen.wide {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-screen.wide > .home-value-system { order: 1; }
|
||||||
|
.start-screen.wide > .home-catalog-showcase { order: 2; margin-top: clamp(18px, 2.4vw, 28px); }
|
||||||
|
.start-screen.wide > .audience-system { order: 3; }
|
||||||
|
.start-screen.wide > .price-request-panel { order: 4; }
|
||||||
|
.start-screen.wide > .warehouse-showcase { order: 5; }
|
||||||
|
.start-screen.wide > .wishlist-request-panel { order: 6; }
|
||||||
|
.start-screen.wide > .home-delivery-summary { order: 7; }
|
||||||
|
.start-screen.wide > .status-box { order: 8; }
|
||||||
|
|
||||||
|
.home-delivery-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
|
||||||
|
gap: clamp(18px, 3vw, 34px);
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid rgba(13, 93, 128, 0.18);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: clamp(22px, 4vw, 42px);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 86% 20%, rgba(13, 93, 128, 0.18), transparent 30%),
|
||||||
|
linear-gradient(135deg, #fffdf6 0%, #eef6f4 56%, #eaf3e8 100%);
|
||||||
|
box-shadow: 0 24px 60px rgba(22, 60, 43, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-summary h2 {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--brand-green);
|
||||||
|
font-size: clamp(26px, 3.1vw, 44px);
|
||||||
|
line-height: 1.02;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-summary p:not(.eyebrow) {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: clamp(15px, 1.35vw, 18px);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-benefits {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-benefits article {
|
||||||
|
min-height: 142px;
|
||||||
|
border: 1px solid rgba(19, 66, 45, 0.12);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-benefits span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-benefits strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--brand-red);
|
||||||
|
font-size: clamp(24px, 2.5vw, 34px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-benefits p {
|
||||||
|
font-size: 13px !important;
|
||||||
|
line-height: 1.35 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-summary > .primary-button {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-catalog-showcase .home-catalog-heading {
|
||||||
|
padding: clamp(12px, 1.5vw, 18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-catalog-showcase .home-catalog-heading h2 {
|
||||||
|
font-size: clamp(24px, 2.4vw, 38px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-products-strip {
|
||||||
|
grid-auto-columns: clamp(252px, 18.5vw, 292px);
|
||||||
|
gap: 14px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-products-strip .product-card {
|
||||||
|
min-height: 548px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-products-strip .product-card h3 {
|
||||||
|
height: 58px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-products-strip .price-line,
|
||||||
|
.home-products-strip .package-price-line {
|
||||||
|
grid-template-columns: minmax(88px, 1fr) auto auto;
|
||||||
|
min-height: 30px;
|
||||||
|
overflow: visible;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-products-strip .quantity-control {
|
||||||
|
grid-template-columns: 34px 34px 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-products-strip .add-to-cart-button {
|
||||||
|
min-width: 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .catalog-heading {
|
||||||
|
order: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: clamp(14px, 1.7vw, 20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .catalog-heading h1 {
|
||||||
|
font-size: clamp(28px, 3vw, 42px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .catalog-search-result-panel {
|
||||||
|
order: 2;
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .catalog-search-result-panel h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: clamp(18px, 1.7vw, 24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .catalog-search-result-panel p:not(.eyebrow) {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .catalog-count-panel { order: 3; }
|
||||||
|
.catalog-content > .catalog-product-grid,
|
||||||
|
.catalog-content > .panel { order: 4; }
|
||||||
|
.catalog-content > .catalog-pagination { order: 5; }
|
||||||
|
|
||||||
|
.catalog-content > .payment-benefit-banner {
|
||||||
|
order: 6;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.28fr);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: clamp(16px, 2vw, 22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .payment-benefit-banner .cash-benefit-copy h2 {
|
||||||
|
font-size: clamp(22px, 2.1vw, 30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .payment-benefit-banner .cash-benefit-copy p:not(.eyebrow) {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .catalog-seo-panel { order: 7; }
|
||||||
|
|
||||||
|
.delivery-vehicle-visual {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
background: #eaf4f0;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(19, 66, 45, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delivery-vehicle-visual img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 8;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calculator-submit {
|
||||||
|
align-self: end;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-delivery-options label,
|
||||||
|
.cart-lift-label,
|
||||||
|
.checkbox-label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-lift-label {
|
||||||
|
min-height: 48px;
|
||||||
|
align-content: center;
|
||||||
|
border: 1px solid #cfd8c9;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
transition: border-color 0.16s ease, background 0.16s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-lift-label:hover,
|
||||||
|
.cart-lift-label:has(input:checked) {
|
||||||
|
border-color: var(--brand-green);
|
||||||
|
background: #f0f7ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.home-delivery-summary,
|
||||||
|
.catalog-content > .payment-benefit-banner {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-benefits {
|
||||||
|
grid-template-columns: repeat(3, minmax(160px, 1fr));
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.home-products-strip {
|
||||||
|
grid-auto-columns: minmax(248px, 82vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-section-head,
|
||||||
|
.home-catalog-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-delivery-benefits {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-content > .catalog-heading {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 520" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Рефрижератор Рыбсток в Москве</title>
|
||||||
|
<desc id="desc">Фирменная машина Рыбсток для доставки внутри МКАД</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#d9edf4"/>
|
||||||
|
<stop offset="1" stop-color="#f7f2e9"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="van" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#fffdf7"/>
|
||||||
|
<stop offset="1" stop-color="#dfe8e2"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="18" stdDeviation="18" flood-color="#073322" flood-opacity=".25"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="520" rx="34" fill="url(#sky)"/>
|
||||||
|
<path d="M0 377c158-42 306-49 444-20 131 27 272 22 516-42v205H0z" fill="#cddfd3"/>
|
||||||
|
<path d="M0 417c210-41 386-38 530 8 130 42 247 38 430-12v107H0z" fill="#f4efe6"/>
|
||||||
|
<g opacity=".42" fill="#ffffff">
|
||||||
|
<rect x="62" y="100" width="76" height="185" rx="7"/>
|
||||||
|
<rect x="152" y="75" width="105" height="220" rx="9"/>
|
||||||
|
<rect x="282" y="118" width="92" height="177" rx="8"/>
|
||||||
|
<rect x="748" y="90" width="132" height="214" rx="10"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#shadow)" transform="translate(130 150)">
|
||||||
|
<path d="M54 124c0-34 28-62 62-62h378c25 0 48 14 59 36l35 69h57c26 0 47 21 47 47v62H54z" fill="url(#van)"/>
|
||||||
|
<path d="M79 55c0-27 22-49 49-49h332c27 0 49 22 49 49v202H79z" fill="#f9fbf5"/>
|
||||||
|
<path d="M511 107h38c18 0 34 10 43 26l27 51H511z" fill="#d7ecf0"/>
|
||||||
|
<path d="M111 42h360v51H111z" fill="#144631"/>
|
||||||
|
<text x="130" y="78" font-family="Arial, sans-serif" font-size="42" font-weight="900" fill="#fff">РЫБ</text>
|
||||||
|
<text x="233" y="78" font-family="Arial, sans-serif" font-size="42" font-weight="900" fill="#d23b30">СТОК</text>
|
||||||
|
<text x="112" y="134" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#144631">Качество со склада. Стоковые цены.</text>
|
||||||
|
<path d="M86 174h396" stroke="#d23b30" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<path d="M86 194h396" stroke="#144631" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<rect x="102" y="218" width="104" height="22" rx="11" fill="#eaf3ed"/>
|
||||||
|
<rect x="230" y="218" width="145" height="22" rx="11" fill="#eaf3ed"/>
|
||||||
|
<circle cx="170" cy="287" r="44" fill="#10261d"/>
|
||||||
|
<circle cx="170" cy="287" r="22" fill="#dfe9e1"/>
|
||||||
|
<circle cx="568" cy="287" r="44" fill="#10261d"/>
|
||||||
|
<circle cx="568" cy="287" r="22" fill="#dfe9e1"/>
|
||||||
|
<path d="M36 257h676" stroke="#154631" stroke-width="18" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,42 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 520" role="img" aria-labelledby="title desc">
|
||||||
|
<title id="title">Рефрижератор Рыбсток за МКАД</title>
|
||||||
|
<desc id="desc">Фирменная машина Рыбсток для доставки по Московской области</desc>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sunset" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#f3d9b6"/>
|
||||||
|
<stop offset=".58" stop-color="#cfe7df"/>
|
||||||
|
<stop offset="1" stop-color="#6d9ab1"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feDropShadow dx="0" dy="20" stdDeviation="20" flood-color="#0a3323" flood-opacity=".28"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="960" height="520" rx="34" fill="url(#sunset)"/>
|
||||||
|
<circle cx="792" cy="92" r="42" fill="#fff2bf" opacity=".82"/>
|
||||||
|
<path d="M0 318c100-64 208-84 333-48 122 35 205 35 318 0 109-34 208-20 309 38v212H0z" fill="#80a884" opacity=".7"/>
|
||||||
|
<path d="M0 395c150-39 319-33 510 17 139 36 283 33 450-8v116H0z" fill="#f6efe4"/>
|
||||||
|
<g fill="#184631" opacity=".38">
|
||||||
|
<path d="M80 294c24-65 55-65 80 0z"/>
|
||||||
|
<path d="M154 306c31-83 71-83 102 0z"/>
|
||||||
|
<path d="M704 288c28-76 64-76 92 0z"/>
|
||||||
|
<path d="M778 306c35-94 80-94 115 0z"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#shadow)" transform="translate(110 152)">
|
||||||
|
<path d="M48 118c0-35 28-63 63-63h391c27 0 51 16 62 41l28 65h77c25 0 45 20 45 45v74H48z" fill="#fffdf5"/>
|
||||||
|
<path d="M76 48c0-27 22-48 48-48h343c27 0 48 22 48 48v208H76z" fill="#f7faf2"/>
|
||||||
|
<path d="M520 103h44c18 0 35 11 42 28l23 53H520z" fill="#cde7ec"/>
|
||||||
|
<path d="M108 38h371v57H108z" fill="#143f2d"/>
|
||||||
|
<text x="129" y="80" font-family="Arial, sans-serif" font-size="43" font-weight="900" fill="#fff">РЫБ</text>
|
||||||
|
<text x="235" y="80" font-family="Arial, sans-serif" font-size="43" font-weight="900" fill="#d23b30">СТОК</text>
|
||||||
|
<text x="110" y="135" font-family="Arial, sans-serif" font-size="20" font-weight="700" fill="#143f2d">Доставка по Москве и области</text>
|
||||||
|
<path d="M94 172h405" stroke="#d23b30" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<path d="M94 193h405" stroke="#143f2d" stroke-width="8" stroke-linecap="round"/>
|
||||||
|
<rect x="104" y="221" width="124" height="23" rx="12" fill="#e8f0e8"/>
|
||||||
|
<rect x="250" y="221" width="168" height="23" rx="12" fill="#e8f0e8"/>
|
||||||
|
<circle cx="174" cy="292" r="45" fill="#10261d"/>
|
||||||
|
<circle cx="174" cy="292" r="22" fill="#dfe9e1"/>
|
||||||
|
<circle cx="590" cy="292" r="45" fill="#10261d"/>
|
||||||
|
<circle cx="590" cy="292" r="22" fill="#dfe9e1"/>
|
||||||
|
<path d="M31 261h702" stroke="#144631" stroke-width="18" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -16,6 +16,86 @@ require_once dirname(__DIR__) . '/app/bootstrap.php';
|
|||||||
|
|
||||||
$router = new Router();
|
$router = new Router();
|
||||||
|
|
||||||
|
$absoluteSiteUrl = static function (): string {
|
||||||
|
$siteUrl = rtrim((string) app_env('SITE_URL', ''), '/');
|
||||||
|
if ($siteUrl !== '') {
|
||||||
|
return $siteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = preg_replace('/[^a-z0-9.\-:]/i', '', (string) ($_SERVER['HTTP_HOST'] ?? 'new.rybstock.ru'));
|
||||||
|
|
||||||
|
return 'https://' . $host;
|
||||||
|
};
|
||||||
|
|
||||||
|
$xmlEscape = static fn (string $value): string => htmlspecialchars($value, ENT_XML1 | ENT_COMPAT, 'UTF-8');
|
||||||
|
|
||||||
|
$sitemapHandler = static function () use ($absoluteSiteUrl, $xmlEscape): void {
|
||||||
|
$baseUrl = $absoluteSiteUrl();
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$urls = [];
|
||||||
|
|
||||||
|
$addUrl = static function (
|
||||||
|
string $path,
|
||||||
|
string $priority,
|
||||||
|
string $changefreq = 'weekly',
|
||||||
|
?string $lastmod = null
|
||||||
|
) use (&$urls, $baseUrl, $today): void {
|
||||||
|
$urls[] = [
|
||||||
|
'loc' => $baseUrl . ($path === '/' ? '/' : '/' . ltrim($path, '/')),
|
||||||
|
'lastmod' => $lastmod ?: $today,
|
||||||
|
'changefreq' => $changefreq,
|
||||||
|
'priority' => $priority,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
$addUrl('/', '1.0', 'daily');
|
||||||
|
$addUrl('/catalog', '0.9', 'daily');
|
||||||
|
$addUrl('/promo', '0.8', 'daily');
|
||||||
|
$addUrl('/new', '0.8', 'daily');
|
||||||
|
$addUrl('/delivery', '0.6', 'monthly');
|
||||||
|
$addUrl('/payment', '0.5', 'monthly');
|
||||||
|
$addUrl('/contacts', '0.5', 'monthly');
|
||||||
|
$addUrl('/returns', '0.4', 'monthly');
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach ((new CategoryRepository())->active() as $category) {
|
||||||
|
$addUrl('/catalog/' . (string) $category['slug'], '0.7', 'weekly');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ((new ProductRepository())->sitemapProducts() as $product) {
|
||||||
|
$dateSource = $product['updated_at'] ?: ($product['published_at'] ?: $product['created_at']);
|
||||||
|
$lastmod = $dateSource ? date('Y-m-d', strtotime((string) $dateSource)) : $today;
|
||||||
|
$addUrl('/product/' . (string) $product['slug'], '0.6', 'weekly', $lastmod);
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
// Keep a valid sitemap available even during database maintenance.
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: application/xml; charset=UTF-8');
|
||||||
|
echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
|
||||||
|
echo "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
|
||||||
|
foreach ($urls as $url) {
|
||||||
|
echo " <url>\n";
|
||||||
|
echo ' <loc>' . $xmlEscape($url['loc']) . "</loc>\n";
|
||||||
|
echo ' <lastmod>' . $xmlEscape($url['lastmod']) . "</lastmod>\n";
|
||||||
|
echo ' <changefreq>' . $xmlEscape($url['changefreq']) . "</changefreq>\n";
|
||||||
|
echo ' <priority>' . $xmlEscape($url['priority']) . "</priority>\n";
|
||||||
|
echo " </url>\n";
|
||||||
|
}
|
||||||
|
echo "</urlset>\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
$robotsHandler = static function () use ($absoluteSiteUrl): void {
|
||||||
|
header('Content-Type: text/plain; charset=UTF-8');
|
||||||
|
echo "User-agent: *\n";
|
||||||
|
echo "Allow: /\n";
|
||||||
|
echo "Disallow: /admin/\n";
|
||||||
|
echo "Disallow: /public/admin/\n";
|
||||||
|
echo "Disallow: /cart\n";
|
||||||
|
echo "\n";
|
||||||
|
echo 'Sitemap: ' . $absoluteSiteUrl() . "/sitemap.xml\n";
|
||||||
|
};
|
||||||
|
|
||||||
$catalogSearchHandler = static function (?string $rawQuery = null): void {
|
$catalogSearchHandler = static function (?string $rawQuery = null): void {
|
||||||
$queryParameters = [];
|
$queryParameters = [];
|
||||||
parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters);
|
parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters);
|
||||||
@@ -111,11 +191,15 @@ $catalogSearchHandler = static function (?string $rawQuery = null): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$router->get('/', static fn () => (new HomeController())->index());
|
$router->get('/', static fn () => (new HomeController())->index());
|
||||||
|
$router->get('/robots.txt', $robotsHandler);
|
||||||
|
$router->get('/sitemap.xml', $sitemapHandler);
|
||||||
$router->post('/price-request', static fn () => (new PriceRequestController())->store());
|
$router->post('/price-request', static fn () => (new PriceRequestController())->store());
|
||||||
$router->post('/wishlist-request', static fn () => (new WishlistRequestController())->store());
|
$router->post('/wishlist-request', static fn () => (new WishlistRequestController())->store());
|
||||||
$router->post('/api/cart/sync', static fn () => (new CartController())->sync());
|
$router->post('/api/cart/sync', static fn () => (new CartController())->sync());
|
||||||
$router->get('/search', static fn () => $catalogSearchHandler());
|
$router->get('/search', static fn () => $catalogSearchHandler());
|
||||||
$router->get('/search/{query}', static fn (string $query) => $catalogSearchHandler($query));
|
$router->get('/search/{query}', static fn (string $query) => $catalogSearchHandler($query));
|
||||||
|
$router->get('/promo', static fn () => (new CatalogController())->promo());
|
||||||
|
$router->get('/new', static fn () => (new CatalogController())->newest());
|
||||||
$router->get('/catalog', static fn () => trim((string) ($_GET['q'] ?? '')) !== '' ? $catalogSearchHandler() : (new CatalogController())->index());
|
$router->get('/catalog', static fn () => trim((string) ($_GET['q'] ?? '')) !== '' ? $catalogSearchHandler() : (new CatalogController())->index());
|
||||||
$router->get('/delivery', static fn () => (new PageController())->delivery());
|
$router->get('/delivery', static fn () => (new PageController())->delivery());
|
||||||
$router->get('/payment', static fn () => (new PageController())->payment());
|
$router->get('/payment', static fn () => (new PageController())->payment());
|
||||||
|
|||||||
+10
-2
@@ -7,8 +7,13 @@ declare(strict_types=1);
|
|||||||
/** @var array<int, array<string, mixed>> $products */
|
/** @var array<int, array<string, mixed>> $products */
|
||||||
/** @var array<string, mixed> $pagination */
|
/** @var array<string, mixed> $pagination */
|
||||||
/** @var string|null $searchQuery */
|
/** @var string|null $searchQuery */
|
||||||
|
/** @var string|null $catalogHeading */
|
||||||
|
/** @var string|null $catalogEyebrow */
|
||||||
|
/** @var string|null $catalogLead */
|
||||||
|
|
||||||
$heading = $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции';
|
$heading = $catalogHeading ?? $currentCategory['h1'] ?? $currentCategory['name'] ?? 'Каталог продукции';
|
||||||
|
$eyebrow = $catalogEyebrow ?? ($currentCategory === null ? 'Полный каталог' : 'Категория');
|
||||||
|
$catalogLead = trim((string) ($catalogLead ?? ''));
|
||||||
$categoryDescription = trim((string) ($currentCategory['description'] ?? ''));
|
$categoryDescription = trim((string) ($currentCategory['description'] ?? ''));
|
||||||
$searchQuery = trim((string) ($searchQuery ?? ''));
|
$searchQuery = trim((string) ($searchQuery ?? ''));
|
||||||
$pagination = $pagination ?? [
|
$pagination = $pagination ?? [
|
||||||
@@ -52,8 +57,11 @@ $paginationGlue = str_contains((string) ($pagination['base_path'] ?? ''), '?') ?
|
|||||||
|
|
||||||
<section class="catalog-content">
|
<section class="catalog-content">
|
||||||
<div class="catalog-heading">
|
<div class="catalog-heading">
|
||||||
<p class="eyebrow"><?= $currentCategory === null ? 'Полный каталог' : 'Категория' ?></p>
|
<p class="eyebrow"><?= e($eyebrow) ?></p>
|
||||||
<h1><?= e($heading) ?></h1>
|
<h1><?= e($heading) ?></h1>
|
||||||
|
<?php if ($catalogLead !== ''): ?>
|
||||||
|
<p class="catalog-heading-lead"><?= e($catalogLead) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($searchQuery !== ''): ?>
|
<?php if ($searchQuery !== ''): ?>
|
||||||
|
|||||||
+78
-20
@@ -6,7 +6,13 @@ declare(strict_types=1);
|
|||||||
/** @var array<int, array<string, mixed>> $variants */
|
/** @var array<int, array<string, mixed>> $variants */
|
||||||
/** @var array<int, array<string, mixed>> $images */
|
/** @var array<int, array<string, mixed>> $images */
|
||||||
|
|
||||||
$mainImage = $product['main_image_path'] ?? '';
|
$sourceMainImage = (string) ($product['main_image_path'] ?? '');
|
||||||
|
$mainImage = function_exists('optimized_image_path')
|
||||||
|
? optimized_image_path($sourceMainImage, 'detail')
|
||||||
|
: $sourceMainImage;
|
||||||
|
$cartImage = function_exists('optimized_image_path')
|
||||||
|
? optimized_image_path($sourceMainImage, 'preview')
|
||||||
|
: $sourceMainImage;
|
||||||
$showUnitPriceForVariant = static function (array $variant) use ($product): bool {
|
$showUnitPriceForVariant = static function (array $variant) use ($product): bool {
|
||||||
if (function_exists('product_should_show_unit_price')) {
|
if (function_exists('product_should_show_unit_price')) {
|
||||||
return product_should_show_unit_price($product, $variant);
|
return product_should_show_unit_price($product, $variant);
|
||||||
@@ -38,7 +44,7 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool
|
|||||||
data-product-id="<?= e((string) ($product['id'] ?? '')) ?>"
|
data-product-id="<?= e((string) ($product['id'] ?? '')) ?>"
|
||||||
data-product-name="<?= e((string) ($product['name'] ?? '')) ?>"
|
data-product-name="<?= e((string) ($product['name'] ?? '')) ?>"
|
||||||
data-product-slug="<?= e((string) ($product['slug'] ?? '')) ?>"
|
data-product-slug="<?= e((string) ($product['slug'] ?? '')) ?>"
|
||||||
data-product-image="<?= e((string) ($product['main_image_path'] ?? '')) ?>"
|
data-product-image="<?= e($cartImage) ?>"
|
||||||
>
|
>
|
||||||
<div class="product-gallery panel">
|
<div class="product-gallery panel">
|
||||||
<?php if ($mainImage !== ''): ?>
|
<?php if ($mainImage !== ''): ?>
|
||||||
@@ -50,7 +56,12 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool
|
|||||||
<?php if ($images !== []): ?>
|
<?php if ($images !== []): ?>
|
||||||
<div class="product-thumbs">
|
<div class="product-thumbs">
|
||||||
<?php foreach ($images as $image): ?>
|
<?php foreach ($images as $image): ?>
|
||||||
<img src="<?= e((string) $image['path']) ?>" alt="<?= e((string) ($image['alt'] ?: $product['name'])) ?>">
|
<?php
|
||||||
|
$thumbPath = function_exists('optimized_image_path')
|
||||||
|
? optimized_image_path((string) $image['path'], 'preview')
|
||||||
|
: (string) $image['path'];
|
||||||
|
?>
|
||||||
|
<img src="<?= e($thumbPath) ?>" alt="<?= e((string) ($image['alt'] ?: $product['name'])) ?>" loading="lazy" decoding="async">
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -64,33 +75,78 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool
|
|||||||
<p class="product-lead"><?= e((string) $product['short_description']) ?></p>
|
<p class="product-lead"><?= e((string) $product['short_description']) ?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="product-detail-notes" aria-label="Условия заказа">
|
||||||
|
<span>Оплата при получении</span>
|
||||||
|
<span>Весовые позиции пересчитываем при сборке</span>
|
||||||
|
<span>Доставка внутри МКАД бесплатно от 5000р.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php if ($variants !== []): ?>
|
<?php if ($variants !== []): ?>
|
||||||
<section class="variant-list" aria-label="Фасовки товара">
|
<section class="variant-list product-detail-variants" aria-label="Фасовки товара">
|
||||||
<h2>Фасовки</h2>
|
<h2>Доступные фасовки</h2>
|
||||||
<?php foreach ($variants as $variant): ?>
|
<?php foreach ($variants as $variant): ?>
|
||||||
<?php $showUnitPrice = $showUnitPriceForVariant($variant); ?>
|
<?php
|
||||||
|
$showUnitPrice = $showUnitPriceForVariant($variant);
|
||||||
|
$unitLabel = unit_label((string) ($variant['unit'] ?? $product['base_unit'] ?? 'кг'));
|
||||||
|
$variantName = (string) ($variant['name'] ?? '');
|
||||||
|
$packageQuantity = (float) ($variant['package_quantity'] ?? 0);
|
||||||
|
$unitPrice = $variant['price_per_unit'] ?? null;
|
||||||
|
$packagePrice = $variant['package_price'] ?? null;
|
||||||
|
$oldPackagePrice = $variant['old_package_price'] ?? null;
|
||||||
|
$oldUnitPrice = null;
|
||||||
|
|
||||||
|
if ($showUnitPrice && $oldPackagePrice !== null && $packageQuantity > 0) {
|
||||||
|
$oldUnitPrice = (float) $oldPackagePrice / $packageQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tierBase = $showUnitPrice && $unitPrice !== null ? (float) $unitPrice : (float) ($packagePrice ?? 0);
|
||||||
|
$tierUnit = $showUnitPrice ? $unitLabel : 'фасовку';
|
||||||
|
?>
|
||||||
<div
|
<div
|
||||||
class="variant-row"
|
class="variant-row"
|
||||||
data-variant-id="<?= e((string) ($variant['id'] ?? '')) ?>"
|
data-variant-id="<?= e((string) ($variant['id'] ?? '')) ?>"
|
||||||
data-variant-name="<?= e((string) ($variant['name'] ?? '')) ?>"
|
data-variant-name="<?= e($variantName) ?>"
|
||||||
data-unit="<?= e(unit_label((string) $variant['unit'])) ?>"
|
data-unit="<?= e($unitLabel) ?>"
|
||||||
data-package-quantity="<?= e((string) ($variant['package_quantity'] ?? '')) ?>"
|
data-package-quantity="<?= e((string) ($variant['package_quantity'] ?? '')) ?>"
|
||||||
data-base-unit-price="<?= e((string) ($variant['price_per_unit'] ?? '')) ?>"
|
data-base-unit-price="<?= e((string) ($variant['price_per_unit'] ?? '')) ?>"
|
||||||
data-base-package-price="<?= e((string) ($variant['package_price'] ?? '')) ?>"
|
data-base-package-price="<?= e((string) ($variant['package_price'] ?? '')) ?>"
|
||||||
>
|
>
|
||||||
<strong><?= e((string) $variant['name']) ?></strong>
|
<div class="variant-row-head">
|
||||||
<span>
|
<span>Фасовка</span>
|
||||||
<?php if ($showUnitPrice): ?>
|
<strong><?= e($variantName) ?></strong>
|
||||||
<span data-dynamic-unit-price data-base-unit-price="<?= e((string) $variant['price_per_unit']) ?>"><?= e(money_label($variant['price_per_unit'])) ?></span> / <?= e(unit_label((string) $variant['unit'])) ?>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
|
<div class="variant-row-prices">
|
||||||
|
<span class="price-line<?= $showUnitPrice ? '' : ' is-price-placeholder' ?>" data-unit-price-row aria-hidden="<?= $showUnitPrice ? 'false' : 'true' ?>">
|
||||||
|
<span class="price-unit-label">Цена за <span data-unit-label><?= e($unitLabel) ?></span>:</span>
|
||||||
|
<span class="price-unit-values">
|
||||||
|
<span class="old-price" data-old-unit-price-display<?= $oldUnitPrice === null ? ' hidden' : '' ?>><?= e($oldUnitPrice === null ? '' : money_label($oldUnitPrice)) ?></span>
|
||||||
|
<b data-price-unit data-dynamic-unit-price data-base-unit-price="<?= e((string) $unitPrice) ?>"><?= e($unitPrice === null ? '' : money_label($unitPrice)) ?></b>
|
||||||
|
<button class="price-tier-trigger" type="button" aria-label="Показать варианты цены за наличный расчет">?</button>
|
||||||
</span>
|
</span>
|
||||||
<b>
|
</span>
|
||||||
<small>За фасовку <?= e((string) $variant['name']) ?></small>
|
|
||||||
<?php if (!empty($variant['old_package_price'])): ?>
|
<strong class="package-price-line">
|
||||||
<span class="old-price"><?= e(money_label($variant['old_package_price'])) ?></span>
|
<span class="package-price-label">За фасовку <span data-package-label-display><?= e($variantName) ?></span>:</span>
|
||||||
<?php endif; ?>
|
<span class="package-price-values">
|
||||||
<span data-dynamic-package-price data-base-package-price="<?= e((string) $variant['package_price']) ?>"><?= e(money_label($variant['package_price'])) ?></span>
|
<span class="old-price" data-old-package-price-display<?= $oldPackagePrice === null ? ' hidden' : '' ?>><?= e($oldPackagePrice === null ? '' : money_label($oldPackagePrice)) ?></span>
|
||||||
</b>
|
<span data-package-price data-dynamic-package-price data-base-package-price="<?= e((string) $packagePrice) ?>"><?= e($packagePrice === null ? '' : money_label($packagePrice)) ?></span>
|
||||||
|
<button class="price-tier-trigger" data-package-tier-trigger type="button" aria-label="Показать варианты цены за наличный расчет"<?= $showUnitPrice ? ' hidden' : '' ?>>?</button>
|
||||||
|
</span>
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<span class="package-price-note<?= $showUnitPrice ? '' : ' is-price-placeholder' ?>" data-package-price-note aria-hidden="<?= $showUnitPrice ? 'false' : 'true' ?>">Цена за <span data-unit-label-note><?= e($unitLabel) ?></span> x <span data-package-label-note><?= e($variantName) ?></span></span>
|
||||||
|
|
||||||
|
<div class="price-tier-popover" role="tooltip">
|
||||||
|
<strong>Варианты цены за <span data-tier-unit-label><?= e($tierUnit) ?></span> при наличном расчете:</strong>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Базовая цена</dt><dd data-tier-base><?= e(money_label($tierBase)) ?></dd></div>
|
||||||
|
<div><dt>При заказе от 20 000р.</dt><dd data-tier-cash-20><?= e(money_label(floor($tierBase * 0.95))) ?></dd></div>
|
||||||
|
<div><dt>При заказе от 50 000р.</dt><dd data-tier-cash-50><?= e(money_label(floor($tierBase * 0.93))) ?></dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="product-card-actions">
|
<div class="product-card-actions">
|
||||||
<div class="quantity-stepper" data-quantity-control aria-label="Количество фасовок">
|
<div class="quantity-stepper" data-quantity-control aria-label="Количество фасовок">
|
||||||
<button type="button" data-quantity-minus aria-label="Уменьшить количество">-</button>
|
<button type="button" data-quantity-minus aria-label="Уменьшить количество">-</button>
|
||||||
@@ -103,6 +159,8 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</section>
|
</section>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p class="product-detail-preorder">На сайте оформляется предзаказ. Мы перезвоним, подтвердим наличие, фактический вес и удобное получение.</p>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<?php if (!empty($product['description'])): ?>
|
<?php if (!empty($product['description'])): ?>
|
||||||
|
|||||||
+342
-47
@@ -10,15 +10,25 @@ declare(strict_types=1);
|
|||||||
/** @var bool $showBackButton */
|
/** @var bool $showBackButton */
|
||||||
$megaCategories = [];
|
$megaCategories = [];
|
||||||
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||||
|
$canonicalBase = rtrim((string) app_env('SITE_URL', ''), '/');
|
||||||
|
if ($canonicalBase === '') {
|
||||||
|
$requestHost = (string) ($_SERVER['HTTP_HOST'] ?? 'new.rybstock.ru');
|
||||||
|
$canonicalBase = 'https://' . preg_replace('/[^a-z0-9.\-:]/i', '', $requestHost);
|
||||||
|
}
|
||||||
|
$canonicalPath = '/' . ltrim($currentPath, '/');
|
||||||
|
$canonicalUrl = rtrim($canonicalBase, '/') . ($canonicalPath === '/' ? '/' : $canonicalPath);
|
||||||
$isCartPage = $currentPath === '/cart';
|
$isCartPage = $currentPath === '/cart';
|
||||||
$isCatalogLikePage = $currentPath === '/'
|
$isCatalogLikePage = $currentPath === '/'
|
||||||
|| $currentPath === '/catalog'
|
|| $currentPath === '/catalog'
|
||||||
|
|| $currentPath === '/promo'
|
||||||
|
|| $currentPath === '/new'
|
||||||
|| str_starts_with($currentPath, '/catalog/')
|
|| str_starts_with($currentPath, '/catalog/')
|
||||||
|| $currentPath === '/search'
|
|| $currentPath === '/search'
|
||||||
|| str_starts_with($currentPath, '/search/');
|
|| str_starts_with($currentPath, '/search/');
|
||||||
$isProductPage = str_starts_with($currentPath, '/product/');
|
$isProductPage = str_starts_with($currentPath, '/product/');
|
||||||
$showPriceModeBar = !$isCartPage && ($isCatalogLikePage || $isProductPage);
|
$showPriceModeBar = !$isCartPage && ($isCatalogLikePage || $isProductPage);
|
||||||
$yandexMapsApiKey = (string) app_env('YANDEX_MAPS_API_KEY', '9ab8ad56-c350-4a2c-8f23-35acb102ee7a');
|
$yandexMapsApiKey = (string) app_env('YANDEX_MAPS_API_KEY', '9ab8ad56-c350-4a2c-8f23-35acb102ee7a');
|
||||||
|
$yandexGeocoderApiKey = (string) app_env('YANDEX_GEOCODER_API_KEY', '0eaa1b4d-8e5c-454a-a40e-ca6ed7b4fbd1');
|
||||||
$queryParameters = [];
|
$queryParameters = [];
|
||||||
parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters);
|
parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters);
|
||||||
$currentSearchQuery = (string) ($_GET['q'] ?? $queryParameters['q'] ?? '');
|
$currentSearchQuery = (string) ($_GET['q'] ?? $queryParameters['q'] ?? '');
|
||||||
@@ -103,11 +113,14 @@ try {
|
|||||||
<?php if ($metaKeywords !== ''): ?>
|
<?php if ($metaKeywords !== ''): ?>
|
||||||
<meta name="keywords" content="<?= e($metaKeywords) ?>">
|
<meta name="keywords" content="<?= e($metaKeywords) ?>">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<link rel="canonical" href="<?= e($canonicalUrl) ?>">
|
||||||
<link rel="icon" type="image/svg+xml" href="<?= e($faviconHref) ?>">
|
<link rel="icon" type="image/svg+xml" href="<?= e($faviconHref) ?>">
|
||||||
<?php if ($yandexMapsApiKey !== ''): ?>
|
<?php if ($yandexMapsApiKey !== ''): ?>
|
||||||
<meta name="yandex-maps-api-key" content="<?= e($yandexMapsApiKey) ?>">
|
<meta name="yandex-maps-api-key" content="<?= e($yandexMapsApiKey) ?>">
|
||||||
|
<meta name="yandex-geocoder-api-key" content="<?= e($yandexGeocoderApiKey) ?>">
|
||||||
<script>
|
<script>
|
||||||
window.RYBSTOCK_YANDEX_MAPS_API_KEY = <?= json_encode($yandexMapsApiKey, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
window.RYBSTOCK_YANDEX_MAPS_API_KEY = <?= json_encode($yandexMapsApiKey, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
window.RYBSTOCK_YANDEX_GEOCODER_API_KEY = <?= json_encode($yandexGeocoderApiKey, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<style id="embedded-home-images">
|
<style id="embedded-home-images">
|
||||||
@@ -387,6 +400,16 @@ try {
|
|||||||
|
|
||||||
return amount;
|
return amount;
|
||||||
};
|
};
|
||||||
|
const formatPackageQuantity = (quantity, unit) => {
|
||||||
|
const normalizedUnit = String(unit || '').trim() || 'ед.';
|
||||||
|
const amount = Number(quantity || 0);
|
||||||
|
|
||||||
|
if (!amount) {
|
||||||
|
return normalizedUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${amount.toLocaleString('ru-RU')} ${normalizedUnit}`;
|
||||||
|
};
|
||||||
const cartBaseTotal = (items = getCartItems()) => items.reduce((sum, item) => {
|
const cartBaseTotal = (items = getCartItems()) => items.reduce((sum, item) => {
|
||||||
if (!isCartItemAvailable(item)) {
|
if (!isCartItemAvailable(item)) {
|
||||||
return sum;
|
return sum;
|
||||||
@@ -464,7 +487,11 @@ try {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moscowCenter = [55.755864, 37.617698];
|
const moscowCenter = [55.755864, 37.617698];
|
||||||
const mkadRadiusKm = 17.7;
|
const deliveryBounds = [[54.1, 35.0], [56.95, 40.45]];
|
||||||
|
const kmPerLatDegree = 110.574;
|
||||||
|
const kmPerLonDegree = 111.320 * Math.cos(moscowCenter[0] * Math.PI / 180);
|
||||||
|
const mkadLatRadiusKm = 18.8;
|
||||||
|
const mkadLonRadiusKm = 17.8;
|
||||||
let yandexMapsPromise = null;
|
let yandexMapsPromise = null;
|
||||||
let deliveryAddressTimer = null;
|
let deliveryAddressTimer = null;
|
||||||
|
|
||||||
@@ -514,6 +541,188 @@ try {
|
|||||||
return 2 * earthRadiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
return 2 * earthRadiusKm * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const coordsToMoscowKm = (coords) => ({
|
||||||
|
x: (coords[1] - moscowCenter[1]) * kmPerLonDegree,
|
||||||
|
y: (coords[0] - moscowCenter[0]) * kmPerLatDegree
|
||||||
|
});
|
||||||
|
|
||||||
|
const isInsideMkadApprox = (coords) => {
|
||||||
|
const point = coordsToMoscowKm(coords);
|
||||||
|
const ellipseValue = (point.x * point.x) / (mkadLonRadiusKm * mkadLonRadiusKm)
|
||||||
|
+ (point.y * point.y) / (mkadLatRadiusKm * mkadLatRadiusKm);
|
||||||
|
|
||||||
|
return ellipseValue <= 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mkadBoundaryPoint = (coords) => {
|
||||||
|
const point = coordsToMoscowKm(coords);
|
||||||
|
const angle = Math.atan2(point.y / mkadLatRadiusKm, point.x / mkadLonRadiusKm);
|
||||||
|
const x = Math.cos(angle) * mkadLonRadiusKm;
|
||||||
|
const y = Math.sin(angle) * mkadLatRadiusKm;
|
||||||
|
|
||||||
|
return [
|
||||||
|
moscowCenter[0] + y / kmPerLatDegree,
|
||||||
|
moscowCenter[1] + x / kmPerLonDegree
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const roadDistanceFromMkad = async (coords) => {
|
||||||
|
if (isInsideMkadApprox(coords)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = mkadBoundaryPoint(coords);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const route = await window.ymaps.route([boundary, coords], { routingMode: 'auto' });
|
||||||
|
const meters = Number(route.getLength ? route.getLength() : 0);
|
||||||
|
|
||||||
|
if (meters > 0) {
|
||||||
|
return Math.max(1, Math.ceil(meters / 1000));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// If route building fails, fall back to a direct distance so checkout is not blocked.
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.ceil(haversineKm(boundary, coords)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeDeliveryAddressQuery = (address) => {
|
||||||
|
const value = String(address || '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lower = value.toLocaleLowerCase('ru-RU');
|
||||||
|
const hasRegion = /москва|московская|люберц|сергиев|посад|химк|балаших|мытищ|красногорск|одинцов|видн|реутов|долгопруд|подольск|домодедов|лобн|королев|пушкино|раменск|зеленоград/.test(lower);
|
||||||
|
|
||||||
|
return hasRegion ? value : `Москва и Московская область, ${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const suggestDeliveryAddress = async (query) => {
|
||||||
|
const normalizedQuery = normalizeDeliveryAddressQuery(query);
|
||||||
|
|
||||||
|
if (normalizedQuery.length < 3) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadYandexMapsApi();
|
||||||
|
|
||||||
|
if (!window.ymaps?.suggest) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await window.ymaps.suggest(normalizedQuery, {
|
||||||
|
results: 7,
|
||||||
|
boundedBy: deliveryBounds,
|
||||||
|
strictBounds: false
|
||||||
|
});
|
||||||
|
|
||||||
|
return (items || [])
|
||||||
|
.map((item) => ({
|
||||||
|
value: String(item.value || item.displayName || '').trim(),
|
||||||
|
displayName: String(item.displayName || item.value || '').trim()
|
||||||
|
}))
|
||||||
|
.filter((item) => item.value !== '');
|
||||||
|
};
|
||||||
|
|
||||||
|
let deliverySuggestCounter = 0;
|
||||||
|
|
||||||
|
const attachDeliveryAddressSuggestions = (input) => {
|
||||||
|
if (!input || input.dataset.deliverySuggestReady === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.dataset.deliverySuggestReady = '1';
|
||||||
|
input.setAttribute('autocomplete', 'off');
|
||||||
|
|
||||||
|
const datalist = document.createElement('datalist');
|
||||||
|
datalist.id = `rybstock-delivery-addresses-${++deliverySuggestCounter}`;
|
||||||
|
document.body.append(datalist);
|
||||||
|
input.setAttribute('list', datalist.id);
|
||||||
|
|
||||||
|
let suggestTimer = null;
|
||||||
|
let suggestRequestId = 0;
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const query = input.value.trim();
|
||||||
|
window.clearTimeout(suggestTimer);
|
||||||
|
|
||||||
|
if (query.length < 3) {
|
||||||
|
datalist.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++suggestRequestId;
|
||||||
|
suggestTimer = window.setTimeout(() => {
|
||||||
|
suggestDeliveryAddress(query)
|
||||||
|
.then((items) => {
|
||||||
|
if (requestId !== suggestRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
datalist.innerHTML = '';
|
||||||
|
items.forEach((item) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.value;
|
||||||
|
if (item.displayName && item.displayName !== item.value) {
|
||||||
|
option.label = item.displayName;
|
||||||
|
}
|
||||||
|
datalist.append(option);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (requestId === suggestRequestId) {
|
||||||
|
datalist.innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupDeliveryAddressSuggestions = () => {
|
||||||
|
document
|
||||||
|
.querySelectorAll('[data-cart-address], #delivery-calculator input[name="address"]')
|
||||||
|
.forEach(attachDeliveryAddressSuggestions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveDeliveryDistance = async (address) => {
|
||||||
|
const normalizedAddress = normalizeDeliveryAddressQuery(address);
|
||||||
|
|
||||||
|
if (normalizedAddress.length < 6) {
|
||||||
|
return { distance: 0, zone: '', status: '', coords: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadYandexMapsApi();
|
||||||
|
const result = await window.ymaps.geocode(normalizedAddress, {
|
||||||
|
results: 1,
|
||||||
|
boundedBy: deliveryBounds,
|
||||||
|
strictBounds: false
|
||||||
|
});
|
||||||
|
const first = result.geoObjects.get(0);
|
||||||
|
|
||||||
|
if (!first) {
|
||||||
|
return {
|
||||||
|
distance: 0,
|
||||||
|
zone: '',
|
||||||
|
status: 'Адрес не найден. Проверьте написание, менеджер уточнит доставку по телефону.',
|
||||||
|
coords: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = first.geometry.getCoordinates();
|
||||||
|
const distance = await roadDistanceFromMkad(coords);
|
||||||
|
|
||||||
|
return distance <= 0
|
||||||
|
? { distance: 0, zone: 'inside', status: 'Адрес найден: внутри МКАД.', coords }
|
||||||
|
: { distance, zone: 'outside', status: `Адрес найден: примерно ${distance} км от МКАД.`, coords };
|
||||||
|
};
|
||||||
|
|
||||||
const resolveDeliveryAddress = async (address) => {
|
const resolveDeliveryAddress = async (address) => {
|
||||||
const normalizedAddress = String(address || '').trim();
|
const normalizedAddress = String(address || '').trim();
|
||||||
|
|
||||||
@@ -527,24 +736,15 @@ try {
|
|||||||
updateAllPrices();
|
updateAllPrices();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loadYandexMapsApi();
|
const resolved = await resolveDeliveryDistance(normalizedAddress);
|
||||||
const result = await window.ymaps.geocode(normalizedAddress, { results: 1 });
|
setDeliveryGeo(resolved.distance, resolved.zone, resolved.status);
|
||||||
const first = result.geoObjects.get(0);
|
|
||||||
|
|
||||||
if (!first) {
|
if (getDeliveryType() !== 'pickup') {
|
||||||
setDeliveryGeo(0, '', 'Адрес не найден. Проверьте написание, менеджер уточнит доставку по телефону.');
|
if (resolved.zone === 'outside') {
|
||||||
updateAllPrices();
|
localStorage.setItem(storageKeys.deliveryType, 'outside_mkad');
|
||||||
return;
|
} else if (resolved.zone === 'inside') {
|
||||||
|
localStorage.setItem(storageKeys.deliveryType, 'inside_mkad');
|
||||||
}
|
}
|
||||||
|
|
||||||
const coords = first.geometry.getCoordinates();
|
|
||||||
const distanceFromCenter = haversineKm(moscowCenter, coords);
|
|
||||||
const outsideKm = Math.max(0, Math.ceil(distanceFromCenter - mkadRadiusKm));
|
|
||||||
|
|
||||||
if (outsideKm <= 0) {
|
|
||||||
setDeliveryGeo(0, 'inside', 'Адрес найден: внутри МКАД.');
|
|
||||||
} else {
|
|
||||||
setDeliveryGeo(outsideKm, 'outside', `Адрес найден: примерно ${outsideKm} км от МКАД.`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDeliveryGeo(0, '', 'Не удалось автоматически рассчитать адрес. Менеджер уточнит доставку по телефону.');
|
setDeliveryGeo(0, '', 'Не удалось автоматически рассчитать адрес. Менеджер уточнит доставку по телефону.');
|
||||||
@@ -558,17 +758,16 @@ try {
|
|||||||
deliveryAddressTimer = window.setTimeout(() => resolveDeliveryAddress(address), 700);
|
deliveryAddressTimer = window.setTimeout(() => resolveDeliveryAddress(address), 700);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deliveryCalculation = (items = getCartItems()) => {
|
const deliveryQuote = (type, amount, distance = 0, liftSelected = false, zone = '') => {
|
||||||
const type = getDeliveryType();
|
const normalizedType = type || '';
|
||||||
const amount = cartCurrentTotal(items);
|
const normalizedAmount = Math.max(0, Number(amount) || 0);
|
||||||
const lift = getDeliveryLift() && type !== 'pickup' ? 390 : 0;
|
const normalizedDistance = Math.max(0, Math.ceil(Number(distance) || 0));
|
||||||
const distance = getDeliveryDistance();
|
const lift = liftSelected && normalizedType !== 'pickup' ? 390 : 0;
|
||||||
const zone = getDeliveryZone();
|
const minimum = deliveryMinimums[normalizedType] || 0;
|
||||||
const minimum = deliveryMinimums[type] || 0;
|
const minimumOk = normalizedType ? normalizedAmount >= minimum : false;
|
||||||
const minimumOk = type ? amount >= minimum : false;
|
const minimumMessage = normalizedType ? deliveryMinimumMessage(normalizedType, normalizedAmount) : '';
|
||||||
const minimumMessage = type ? deliveryMinimumMessage(type, amount) : '';
|
|
||||||
|
|
||||||
if (!type) {
|
if (!normalizedType) {
|
||||||
return {
|
return {
|
||||||
selected: false,
|
selected: false,
|
||||||
minimumOk: false,
|
minimumOk: false,
|
||||||
@@ -581,7 +780,7 @@ try {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'pickup') {
|
if (normalizedType === 'pickup') {
|
||||||
return {
|
return {
|
||||||
selected: true,
|
selected: true,
|
||||||
minimumOk,
|
minimumOk,
|
||||||
@@ -594,8 +793,16 @@ try {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'inside_mkad') {
|
if (normalizedType === 'inside_mkad' && (zone === 'outside' || normalizedDistance > 0)) {
|
||||||
const free = amount >= 5000;
|
return deliveryQuote('outside_mkad', normalizedAmount, normalizedDistance, liftSelected, 'outside');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType === 'outside_mkad' && zone === 'inside') {
|
||||||
|
return deliveryQuote('inside_mkad', normalizedAmount, 0, liftSelected, 'inside');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedType === 'inside_mkad') {
|
||||||
|
const free = normalizedAmount >= 5000;
|
||||||
const price = free ? 0 : 599;
|
const price = free ? 0 : 599;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -612,7 +819,7 @@ try {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'outside_mkad' && distance <= 0) {
|
if (normalizedType === 'outside_mkad' && normalizedDistance <= 0) {
|
||||||
const zoneNote = zone === 'inside'
|
const zoneNote = zone === 'inside'
|
||||||
? 'Адрес похож на адрес внутри МКАД. Выберите доставку внутри МКАД, если это верно.'
|
? 'Адрес похож на адрес внутри МКАД. Выберите доставку внутри МКАД, если это верно.'
|
||||||
: 'Введите адрес доставки, и сайт рассчитает примерное расстояние за МКАД автоматически.';
|
: 'Введите адрес доставки, и сайт рассчитает примерное расстояние за МКАД автоматически.';
|
||||||
@@ -622,16 +829,16 @@ try {
|
|||||||
minimumOk,
|
minimumOk,
|
||||||
minimumMessage,
|
minimumMessage,
|
||||||
price: 0,
|
price: 0,
|
||||||
lift,
|
lift: 0,
|
||||||
total: lift,
|
total: 0,
|
||||||
label: 'Доставка за МКАД',
|
label: 'Доставка за МКАД',
|
||||||
note: `${zoneNote} Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимум для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.`
|
note: `${zoneNote} Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимум для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const freeThreshold = distance <= 10 ? 10000 : distance * 1000;
|
const freeThreshold = normalizedDistance <= 10 ? 10000 : normalizedDistance * 1000;
|
||||||
const free = amount >= freeThreshold;
|
const free = normalizedAmount >= freeThreshold;
|
||||||
const price = free ? 0 : Math.floor(199 + distance * 60);
|
const price = free ? 0 : Math.floor(199 + normalizedDistance * 60);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selected: true,
|
selected: true,
|
||||||
@@ -641,14 +848,18 @@ try {
|
|||||||
lift,
|
lift,
|
||||||
total: price + lift,
|
total: price + lift,
|
||||||
label: free ? 'Бесплатно за МКАД' : 'Доставка за МКАД',
|
label: free ? 'Бесплатно за МКАД' : 'Доставка за МКАД',
|
||||||
note: distance > 0
|
note: free
|
||||||
? (free
|
? `Заказ проходит на бесплатную доставку: расстояние ${normalizedDistance} км от МКАД.`
|
||||||
? `Заказ проходит на бесплатную доставку: расстояние ${distance} км от МКАД.`
|
: `Расчет доставки: 199 руб. + ${normalizedDistance} км × 60 руб. Минимум для бесплатной доставки: ${formatRub(freeThreshold)}.`
|
||||||
: `Расчет доставки: 199 руб. + ${distance} км × 60 руб. Минимум для бесплатной доставки: ${formatRub(freeThreshold)}.`)
|
|
||||||
: 'Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимальная сумма для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.'
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deliveryCalculation = (items = getCartItems()) => {
|
||||||
|
const type = getDeliveryType();
|
||||||
|
const amount = cartCurrentTotal(items);
|
||||||
|
return deliveryQuote(type, amount, getDeliveryDistance(), getDeliveryLift(), getDeliveryZone());
|
||||||
|
};
|
||||||
|
|
||||||
const tierValues = (base) => ({
|
const tierValues = (base) => ({
|
||||||
base: base,
|
base: base,
|
||||||
cash20: base * 0.95,
|
cash20: base * 0.95,
|
||||||
@@ -676,6 +887,34 @@ try {
|
|||||||
const unitNode = card.querySelector('[data-dynamic-unit-price]');
|
const unitNode = card.querySelector('[data-dynamic-unit-price]');
|
||||||
updateDynamicPrices(card);
|
updateDynamicPrices(card);
|
||||||
|
|
||||||
|
const variantRows = card.querySelectorAll('.variant-row');
|
||||||
|
|
||||||
|
if (variantRows.length > 0) {
|
||||||
|
variantRows.forEach((row) => {
|
||||||
|
const rowPackageNode = row.querySelector('[data-dynamic-package-price]');
|
||||||
|
const rowUnitNode = row.querySelector('[data-dynamic-unit-price]');
|
||||||
|
const unitBase = Number(rowUnitNode?.dataset.baseUnitPrice || 0);
|
||||||
|
const packageBase = Number(rowPackageNode?.dataset.basePackagePrice || 0);
|
||||||
|
const unitPriceRow = row.querySelector('[data-unit-price-row]');
|
||||||
|
const showUnitPrice = !unitPriceRow?.classList.contains('is-price-placeholder') && unitBase > 0;
|
||||||
|
const base = showUnitPrice ? unitBase : packageBase;
|
||||||
|
const tiers = tierValues(base);
|
||||||
|
const tierBase = row.querySelector('[data-tier-base]');
|
||||||
|
const tierCash20 = row.querySelector('[data-tier-cash-20]');
|
||||||
|
const tierCash50 = row.querySelector('[data-tier-cash-50]');
|
||||||
|
const tierUnitLabel = row.querySelector('[data-tier-unit-label]');
|
||||||
|
|
||||||
|
if (tierBase) tierBase.textContent = formatRub(tiers.base);
|
||||||
|
if (tierCash20) tierCash20.textContent = formatRub(tiers.cash20);
|
||||||
|
if (tierCash50) tierCash50.textContent = formatRub(tiers.cash50);
|
||||||
|
if (tierUnitLabel) tierUnitLabel.textContent = showUnitPrice
|
||||||
|
? (row.querySelector('[data-unit-label]')?.textContent || 'кг')
|
||||||
|
: 'фасовку';
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (unitNode || packageNode) {
|
if (unitNode || packageNode) {
|
||||||
const unitBase = Number(unitNode?.dataset.baseUnitPrice || 0);
|
const unitBase = Number(unitNode?.dataset.baseUnitPrice || 0);
|
||||||
const packageBase = Number(packageNode?.dataset.basePackagePrice || 0);
|
const packageBase = Number(packageNode?.dataset.basePackagePrice || 0);
|
||||||
@@ -759,7 +998,7 @@ try {
|
|||||||
const baseTotal = cartBaseTotal(items);
|
const baseTotal = cartBaseTotal(items);
|
||||||
const currentTotal = cartCurrentTotal(items);
|
const currentTotal = cartCurrentTotal(items);
|
||||||
const savingTotal = Math.max(0, baseTotal - currentTotal);
|
const savingTotal = Math.max(0, baseTotal - currentTotal);
|
||||||
const delivery = deliveryCalculation(items);
|
let delivery = deliveryCalculation(items);
|
||||||
|
|
||||||
if (emptyNode) {
|
if (emptyNode) {
|
||||||
emptyNode.hidden = items.length !== 0;
|
emptyNode.hidden = items.length !== 0;
|
||||||
@@ -774,23 +1013,31 @@ try {
|
|||||||
const rowTotal = currentPrice * Number(item.quantity || 0);
|
const rowTotal = currentPrice * Number(item.quantity || 0);
|
||||||
const priceChangedDown = available && item.priceChange === 'down';
|
const priceChangedDown = available && item.priceChange === 'down';
|
||||||
const packageChanged = available && item.packageChanged;
|
const packageChanged = available && item.packageChanged;
|
||||||
|
const currentPackageLabel = formatPackageQuantity(item.packageQuantity, item.unit);
|
||||||
|
const originalPackageLabel = formatPackageQuantity(item.originalPackageQuantity, item.unit);
|
||||||
const priceTitle = priceChangedDown
|
const priceTitle = priceChangedDown
|
||||||
? `Цена снизилась. Было ${formatRub(item.previousPackagePrice)}, сейчас ${formatRub(item.basePackagePrice)} за фасовку.`
|
? `Цена снизилась. Было ${formatRub(item.previousPackagePrice)}, сейчас ${formatRub(item.basePackagePrice)} за фасовку.`
|
||||||
: '';
|
: '';
|
||||||
const packageTitle = packageChanged
|
const packageTitle = packageChanged
|
||||||
? `Изменилась фасовка: было ${Number(item.originalPackageQuantity || 0).toLocaleString('ru-RU')} кг, сейчас ${Number(item.packageQuantity || 0).toLocaleString('ru-RU')} кг. Цена пересчитана автоматически.`
|
? `Изменилась фасовка: было ${originalPackageLabel}, сейчас ${currentPackageLabel}. Цена пересчитана автоматически.`
|
||||||
: '';
|
: '';
|
||||||
|
const rowStateBadges = [
|
||||||
|
priceChangedDown ? '<span class="cart-row-state is-price-drop">Цена снизилась</span>' : '',
|
||||||
|
packageChanged ? '<span class="cart-row-state is-package-change">Новая фасовка</span>' : '',
|
||||||
|
!available ? '<span class="cart-row-state is-unavailable">Нет в наличии</span>' : ''
|
||||||
|
].join('');
|
||||||
const row = document.createElement('article');
|
const row = document.createElement('article');
|
||||||
row.className = 'cart-row' + (available ? '' : ' is-unavailable') + (priceChangedDown ? ' has-price-drop' : '') + (packageChanged ? ' has-package-change' : '');
|
row.className = 'cart-row' + (available ? '' : ' is-unavailable') + (priceChangedDown ? ' has-price-drop' : '') + (packageChanged ? ' has-package-change' : '');
|
||||||
row.dataset.cartKey = item.key;
|
row.dataset.cartKey = item.key;
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="cart-row-image">${item.image ? `<img src="${escapeHtml(item.image)}" alt="">` : ''}</div>
|
<div class="cart-row-image">${item.image ? `<img src="${escapeHtml(item.image)}" alt="">` : ''}</div>
|
||||||
<div class="cart-row-main">
|
<div class="cart-row-main">
|
||||||
|
<div class="cart-row-states">${rowStateBadges}</div>
|
||||||
<h2>${escapeHtml(item.name)}</h2>
|
<h2>${escapeHtml(item.name)}</h2>
|
||||||
<p>${escapeHtml(item.variantName || 'Фасовка')} · <span class="${priceChangedDown ? 'cart-row-price-drop' : ''}" title="${escapeHtml(priceTitle)}">${available ? formatRub(currentPrice) : 'недоступно'}</span> за фасовку</p>
|
<p>${escapeHtml(item.variantName || 'Фасовка')} · <span class="${priceChangedDown ? 'cart-row-price-drop' : ''}" title="${escapeHtml(priceTitle)}">${available ? formatRub(currentPrice) : 'недоступно'}</span> за фасовку</p>
|
||||||
${available ? '' : `<p class="cart-row-warning">${escapeHtml(item.unavailableReason || 'Позиция сейчас недоступна и не участвует в сумме заказа.')}</p>`}
|
${available ? '' : `<p class="cart-row-warning">${escapeHtml(item.unavailableReason || 'Позиция сейчас недоступна и не участвует в сумме заказа.')}</p>`}
|
||||||
</div>
|
</div>
|
||||||
${packageChanged ? `<p class="cart-row-package-change" title="${escapeHtml(packageTitle)}">Изменилась фасовка. Сейчас ${Number(item.packageQuantity || 0).toLocaleString('ru-RU')} кг вместо ${Number(item.originalPackageQuantity || 0).toLocaleString('ru-RU')} кг.</p>` : ''}
|
${packageChanged ? `<p class="cart-row-package-change" title="${escapeHtml(packageTitle)}">Фасовка обновилась после Вашего добавления в корзину: сейчас ${escapeHtml(currentPackageLabel)} вместо ${escapeHtml(originalPackageLabel)}. Сумма пересчитана автоматически.</p>` : ''}
|
||||||
<div class="cart-row-controls">
|
<div class="cart-row-controls">
|
||||||
<button type="button" data-cart-minus ${available ? '' : 'disabled'}>-</button>
|
<button type="button" data-cart-minus ${available ? '' : 'disabled'}>-</button>
|
||||||
<strong>${Number(item.quantity || 0)}</strong>
|
<strong>${Number(item.quantity || 0)}</strong>
|
||||||
@@ -1047,6 +1294,22 @@ try {
|
|||||||
update: updateAllPrices
|
update: updateAllPrices
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.RybStockDelivery = {
|
||||||
|
quote: deliveryQuote,
|
||||||
|
resolveAddress: resolveDeliveryDistance,
|
||||||
|
suggestAddress: suggestDeliveryAddress,
|
||||||
|
setupAddressSuggestions: setupDeliveryAddressSuggestions,
|
||||||
|
formatRub
|
||||||
|
};
|
||||||
|
|
||||||
|
setupDeliveryAddressSuggestions();
|
||||||
|
|
||||||
|
document.addEventListener('focusin', (event) => {
|
||||||
|
if (event.target.matches('[data-cart-address], #delivery-calculator input[name="address"]')) {
|
||||||
|
setupDeliveryAddressSuggestions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
syncCartWithServer();
|
syncCartWithServer();
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
@@ -1197,7 +1460,7 @@ try {
|
|||||||
const cartPage = form.closest('[data-cart-page]');
|
const cartPage = form.closest('[data-cart-page]');
|
||||||
const successNode = cartPage?.querySelector('[data-cart-order-success]');
|
const successNode = cartPage?.querySelector('[data-cart-order-success]');
|
||||||
const items = getCartItems();
|
const items = getCartItems();
|
||||||
const delivery = deliveryCalculation(items);
|
let delivery = deliveryCalculation(items);
|
||||||
const purchasableItems = purchasableCartItems(items);
|
const purchasableItems = purchasableCartItems(items);
|
||||||
const name = form.querySelector('[data-order-name]')?.value.trim() || '';
|
const name = form.querySelector('[data-order-name]')?.value.trim() || '';
|
||||||
const phone = form.querySelector('[data-order-phone]')?.value.trim() || '';
|
const phone = form.querySelector('[data-order-phone]')?.value.trim() || '';
|
||||||
@@ -1217,6 +1480,38 @@ try {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let deliveryType = getDeliveryType();
|
||||||
|
const deliveryAddress = (localStorage.getItem(storageKeys.deliveryAddress) || '').trim();
|
||||||
|
|
||||||
|
if (deliveryType !== 'pickup' && deliveryAddress === '') {
|
||||||
|
window.alert('Укажите адрес доставки.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryType !== 'pickup' && deliveryAddress !== '' && window.RybStockDelivery?.resolveAddress) {
|
||||||
|
try {
|
||||||
|
const resolved = await window.RybStockDelivery.resolveAddress(deliveryAddress);
|
||||||
|
setDeliveryGeo(resolved.distance, resolved.zone, resolved.status);
|
||||||
|
|
||||||
|
if (resolved.zone === 'outside') {
|
||||||
|
localStorage.setItem(storageKeys.deliveryType, 'outside_mkad');
|
||||||
|
} else if (resolved.zone === 'inside') {
|
||||||
|
localStorage.setItem(storageKeys.deliveryType, 'inside_mkad');
|
||||||
|
}
|
||||||
|
|
||||||
|
deliveryType = getDeliveryType();
|
||||||
|
delivery = deliveryCalculation(items);
|
||||||
|
updateAllPrices();
|
||||||
|
} catch (error) {
|
||||||
|
setDeliveryGeo(0, '', 'Не удалось автоматически рассчитать адрес. Менеджер уточнит доставку по телефону.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deliveryType === 'outside_mkad' && getDeliveryDistance() <= 0) {
|
||||||
|
window.alert('Введите адрес доставки за МКАД и дождитесь автоматического расчета расстояния.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!delivery.minimumOk) {
|
if (!delivery.minimumOk) {
|
||||||
window.alert(delivery.minimumMessage);
|
window.alert(delivery.minimumMessage);
|
||||||
return;
|
return;
|
||||||
@@ -1241,8 +1536,8 @@ try {
|
|||||||
invoiceRequisites,
|
invoiceRequisites,
|
||||||
invoiceFiles,
|
invoiceFiles,
|
||||||
paymentMode: getPaymentMode(),
|
paymentMode: getPaymentMode(),
|
||||||
deliveryType: getDeliveryType(),
|
deliveryType,
|
||||||
address: localStorage.getItem(storageKeys.deliveryAddress) || '',
|
address: deliveryAddress,
|
||||||
distance: getDeliveryDistance(),
|
distance: getDeliveryDistance(),
|
||||||
deliveryZone: getDeliveryZone(),
|
deliveryZone: getDeliveryZone(),
|
||||||
lift: getDeliveryLift(),
|
lift: getDeliveryLift(),
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<div class="cart-weight-notice">
|
<div class="cart-weight-notice">
|
||||||
Весовые позиции пересчитываются при сборке по фактическому весу. Если Вам нужна итоговая сумма до получения товара, отметьте это в форме заказа: после сборки менеджер перезвонит и сообщит сумму.
|
Весовые позиции пересчитываются при сборке по фактическому весу. Если Вам нужна итоговая сумма до получения товара, отметьте это в форме заказа: после сборки менеджер перезвонит и сообщит сумму.
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cart-weight-notice cart-live-notice">
|
||||||
|
При каждом открытии корзины сайт сверяет наличие, актуальные цены и фасовки. Если позиция закончилась, она не попадет в сумму заказа; если изменилась фасовка, мы покажем это рядом с товаром.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cart-layout cart-layout-checkout">
|
<div class="cart-layout cart-layout-checkout">
|
||||||
|
|||||||
@@ -45,7 +45,10 @@
|
|||||||
<p class="eyebrow">Как пройти</p>
|
<p class="eyebrow">Как пройти</p>
|
||||||
<h2>Маршрут к пункту самовывоза</h2>
|
<h2>Маршрут к пункту самовывоза</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="route-actions">
|
||||||
<a class="subtle-link" href="https://yandex.ru/maps/?text=Москва%2C%20улица%20Привольная%2C%2013%D0%BA1" target="_blank" rel="noopener">Открыть в Яндекс.Картах</a>
|
<a class="subtle-link" href="https://yandex.ru/maps/?text=Москва%2C%20улица%20Привольная%2C%2013%D0%BA1" target="_blank" rel="noopener">Открыть в Яндекс.Картах</a>
|
||||||
|
<a class="secondary-button" href="https://yandex.ru/maps/?rtext=%D0%BC%D0%B5%D1%82%D1%80%D0%BE%20%D0%9B%D0%B5%D1%80%D0%BC%D0%BE%D0%BD%D1%82%D0%BE%D0%B2%D1%81%D0%BA%D0%B8%D0%B9%20%D0%BF%D1%80%D0%BE%D1%81%D0%BF%D0%B5%D0%BA%D1%82~%D0%9C%D0%BE%D1%81%D0%BA%D0%B2%D0%B0%2C%20%D1%83%D0%BB.%20%D0%9F%D1%80%D0%B8%D0%B2%D0%BE%D0%BB%D1%8C%D0%BD%D0%B0%D1%8F%2C%2013%D0%BA1&rtt=pd" target="_blank" rel="noopener">Маршрут от метро</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="route-layout">
|
<div class="route-layout">
|
||||||
@@ -60,7 +63,7 @@
|
|||||||
<div class="route-steps">
|
<div class="route-steps">
|
||||||
<ol>
|
<ol>
|
||||||
<li>Доезжайте до станции метро “Лермонтовский проспект”.</li>
|
<li>Доезжайте до станции метро “Лермонтовский проспект”.</li>
|
||||||
<li>Идите к адресу: ул. Привольная, д. 13к1.</li>
|
<li>Выходите в сторону ул. Привольная и идите к дому 13к1.</li>
|
||||||
<li>Зайдите во двор дома.</li>
|
<li>Зайдите во двор дома.</li>
|
||||||
<li>Ориентир - крыльцо Wildberries, вход со двора.</li>
|
<li>Ориентир - крыльцо Wildberries, вход со двора.</li>
|
||||||
<li>Перед самовывозом дождитесь подтверждения заказа по телефону.</li>
|
<li>Перед самовывозом дождитесь подтверждения заказа по телефону.</li>
|
||||||
|
|||||||
+117
-41
@@ -25,6 +25,7 @@
|
|||||||
<label>
|
<label>
|
||||||
Адрес доставки
|
Адрес доставки
|
||||||
<input type="text" name="address" placeholder="Например: Москва, ул. Привольная, 13к1">
|
<input type="text" name="address" placeholder="Например: Москва, ул. Привольная, 13к1">
|
||||||
|
<small class="cart-address-status" id="delivery-address-status" hidden></small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@@ -35,21 +36,18 @@
|
|||||||
<label>
|
<label>
|
||||||
Способ получения
|
Способ получения
|
||||||
<select name="zone">
|
<select name="zone">
|
||||||
<option value="inside">Доставка внутри МКАД</option>
|
<option value="inside_mkad">Доставка внутри МКАД</option>
|
||||||
<option value="outside">Доставка за МКАД</option>
|
<option value="outside_mkad">Доставка за МКАД</option>
|
||||||
<option value="pickup">Самовывоз</option>
|
<option value="pickup">Самовывоз</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="km-field">
|
|
||||||
Км от МКАД
|
|
||||||
<input type="number" name="km" min="0" step="1" value="0">
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="checkbox-label">
|
<label class="checkbox-label">
|
||||||
<input type="checkbox" name="lift">
|
<input type="checkbox" name="lift">
|
||||||
Нужен подъем до квартиры/офиса
|
Нужен подъем до квартиры/офиса
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button class="secondary-button calculator-submit" type="button" data-delivery-calc-button>Рассчитать</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="calculator-result" id="delivery-result">
|
<div class="calculator-result" id="delivery-result">
|
||||||
@@ -60,7 +58,9 @@
|
|||||||
|
|
||||||
<div class="info-grid">
|
<div class="info-grid">
|
||||||
<section class="info-card">
|
<section class="info-card">
|
||||||
<div class="info-card-visual delivery-city"></div>
|
<figure class="delivery-vehicle-visual delivery-city">
|
||||||
|
<img src="/assets/img/delivery-van-city.svg" alt="Рефрижератор Рыбсток для доставки внутри МКАД">
|
||||||
|
</figure>
|
||||||
<h2>Доставка внутри МКАД</h2>
|
<h2>Доставка внутри МКАД</h2>
|
||||||
<ul class="clean-list">
|
<ul class="clean-list">
|
||||||
<li>Заказы от 5000 руб. - бесплатно до подъезда.</li>
|
<li>Заказы от 5000 руб. - бесплатно до подъезда.</li>
|
||||||
@@ -72,7 +72,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="info-card">
|
<section class="info-card">
|
||||||
<div class="info-card-visual delivery-region"></div>
|
<figure class="delivery-vehicle-visual delivery-region">
|
||||||
|
<img src="/assets/img/delivery-van-region.svg" alt="Рефрижератор Рыбсток для доставки за МКАД">
|
||||||
|
</figure>
|
||||||
<h2>Доставка за МКАД</h2>
|
<h2>Доставка за МКАД</h2>
|
||||||
<ul class="clean-list">
|
<ul class="clean-list">
|
||||||
<li>До 10 км от МКАД бесплатная доставка от 10000 руб.</li>
|
<li>До 10 км от МКАД бесплатная доставка от 10000 руб.</li>
|
||||||
@@ -113,57 +115,131 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(() => {
|
window.addEventListener('load', () => {
|
||||||
const form = document.getElementById('delivery-calculator');
|
const form = document.getElementById('delivery-calculator');
|
||||||
const result = document.getElementById('delivery-result');
|
const result = document.getElementById('delivery-result');
|
||||||
|
const addressStatus = document.getElementById('delivery-address-status');
|
||||||
|
|
||||||
if (!form || !result) {
|
if (!form || !result) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatRub = (value) => new Intl.NumberFormat('ru-RU').format(Math.max(0, Math.round(value))) + ' руб.';
|
const formatRub = (value) => window.RybStockDelivery?.formatRub
|
||||||
|
? window.RybStockDelivery.formatRub(value)
|
||||||
|
: new Intl.NumberFormat('ru-RU').format(Math.max(0, Math.round(value))) + ' руб.';
|
||||||
|
let resolvedDistance = 0;
|
||||||
|
let resolvedZone = '';
|
||||||
|
let addressTimer = null;
|
||||||
|
let addressRequestId = 0;
|
||||||
|
|
||||||
|
const setAddressStatus = (message) => {
|
||||||
|
if (!addressStatus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addressStatus.hidden = message === '';
|
||||||
|
addressStatus.textContent = message;
|
||||||
|
};
|
||||||
|
|
||||||
const calculate = () => {
|
const calculate = () => {
|
||||||
const data = new FormData(form);
|
const data = new FormData(form);
|
||||||
const orderSum = Number(data.get('order_sum') || 0);
|
const orderSum = Number(data.get('order_sum') || 0);
|
||||||
const zone = String(data.get('zone') || 'inside');
|
const zone = String(data.get('zone') || 'inside_mkad');
|
||||||
const km = Math.max(0, Number(data.get('km') || 0));
|
|
||||||
const lift = data.get('lift') === 'on';
|
const lift = data.get('lift') === 'on';
|
||||||
const liftPrice = lift ? 390 : 0;
|
const effectiveZone = resolvedZone === 'outside'
|
||||||
let delivery = 0;
|
? 'outside_mkad'
|
||||||
let title = '';
|
: (resolvedZone === 'inside' ? 'inside_mkad' : zone);
|
||||||
let note = '';
|
const distance = effectiveZone === 'outside_mkad' ? resolvedDistance : 0;
|
||||||
|
const quote = window.RybStockDelivery?.quote
|
||||||
|
? window.RybStockDelivery.quote(effectiveZone, orderSum, distance, lift, resolvedZone)
|
||||||
|
: { label: 'Расчет доставки', note: 'Обновите страницу, чтобы загрузить калькулятор.', total: 0, minimumMessage: '' };
|
||||||
|
const title = quote.selected ? `${quote.label}: ${formatRub(quote.total)}` : quote.label;
|
||||||
|
const liftNote = lift && effectiveZone !== 'pickup' ? ` Подъем включен: 390 руб.` : '';
|
||||||
|
const minimumNote = quote.minimumMessage ? ` ${quote.minimumMessage}` : '';
|
||||||
|
|
||||||
form.querySelector('.km-field').style.display = zone === 'outside' ? 'grid' : 'none';
|
result.innerHTML = `<strong>${title}</strong><span>${quote.note}${liftNote}${minimumNote}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAddress = () => {
|
||||||
|
const address = String(new FormData(form).get('address') || '').trim();
|
||||||
|
const zone = String(new FormData(form).get('zone') || 'inside_mkad');
|
||||||
|
const requestId = ++addressRequestId;
|
||||||
|
|
||||||
if (zone === 'pickup') {
|
if (zone === 'pickup') {
|
||||||
delivery = 0;
|
resolvedDistance = 0;
|
||||||
title = 'Самовывоз: бесплатно';
|
resolvedZone = '';
|
||||||
note = orderSum < 1
|
setAddressStatus('');
|
||||||
? 'Минимальная сумма заказа на самовывоз - 1 руб.'
|
calculate();
|
||||||
: 'Самовывоз возможен после подтверждения заказа.';
|
return;
|
||||||
} else if (zone === 'inside') {
|
|
||||||
delivery = orderSum >= 5000 ? 0 : 599;
|
|
||||||
title = delivery === 0 ? 'Доставка внутри МКАД: бесплатно' : 'Доставка внутри МКАД: ' + formatRub(delivery);
|
|
||||||
note = orderSum < 2000
|
|
||||||
? 'Минимальная сумма заказа на доставку внутри МКАД - 2000 руб.'
|
|
||||||
: 'Бесплатная доставка внутри МКАД от 5000 руб.';
|
|
||||||
} else {
|
|
||||||
const freeThreshold = km <= 10 ? 10000 : Math.ceil(km) * 1000;
|
|
||||||
delivery = orderSum >= freeThreshold ? 0 : 199 + km * 60;
|
|
||||||
title = delivery === 0 ? 'Доставка за МКАД: бесплатно' : 'Доставка за МКАД: ' + formatRub(delivery);
|
|
||||||
note = 'Порог бесплатной доставки для этого расстояния: ' + formatRub(freeThreshold) + '. Платная доставка: 199 руб. + 60 руб./км.';
|
|
||||||
if (orderSum < 5000) {
|
|
||||||
note += ' Минимальная сумма заказа за МКАД - 5000 руб.';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = delivery + liftPrice;
|
if (address.length < 6) {
|
||||||
result.innerHTML = '<strong>' + title + '</strong><span>' + note + (lift ? ' Подъем: 390 руб. Итого с подъемом: ' + formatRub(total) + '.' : '') + '</span>';
|
resolvedDistance = 0;
|
||||||
|
resolvedZone = '';
|
||||||
|
setAddressStatus('Введите адрес и выберите вариант из подсказки, чтобы сайт рассчитал доставку автоматически.');
|
||||||
|
calculate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAddressStatus('Рассчитываем расстояние по адресу...');
|
||||||
|
|
||||||
|
if (!window.RybStockDelivery?.resolveAddress) {
|
||||||
|
resolvedDistance = 0;
|
||||||
|
resolvedZone = '';
|
||||||
|
setAddressStatus('Калькулятор еще загружается. Если расчет не появился, обновите страницу.');
|
||||||
|
calculate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.RybStockDelivery.resolveAddress(address)
|
||||||
|
.then((resolved) => {
|
||||||
|
if (requestId !== addressRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedDistance = Number(resolved.distance || 0);
|
||||||
|
resolvedZone = String(resolved.zone || '');
|
||||||
|
if (resolvedZone === 'outside') {
|
||||||
|
form.querySelector('[name="zone"]').value = 'outside_mkad';
|
||||||
|
} else if (resolvedZone === 'inside') {
|
||||||
|
form.querySelector('[name="zone"]').value = 'inside_mkad';
|
||||||
|
}
|
||||||
|
setAddressStatus(resolved.status || '');
|
||||||
|
calculate();
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (requestId !== addressRequestId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedDistance = 0;
|
||||||
|
resolvedZone = '';
|
||||||
|
setAddressStatus('Не удалось автоматически рассчитать адрес. Менеджер уточнит доставку по телефону.');
|
||||||
|
calculate();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleResolveAddress = () => {
|
||||||
|
window.clearTimeout(addressTimer);
|
||||||
|
addressTimer = window.setTimeout(resolveAddress, 700);
|
||||||
};
|
};
|
||||||
|
|
||||||
form.addEventListener('input', calculate);
|
form.addEventListener('input', calculate);
|
||||||
form.addEventListener('change', calculate);
|
form.addEventListener('input', (event) => {
|
||||||
|
if (event.target.matches('[name="address"]')) {
|
||||||
|
scheduleResolveAddress();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
form.addEventListener('change', () => {
|
||||||
|
resolveAddress();
|
||||||
calculate();
|
calculate();
|
||||||
})();
|
});
|
||||||
|
form.querySelector('[data-delivery-calc-button]')?.addEventListener('click', () => {
|
||||||
|
resolveAddress();
|
||||||
|
calculate();
|
||||||
|
});
|
||||||
|
window.RybStockDelivery?.setupAddressSuggestions?.();
|
||||||
|
resolveAddress();
|
||||||
|
calculate();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -295,6 +295,68 @@ $arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->for
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<section class="wishlist-request-panel" id="wishlist-request">
|
||||||
|
<div class="wishlist-request-copy">
|
||||||
|
<p class="eyebrow">Запрос позиции</p>
|
||||||
|
<h2>Одна голова хорошо, две лучше</h2>
|
||||||
|
<p>Нужна позиция, но нигде не можете ее найти? Отправьте нам наименование и объем. Если сможем помочь с поиском, менеджер свяжется с Вами.</p>
|
||||||
|
<span>Для частных покупателей и бизнеса</span>
|
||||||
|
</div>
|
||||||
|
<div class="wishlist-request-card">
|
||||||
|
<form class="wishlist-request-form" action="/wishlist-request" method="post" enctype="multipart/form-data">
|
||||||
|
<label>
|
||||||
|
<span>Имя</span>
|
||||||
|
<input type="text" name="customer_name" placeholder="Как к Вам обращаться" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Телефон</span>
|
||||||
|
<input type="tel" name="phone" placeholder="+7 ___ ___-__-__">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Email</span>
|
||||||
|
<input type="email" name="email" placeholder="mail@example.ru">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
<span>Что нужно найти</span>
|
||||||
|
<textarea name="request_text" rows="5" placeholder="Например: филе судака 10 кг, икра в стеклянной банке, креветки нужного размера" required></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="file-field wide">
|
||||||
|
<span>Прикрепить файлы</span>
|
||||||
|
<input type="file" name="wishlist_files[]" multiple>
|
||||||
|
<small>Можно приложить фото, список или пример товара.</small>
|
||||||
|
</label>
|
||||||
|
<p class="form-hint wide">Телефон или email - достаточно одного контакта.</p>
|
||||||
|
<button class="primary-button wide" type="submit">Отправить запрос</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="home-delivery-summary" aria-label="Доставка и выгода Рыбсток">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Перед оформлением заказа</p>
|
||||||
|
<h2>Доставка, оплата при получении и подарок за наличный расчет</h2>
|
||||||
|
<p>Мы привозим заказы после подтверждения по телефону, а оплату принимаем только при получении. При оплате наличными сайт сам пересчитает выгоду в корзине: считать вручную ничего не нужно.</p>
|
||||||
|
</div>
|
||||||
|
<div class="home-delivery-benefits">
|
||||||
|
<article>
|
||||||
|
<span>от 7 000 руб.</span>
|
||||||
|
<strong>-300 руб.</strong>
|
||||||
|
<p>Подарок при оплате наличными.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>внутри МКАД</span>
|
||||||
|
<strong>0 руб.</strong>
|
||||||
|
<p>Бесплатно от 5000 руб., иначе 599 руб.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span>за МКАД</span>
|
||||||
|
<strong>по адресу</strong>
|
||||||
|
<p>Сайт рассчитает предварительную стоимость доставки.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<a class="primary-button" href="/delivery">Посмотреть условия доставки</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
<?php if (false): ?>
|
<?php if (false): ?>
|
||||||
<div class="content-grid">
|
<div class="content-grid">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ if (!function_exists('category_tree_has_current')) {
|
|||||||
<a href="/catalog"><span>Весь каталог</span></a>
|
<a href="/catalog"><span>Весь каталог</span></a>
|
||||||
</li>
|
</li>
|
||||||
<li class="category-special-link">
|
<li class="category-special-link">
|
||||||
<a href="/#home-promos"><span>Акции</span></a>
|
<a href="/promo"><span>Акции</span></a>
|
||||||
</li>
|
</li>
|
||||||
<li class="category-special-link">
|
<li class="category-special-link">
|
||||||
<a href="/#home-new"><span>Новинки</span></a>
|
<a href="/new"><span>Новинки</span></a>
|
||||||
</li>
|
</li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php foreach ($items as $category): ?>
|
<?php foreach ($items as $category): ?>
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ if (!function_exists('render_mega_category_children')) {
|
|||||||
<nav class="category-mega-bar" aria-label="Категории товаров">
|
<nav class="category-mega-bar" aria-label="Категории товаров">
|
||||||
<div class="category-mega-inner">
|
<div class="category-mega-inner">
|
||||||
<a class="mega-root mega-all-link" href="/catalog">Весь каталог</a>
|
<a class="mega-root mega-all-link" href="/catalog">Весь каталог</a>
|
||||||
<a class="mega-root mega-virtual-link is-promo" href="/#home-promos">Акции</a>
|
<a class="mega-root mega-virtual-link is-promo" href="/promo">Акции</a>
|
||||||
<a class="mega-root mega-virtual-link is-new" href="/#home-new">Новинки</a>
|
<a class="mega-root mega-virtual-link is-new" href="/new">Новинки</a>
|
||||||
<?php foreach ($megaCategories as $category): ?>
|
<?php foreach ($megaCategories as $category): ?>
|
||||||
<?php
|
<?php
|
||||||
$hiddenTopCategorySlugs = ['myasnaya-gastronomiya', 'molochnaya-produkciya'];
|
$hiddenTopCategorySlugs = ['myasnaya-gastronomiya', 'molochnaya-produkciya'];
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ $activeTierUnit = $activeShowsUnitPrice ? unit_label((string) ($activeVariant['u
|
|||||||
$isPublished = (int) ($product['is_published'] ?? 1) === 1;
|
$isPublished = (int) ($product['is_published'] ?? 1) === 1;
|
||||||
$isAvailable = (int) ($product['is_available'] ?? 1) === 1;
|
$isAvailable = (int) ($product['is_available'] ?? 1) === 1;
|
||||||
$isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
|
$isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
|
||||||
|
$sourceImagePath = (string) ($product['main_image_path'] ?? '');
|
||||||
|
$previewImagePath = function_exists('optimized_image_path')
|
||||||
|
? optimized_image_path($sourceImagePath, 'preview')
|
||||||
|
: $sourceImagePath;
|
||||||
?>
|
?>
|
||||||
<article
|
<article
|
||||||
class="product-card price-aware<?= $isPurchasable ? '' : ' is-unavailable' ?>"
|
class="product-card price-aware<?= $isPurchasable ? '' : ' is-unavailable' ?>"
|
||||||
@@ -67,7 +71,7 @@ $isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
|
|||||||
data-product-id="<?= e((string) ($product['id'] ?? '')) ?>"
|
data-product-id="<?= e((string) ($product['id'] ?? '')) ?>"
|
||||||
data-product-name="<?= e((string) ($product['name'] ?? '')) ?>"
|
data-product-name="<?= e((string) ($product['name'] ?? '')) ?>"
|
||||||
data-product-slug="<?= e((string) ($product['slug'] ?? '')) ?>"
|
data-product-slug="<?= e((string) ($product['slug'] ?? '')) ?>"
|
||||||
data-product-image="<?= e((string) ($product['main_image_path'] ?? '')) ?>"
|
data-product-image="<?= e($previewImagePath) ?>"
|
||||||
data-show-unit-price="<?= $activeShowsUnitPrice ? '1' : '0' ?>"
|
data-show-unit-price="<?= $activeShowsUnitPrice ? '1' : '0' ?>"
|
||||||
>
|
>
|
||||||
<?php if (!$isPurchasable): ?>
|
<?php if (!$isPurchasable): ?>
|
||||||
@@ -77,13 +81,13 @@ $isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
|
|||||||
<span class="product-card-badge is-<?= e((string) ($product['badge_kind'] ?? 'default')) ?>"><?= e((string) $product['badge_label']) ?></span>
|
<span class="product-card-badge is-<?= e((string) ($product['badge_kind'] ?? 'default')) ?>"><?= e((string) $product['badge_label']) ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (!empty($product['main_image_path']) && $isPurchasable): ?>
|
<?php if ($previewImagePath !== '' && $isPurchasable): ?>
|
||||||
<a class="product-card-image" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
|
<a class="product-card-image" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
|
||||||
<img src="<?= e((string) $product['main_image_path']) ?>" alt="<?= e((string) $product['name']) ?>" loading="lazy" decoding="async">
|
<img src="<?= e($previewImagePath) ?>" alt="<?= e((string) $product['name']) ?>" loading="lazy" decoding="async">
|
||||||
</a>
|
</a>
|
||||||
<?php elseif (!empty($product['main_image_path'])): ?>
|
<?php elseif ($previewImagePath !== ''): ?>
|
||||||
<div class="product-card-image" aria-label="<?= e($product['name']) ?>">
|
<div class="product-card-image" aria-label="<?= e($product['name']) ?>">
|
||||||
<img src="<?= e((string) $product['main_image_path']) ?>" alt="<?= e((string) $product['name']) ?>" loading="lazy" decoding="async">
|
<img src="<?= e($previewImagePath) ?>" alt="<?= e((string) $product['name']) ?>" loading="lazy" decoding="async">
|
||||||
</div>
|
</div>
|
||||||
<?php elseif ($isPurchasable): ?>
|
<?php elseif ($isPurchasable): ?>
|
||||||
<a class="product-card-image product-card-image-placeholder" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
|
<a class="product-card-image product-card-image-placeholder" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
|
||||||
|
|||||||
Reference in New Issue
Block a user