Compare commits

..

5 Commits

Author SHA1 Message Date
Alisa 256afcbb80 доставка в плавающем 2026-06-08 10:58:47 +03:00
Alisa 3a065b34d6 главная добита по визуалу 2026-06-07 23:13:03 +03:00
Alisa 66e931e0d6 js css 2026-06-07 21:14:30 +03:00
Alisa 76bf5d8191 корзина кеш 2026-06-07 17:43:45 +03:00
Alisa 170bea76d5 переключатель цены убран где не нужно 2026-06-07 14:59:33 +03:00
30 changed files with 3162 additions and 180 deletions
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Repositories\ProductRepository;
final class CartController
{
public function sync(): void
{
header('Content-Type: application/json; charset=utf-8');
$payload = json_decode((string) file_get_contents('php://input'), true);
$items = is_array($payload) && isset($payload['items']) && is_array($payload['items'])
? $payload['items']
: [];
if ($items === []) {
echo json_encode(['items' => []], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
try {
$snapshots = (new ProductRepository())->cartSnapshots($items);
echo json_encode(['items' => $snapshots], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (\Throwable) {
http_response_code(500);
echo json_encode([
'items' => [],
'error' => 'cart_sync_failed',
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
}
+50
View File
@@ -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
{
$_GET['q'] = trim($query);
+100 -1
View File
@@ -18,6 +18,7 @@ final class HomeController
$categories = [];
$catalogProducts = [];
$popularProducts = [];
$homeCatalogSections = [];
$settings = [];
$notice = null;
@@ -25,10 +26,27 @@ final class HomeController
Database::pdo()->query('SELECT 1');
$dbStatus = 'connected';
$categories = (new CategoryRepository())->tree();
$categoryRepository = new CategoryRepository();
$categories = $categoryRepository->tree();
$productRepository = new ProductRepository();
$catalogProducts = $productRepository->catalogPreview(24);
$popularProducts = $productRepository->popularPreview(10);
$homeCatalogSections[] = [
'key' => 'promos',
'title' => 'Акции',
'url' => '/promo',
'count' => 50,
'kind' => 'promo',
'products' => $productRepository->promoPreview(10),
];
$homeCatalogSections[] = [
'key' => 'new',
'title' => 'Новинки',
'url' => '/new',
'count' => 50,
'kind' => 'new',
'products' => $productRepository->newPreview(10),
];
$settings = (new SettingRepository())->allKeyed();
} catch (Throwable $exception) {
$dbStatus = app_env('APP_DEBUG', 'false') === 'true'
@@ -49,9 +67,90 @@ final class HomeController
'categories' => $categories,
'catalogProducts' => $catalogProducts,
'popularProducts' => $popularProducts,
'homeCatalogSections' => $homeCatalogSections,
'settings' => $settings,
'notice' => $notice,
'buildVersion' => '2026-06-04-02',
]);
}
/**
* @param array<int, array<string, mixed>> $categoryTree
* @param array<int, string> $slugs
* @return array{category: array<string, mixed>, showcase: array{products: array<int, array<string, mixed>>, total: int}}|null
*/
private function homeCategoryShowcase(
CategoryRepository $categoryRepository,
ProductRepository $productRepository,
array $categoryTree,
array $slugs,
string $title
): ?array {
$candidates = [];
$seenIds = [];
foreach ($slugs as $slug) {
$category = $categoryRepository->findBySlug($slug);
if ($category !== null) {
$categoryId = (int) $category['id'];
$seenIds[$categoryId] = true;
$candidates[] = $category;
}
}
foreach ($this->findCategoriesByName($categoryTree, $title) as $category) {
$categoryId = (int) $category['id'];
if (isset($seenIds[$categoryId])) {
continue;
}
$seenIds[$categoryId] = true;
$candidates[] = $category;
}
$fallback = null;
foreach ($candidates as $category) {
$showcase = $productRepository->categoryRetailShowcase((int) $category['id'], 10);
$match = [
'category' => $category,
'showcase' => $showcase,
];
if ($showcase['products'] !== []) {
return $match;
}
$fallback ??= $match;
}
return $fallback;
}
/**
* @param array<int, array<string, mixed>> $categories
* @return array<int, array<string, mixed>>
*/
private function findCategoriesByName(array $categories, string $name): array
{
$matches = [];
$normalizedName = $this->normalizeCategoryName($name);
foreach ($categories as $category) {
if ($this->normalizeCategoryName((string) ($category['name'] ?? '')) === $normalizedName) {
$matches[] = $category;
}
$matches = array_merge(
$matches,
$this->findCategoriesByName($category['children'] ?? [], $name)
);
}
return $matches;
}
private function normalizeCategoryName(string $name): string
{
return trim(str_replace('ё', 'е', mb_strtolower($name)));
}
}
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Database;
use App\Repositories\SettingRepository;
use Throwable;
final class WishlistRequestController
{
public function store(): void
{
$customerName = trim((string) ($_POST['customer_name'] ?? ''));
$phone = trim((string) ($_POST['phone'] ?? ''));
$email = trim((string) ($_POST['email'] ?? ''));
$requestText = trim((string) ($_POST['request_text'] ?? ''));
if ($customerName === '' || $requestText === '' || ($phone === '' && $email === '')) {
$this->redirect('error');
}
try {
$uploadedFiles = $this->storeUploadedFiles();
$fullRequestText = $this->buildRequestText($requestText, $uploadedFiles);
$statement = Database::pdo()->prepare(
'INSERT INTO price_requests
(customer_name, phone, email, company_name, request_text, file_path, status, client_ip, user_agent)
VALUES
(:customer_name, :phone, :email, :company_name, :request_text, :file_path, :status, :client_ip, :user_agent)'
);
$statement->execute([
'customer_name' => $customerName,
'phone' => $phone !== '' ? $phone : null,
'email' => $email !== '' ? $email : null,
'company_name' => 'Запрос позиции',
'request_text' => $fullRequestText,
'file_path' => $uploadedFiles[0] ?? null,
'status' => 'new',
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => substr((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''), 0, 255),
]);
$requestId = (int) Database::pdo()->lastInsertId();
$this->sendNotification($requestId, $customerName, $phone, $email, $fullRequestText);
} catch (Throwable $exception) {
$this->redirect('error');
}
$this->redirect('sent');
}
/**
* @return array<int, string>
*/
private function storeUploadedFiles(): array
{
if (!isset($_FILES['wishlist_files']) || !is_array($_FILES['wishlist_files']['name'])) {
return [];
}
$stored = [];
$uploadDir = base_path('storage/wishlist_requests/' . date('Y/m'));
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
foreach ($_FILES['wishlist_files']['name'] as $index => $originalName) {
$error = (int) ($_FILES['wishlist_files']['error'][$index] ?? UPLOAD_ERR_NO_FILE);
if ($error === UPLOAD_ERR_NO_FILE || $error !== UPLOAD_ERR_OK) {
continue;
}
$tmpName = (string) ($_FILES['wishlist_files']['tmp_name'][$index] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
continue;
}
$extension = strtolower(pathinfo((string) $originalName, PATHINFO_EXTENSION));
$safeExtension = preg_replace('/[^a-z0-9]/', '', $extension) ?: 'file';
$fileName = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . $safeExtension;
$targetPath = $uploadDir . '/' . $fileName;
if (move_uploaded_file($tmpName, $targetPath)) {
$stored[] = str_replace('\\', '/', substr($targetPath, strlen(base_path()) + 1));
}
}
return $stored;
}
/**
* @param array<int, string> $uploadedFiles
*/
private function buildRequestText(string $requestText, array $uploadedFiles): string
{
$lines = [
'Тип заявки: поиск позиции / виш-лист.',
'',
'Что нужно найти:',
$requestText,
];
if ($uploadedFiles !== []) {
$lines[] = '';
$lines[] = 'Файлы:';
foreach ($uploadedFiles as $path) {
$lines[] = '- ' . $path;
}
}
return implode("\n", $lines);
}
private function sendNotification(int $requestId, string $customerName, string $phone, string $email, string $requestText): void
{
$settings = (new SettingRepository())->allKeyed();
$to = trim((string) (($settings['price_request_email'] ?? '') ?: ($settings['admin_email'] ?? '') ?: 'zakaz@rybstock.ru'));
if ($to === '') {
return;
}
$subject = 'Запрос позиции #' . $requestId . ' - ' . $customerName;
$body = implode("\n", [
'Новый запрос на поиск позиции.',
'',
'Номер заявки: ' . $requestId,
'Имя: ' . $customerName,
'Телефон: ' . ($phone !== '' ? $phone : 'не указан'),
'Email: ' . ($email !== '' ? $email : 'не указан'),
'',
$requestText,
]);
$headers = [
'Content-Type: text/plain; charset=UTF-8',
'From: Рыбсток <no-reply@rybstock.ru>',
];
@mail($to, $subject, $body, implode("\r\n", $headers));
}
private function redirect(string $status): never
{
header('Location: /?wishlist_request=' . rawurlencode($status) . '#wishlist-request', true, 303);
exit;
}
}
+425 -1
View File
@@ -25,6 +25,141 @@ final class ProductRepository extends BaseRepository
return $this->preparePreviewProducts($statement->fetchAll());
}
/**
* @return array<int, array<string, mixed>>
*/
public function promoPreview(int $limit = 10): array
{
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);
$usedIds = array_flip(array_map(static fn (array $product): int => (int) ($product['id'] ?? 0), $products));
$fallbackProducts = $this->catalogPreview(5000);
$fallbackProducts = array_values(array_filter(array_map(
fn (array $product): ?array => $this->withRetailPreviewVariant($product, 6.0),
$fallbackProducts
)));
usort($fallbackProducts, static function (array $left, array $right): int {
return (int) sprintf('%u', crc32('promo|' . ($left['id'] ?? 0)))
<=> (int) sprintf('%u', crc32('promo|' . ($right['id'] ?? 0)));
});
foreach ($fallbackProducts as $fallbackProduct) {
$fallbackId = (int) ($fallbackProduct['id'] ?? 0);
if (isset($usedIds[$fallbackId])) {
continue;
}
$products[] = $fallbackProduct;
if (count($products) >= $limit) {
break;
}
}
return array_map(
fn (array $product): array => $this->applyPromoPricing($product),
array_slice($products, 0, $limit)
);
}
/**
* @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>>
*/
public function newPreview(int $limit = 10): array
{
$limit = max(1, min($limit, 50));
$sql = $this->previewSelectSql(
'ORDER BY p.created_at DESC, p.id DESC',
$limit,
0,
false
);
$statement = $this->pdo()->query($sql);
$products = $this->preparePreviewProducts($statement->fetchAll());
foreach ($products as &$product) {
$product['badge_label'] = 'Новинка';
$product['badge_kind'] = 'new';
}
unset($product);
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>>
*/
public function categoryRetailPreview(int $categoryId, int $limit = 10, float $maxPackageQuantity = 6.0): array
{
return $this->categoryRetailShowcase($categoryId, $limit, $maxPackageQuantity)['products'];
}
/**
* @return array{products: array<int, array<string, mixed>>, total: int}
*/
public function categoryRetailShowcase(int $categoryId, int $limit = 10, float $maxPackageQuantity = 6.0): array
{
$products = $this->forCategory($categoryId, 5000, 0, false);
$total = count($products);
$products = array_values(array_filter(array_map(
fn (array $product): ?array => $this->withRetailPreviewVariant($product, $maxPackageQuantity),
$products
)));
usort($products, static function (array $left, array $right): int {
return [
(int) sprintf('%u', crc32('home|' . ($left['id'] ?? 0))),
(string) ($left['name'] ?? ''),
] <=> [
(int) sprintf('%u', crc32('home|' . ($right['id'] ?? 0))),
(string) ($right['name'] ?? ''),
];
});
return [
'products' => array_slice($products, 0, max(1, min($limit, 10))),
'total' => $total,
];
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -52,6 +187,23 @@ final class ProductRepository extends BaseRepository
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}
*/
@@ -192,7 +344,17 @@ final class ProductRepository extends BaseRepository
);
$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()
);
}
/**
@@ -211,6 +373,147 @@ final class ProductRepository extends BaseRepository
return $statement->fetchAll();
}
/**
* @param array<int, array<string, mixed>> $items
* @return array<int, array<string, mixed>>
*/
public function cartSnapshots(array $items): array
{
$result = [];
foreach ($items as $item) {
$key = (string) ($item['key'] ?? '');
$productId = (int) ($item['productId'] ?? 0);
$variantId = (int) ($item['variantId'] ?? 0);
$desiredPackageQuantity = (float) ($item['packageQuantity'] ?? 0);
if ($key === '' || $productId <= 0) {
continue;
}
$variantJoin = $variantId > 0
? 'v.product_id = p.id AND v.id = :variant_id'
: 'v.id = (
SELECT pv.id
FROM product_variants pv
WHERE pv.product_id = p.id
ORDER BY pv.is_default DESC, pv.sort_order, pv.id
LIMIT 1
)';
$statement = $this->pdo()->prepare(
'SELECT
p.id AS product_id,
p.name,
p.slug,
p.main_image_path,
p.is_published AS product_is_published,
p.is_available AS product_is_available,
v.id AS variant_id,
v.name AS variant_name,
v.unit,
v.package_quantity,
v.price_per_unit,
v.package_price,
v.is_published AS variant_is_published,
v.is_available AS variant_is_available
FROM products p
LEFT JOIN product_variants v ON ' . $variantJoin . '
WHERE p.id = :product_id
LIMIT 1'
);
$params = ['product_id' => $productId];
if ($variantId > 0) {
$params['variant_id'] = $variantId;
}
$statement->execute($params);
$row = $statement->fetch();
if (!$row) {
$result[] = [
'key' => $key,
'isAvailable' => false,
'reason' => 'Товар больше не найден в каталоге.',
];
continue;
}
if (empty($row['variant_id'])) {
$fallbackStatement = $this->pdo()->prepare(
'SELECT
p.id AS product_id,
p.name,
p.slug,
p.main_image_path,
p.is_published AS product_is_published,
p.is_available AS product_is_available,
v.id AS variant_id,
v.name AS variant_name,
v.unit,
v.package_quantity,
v.price_per_unit,
v.package_price,
v.is_published AS variant_is_published,
v.is_available AS variant_is_available
FROM products p
INNER JOIN product_variants v ON v.product_id = p.id
WHERE p.id = :product_id
ORDER BY
(v.is_published = 1 AND v.is_available = 1 AND v.package_price > 0) DESC,
CASE WHEN :package_quantity_check > 0 THEN ABS(v.package_quantity - :package_quantity) ELSE 0 END,
v.is_default DESC,
v.sort_order,
v.id
LIMIT 1'
);
$fallbackStatement->execute([
'product_id' => $productId,
'package_quantity' => $desiredPackageQuantity,
'package_quantity_check' => $desiredPackageQuantity,
]);
$fallbackRow = $fallbackStatement->fetch();
if ($fallbackRow) {
$row = $fallbackRow;
}
}
$productIsAvailable = (int) ($row['product_is_published'] ?? 0) === 1
&& (int) ($row['product_is_available'] ?? 0) === 1;
$variantIsAvailable = !empty($row['variant_id'])
&& (int) ($row['variant_is_published'] ?? 0) === 1
&& (int) ($row['variant_is_available'] ?? 0) === 1
&& (float) ($row['package_price'] ?? 0) > 0;
$reason = '';
if (!$productIsAvailable) {
$reason = 'Товар сейчас не опубликован или отсутствует в наличии.';
} elseif (!$variantIsAvailable) {
$reason = 'Выбранная фасовка сейчас недоступна.';
}
$result[] = [
'key' => $key,
'productId' => (int) $row['product_id'],
'variantId' => !empty($row['variant_id']) ? (int) $row['variant_id'] : $variantId,
'name' => (string) ($row['name'] ?? ''),
'slug' => (string) ($row['slug'] ?? ''),
'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'] ?? 'Фасовка'),
'unit' => (string) ($row['unit'] ?? ''),
'packageQuantity' => (float) ($row['package_quantity'] ?? 0),
'baseUnitPrice' => (float) ($row['price_per_unit'] ?? 0),
'basePackagePrice' => (float) ($row['package_price'] ?? 0),
'isAvailable' => $productIsAvailable && $variantIsAvailable,
'reason' => $reason,
];
}
return $result;
}
/**
* @return array<int, array<string, mixed>>
*/
@@ -340,6 +643,127 @@ final class ProductRepository extends BaseRepository
LIMIT ' . $limit . ' OFFSET ' . $offset;
}
/**
* @param array<string, mixed> $product
* @return array<string, mixed>|null
*/
private function withRetailPreviewVariant(array $product, float $maxPackageQuantity): ?array
{
$variants = array_values(array_filter(
$product['preview_variants'] ?? [],
static function (array $variant) use ($maxPackageQuantity): bool {
$packagePrice = (float) ($variant['package_price'] ?? 0);
$packageQuantity = (float) ($variant['package_quantity'] ?? 0);
return $packagePrice > 0
&& $packageQuantity > 0
&& $packageQuantity <= $maxPackageQuantity;
}
));
if ($variants === []) {
return null;
}
$product['preview_variants'] = $variants;
return $this->syncProductVariantFields($product, $variants[0]);
}
/**
* @return array<int, array<string, mixed>>
*/
private function manualPromoPreview(int $limit): array
{
$statement = $this->pdo()->query(
'SELECT product_id
FROM promo_block_items
WHERE is_active = 1
ORDER BY sort_order, id
LIMIT ' . max(1, min($limit, 10))
);
$productIds = array_map('intval', $statement->fetchAll(\PDO::FETCH_COLUMN));
if ($productIds === []) {
return [];
}
$placeholders = implode(',', array_fill(0, count($productIds), '?'));
$sql = $this->previewSelectSql(
'AND p.id IN (' . $placeholders . ')
ORDER BY FIELD(p.id, ' . $placeholders . ')',
count($productIds),
0,
false
);
$productsStatement = $this->pdo()->prepare($sql);
$productsStatement->execute(array_merge($productIds, $productIds));
$products = $this->preparePreviewProducts($productsStatement->fetchAll());
$productsById = [];
foreach ($products as $product) {
$retailProduct = $this->withRetailPreviewVariant($product, 6.0);
if ($retailProduct !== null) {
$productsById[(int) ($product['id'] ?? 0)] = $retailProduct;
}
}
$result = [];
foreach ($productIds as $productId) {
if (isset($productsById[$productId])) {
$result[] = $productsById[$productId];
}
}
return $result;
}
/**
* @param array<string, mixed> $product
* @param array<string, mixed> $variant
* @return array<string, mixed>
*/
private function syncProductVariantFields(array $product, array $variant): array
{
$product['variant_id'] = $variant['id'] ?? $product['variant_id'] ?? null;
$product['variant_name'] = $variant['name'] ?? $product['variant_name'] ?? null;
$product['unit'] = $variant['unit'] ?? $product['unit'] ?? null;
$product['package_quantity'] = $variant['package_quantity'] ?? $product['package_quantity'] ?? null;
$product['price_per_unit'] = $variant['price_per_unit'] ?? $product['price_per_unit'] ?? null;
$product['package_price'] = $variant['package_price'] ?? $product['package_price'] ?? null;
$product['old_package_price'] = $variant['old_package_price'] ?? $product['old_package_price'] ?? null;
return $product;
}
/**
* @param array<string, mixed> $product
* @return array<string, mixed>
*/
private function applyPromoPricing(array $product): array
{
$discountPercent = 5 + ((int) sprintf('%u', crc32('discount|' . ($product['id'] ?? 0))) % 31);
$divider = max(0.01, 1 - ($discountPercent / 100));
$variants = [];
foreach (($product['preview_variants'] ?? []) as $variant) {
$packagePrice = (float) ($variant['package_price'] ?? 0);
if ($packagePrice > 0) {
$variant['old_package_price'] = (string) ceil($packagePrice / $divider);
}
$variants[] = $variant;
}
$product['preview_variants'] = $variants;
$product = $variants === [] ? $product : $this->syncProductVariantFields($product, $variants[0]);
$product['badge_label'] = '-' . $discountPercent . '%';
$product['badge_kind'] = 'promo';
$product['promo_discount_percent'] = $discountPercent;
return $product;
}
/**
* @param array<int, array<string, mixed>> $products
* @return array<int, array<string, mixed>>
+52
View File
@@ -22,6 +22,58 @@ function base_path(string $path = ''): string
return $path === '' ? $base : $base . '/' . ltrim($path, '/');
}
function versioned_asset(string $path): string
{
$assetPath = '/' . ltrim($path, '/');
$pathWithoutQuery = strtok($assetPath, '?') ?: $assetPath;
$querySeparator = str_contains($assetPath, '?') ? '&' : '?';
$publicPath = base_path('public' . $pathWithoutQuery);
if (!is_file($publicPath)) {
return $assetPath;
}
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
{
View::render($template, $data);
+1 -1
View File
@@ -229,7 +229,7 @@ $progressPercent = !empty($progress['done'])
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Импорт старого сайта - Рыбсток</title>
<link rel="stylesheet" href="/assets/css/app.css">
<link rel="stylesheet" href="<?= e(versioned_asset('/assets/css/app.css')) ?>">
</head>
<body>
<main class="page">
+1136 -35
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

+44
View File
@@ -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

+42
View File
@@ -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

+88
View File
@@ -3,9 +3,11 @@
declare(strict_types=1);
use App\Controllers\CatalogController;
use App\Controllers\CartController;
use App\Controllers\HomeController;
use App\Controllers\PageController;
use App\Controllers\PriceRequestController;
use App\Controllers\WishlistRequestController;
use App\Core\Router;
use App\Repositories\CategoryRepository;
use App\Repositories\ProductRepository;
@@ -14,6 +16,86 @@ require_once dirname(__DIR__) . '/app/bootstrap.php';
$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 {
$queryParameters = [];
parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters);
@@ -109,9 +191,15 @@ $catalogSearchHandler = static function (?string $rawQuery = null): void {
};
$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('/wishlist-request', static fn () => (new WishlistRequestController())->store());
$router->post('/api/cart/sync', static fn () => (new CartController())->sync());
$router->get('/search', static fn () => $catalogSearchHandler());
$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('/delivery', static fn () => (new PageController())->delivery());
$router->get('/payment', static fn () => (new PageController())->payment());
+1 -1
View File
@@ -82,7 +82,7 @@ function run_sql_file(PDO $pdo, string $path): void
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Установка базы Рыбсток</title>
<link rel="stylesheet" href="/assets/css/app.css">
<link rel="stylesheet" href="<?= e(versioned_asset('/assets/css/app.css')) ?>">
</head>
<body>
<main class="page">
+10 -2
View File
@@ -7,8 +7,13 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $products */
/** @var array<string, mixed> $pagination */
/** @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'] ?? ''));
$searchQuery = trim((string) ($searchQuery ?? ''));
$pagination = $pagination ?? [
@@ -52,8 +57,11 @@ $paginationGlue = str_contains((string) ($pagination['base_path'] ?? ''), '?') ?
<section class="catalog-content">
<div class="catalog-heading">
<p class="eyebrow"><?= $currentCategory === null ? 'Полный каталог' : 'Категория' ?></p>
<p class="eyebrow"><?= e($eyebrow) ?></p>
<h1><?= e($heading) ?></h1>
<?php if ($catalogLead !== ''): ?>
<p class="catalog-heading-lead"><?= e($catalogLead) ?></p>
<?php endif; ?>
</div>
<?php if ($searchQuery !== ''): ?>
+80 -20
View File
@@ -6,7 +6,13 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $variants */
/** @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 {
if (function_exists('product_should_show_unit_price')) {
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-name="<?= e((string) ($product['name'] ?? '')) ?>"
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">
<?php if ($mainImage !== ''): ?>
@@ -50,7 +56,12 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool
<?php if ($images !== []): ?>
<div class="product-thumbs">
<?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; ?>
</div>
<?php endif; ?>
@@ -64,31 +75,78 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool
<p class="product-lead"><?= e((string) $product['short_description']) ?></p>
<?php endif; ?>
<div class="product-detail-notes" aria-label="Условия заказа">
<span>Оплата при получении</span>
<span>Весовые позиции пересчитываем при сборке</span>
<span>Доставка внутри МКАД бесплатно от 5000р.</span>
</div>
<?php if ($variants !== []): ?>
<section class="variant-list" aria-label="Фасовки товара">
<h2>Фасовки</h2>
<section class="variant-list product-detail-variants" aria-label="Фасовки товара">
<h2>Доступные фасовки</h2>
<?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
class="variant-row"
data-variant-id="<?= e((string) ($variant['id'] ?? '')) ?>"
data-variant-name="<?= e((string) ($variant['name'] ?? '')) ?>"
data-variant-name="<?= e($variantName) ?>"
data-unit="<?= e($unitLabel) ?>"
data-package-quantity="<?= e((string) ($variant['package_quantity'] ?? '')) ?>"
data-base-unit-price="<?= e((string) ($variant['price_per_unit'] ?? '')) ?>"
data-base-package-price="<?= e((string) ($variant['package_price'] ?? '')) ?>"
>
<strong><?= e((string) $variant['name']) ?></strong>
<span>
<?php if ($showUnitPrice): ?>
<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'])) ?>
<?php endif; ?>
</span>
<b>
<small>За фасовку <?= e((string) $variant['name']) ?></small>
<?php if (!empty($variant['old_package_price'])): ?>
<span class="old-price"><?= e(money_label($variant['old_package_price'])) ?></span>
<?php endif; ?>
<span data-dynamic-package-price data-base-package-price="<?= e((string) $variant['package_price']) ?>"><?= e(money_label($variant['package_price'])) ?></span>
</b>
<div class="variant-row-head">
<span>Фасовка</span>
<strong><?= e($variantName) ?></strong>
</div>
<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>
<strong class="package-price-line">
<span class="package-price-label">За фасовку <span data-package-label-display><?= e($variantName) ?></span>:</span>
<span class="package-price-values">
<span class="old-price" data-old-package-price-display<?= $oldPackagePrice === null ? ' hidden' : '' ?>><?= e($oldPackagePrice === null ? '' : money_label($oldPackagePrice)) ?></span>
<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="quantity-stepper" data-quantity-control aria-label="Количество фасовок">
<button type="button" data-quantity-minus aria-label="Уменьшить количество">-</button>
@@ -101,6 +159,8 @@ $showUnitPriceForVariant = static function (array $variant) use ($product): bool
<?php endforeach; ?>
</section>
<?php endif; ?>
<p class="product-detail-preorder">На сайте оформляется предзаказ. Мы перезвоним, подтвердим наличие, фактический вес и удобное получение.</p>
</article>
<?php if (!empty($product['description'])): ?>
+607 -69
View File
@@ -10,8 +10,25 @@ declare(strict_types=1);
/** @var bool $showBackButton */
$megaCategories = [];
$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';
$isCatalogLikePage = $currentPath === '/'
|| $currentPath === '/catalog'
|| $currentPath === '/promo'
|| $currentPath === '/new'
|| str_starts_with($currentPath, '/catalog/')
|| $currentPath === '/search'
|| str_starts_with($currentPath, '/search/');
$isProductPage = str_starts_with($currentPath, '/product/');
$showPriceModeBar = !$isCartPage && ($isCatalogLikePage || $isProductPage);
$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 = [];
parse_str((string) (parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_QUERY) ?? ''), $queryParameters);
$currentSearchQuery = (string) ($_GET['q'] ?? $queryParameters['q'] ?? '');
@@ -70,13 +87,13 @@ $faviconSvg = <<<'SVG'
SVG;
$faviconHref = 'data:image/svg+xml,' . rawurlencode($faviconSvg);
$homeEmbeddedImages = [
'home-hero-picnic' => '/assets/images/home/hero-picnic-sunset.png',
'home-warehouse-fish' => '/assets/images/home/warehouse-fish.jpg',
'home-banner-meat' => '/assets/images/home/banner-meat-grill.png',
'home-banner-seafood' => '/assets/images/home/banner-seafood-fresh.png',
'home-warehouse-main' => '/assets/images/home/warehouse-main.jpg',
'home-warehouse-arrival' => '/assets/images/home/warehouse-arrival.jpg',
'home-warehouse-cold' => '/assets/images/home/warehouse-cold.jpg',
'home-hero-picnic' => '/assets/images/home/hero-picnic-sunset.webp',
'home-warehouse-fish' => '/assets/images/home/warehouse-fish.webp',
'home-banner-meat' => '/assets/images/home/banner-meat-grill.webp',
'home-banner-seafood' => '/assets/images/home/banner-seafood-fresh.webp',
'home-warehouse-main' => '/assets/images/home/warehouse-main.webp',
'home-warehouse-arrival' => '/assets/images/home/warehouse-arrival.webp',
'home-warehouse-cold' => '/assets/images/home/warehouse-cold.webp',
];
try {
$megaCategories = (new \App\Repositories\CategoryRepository())->tree();
@@ -96,11 +113,14 @@ try {
<?php if ($metaKeywords !== ''): ?>
<meta name="keywords" content="<?= e($metaKeywords) ?>">
<?php endif; ?>
<link rel="canonical" href="<?= e($canonicalUrl) ?>">
<link rel="icon" type="image/svg+xml" href="<?= e($faviconHref) ?>">
<?php if ($yandexMapsApiKey !== ''): ?>
<meta name="yandex-maps-api-key" content="<?= e($yandexMapsApiKey) ?>">
<meta name="yandex-geocoder-api-key" content="<?= e($yandexGeocoderApiKey) ?>">
<script>
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>
<?php endif; ?>
<style id="embedded-home-images">
@@ -111,7 +131,7 @@ try {
}
</style>
<meta name="theme-color" content="#173d2c">
<link rel="stylesheet" href="/assets/css/app.css">
<link rel="stylesheet" href="<?= e(versioned_asset('/assets/css/app.css')) ?>">
</head>
<body>
<header class="site-topbar">
@@ -149,7 +169,7 @@ try {
<?php require base_path('views/partials/mega-category-menu.php'); ?>
<?php if (!$isCartPage): ?>
<?php if ($showPriceModeBar): ?>
<section class="price-mode-bar" aria-label="Система оплаты при получении">
<div class="price-mode-inner">
<span>Выберите систему оплаты при получении:</span>
@@ -358,7 +378,43 @@ try {
return [];
}
};
const isCartItemAvailable = (item) => item && item.isAvailable !== false && item.unavailable !== true;
const purchasableCartItems = (items = getCartItems()) => items.filter(isCartItemAvailable);
const parsePackageQuantity = (value) => {
const match = String(value || '').replace(',', '.').match(/(\d+(?:\.\d+)?)\s*([a-zа-яё]*)/i);
if (!match) {
return 0;
}
const amount = Number(match[1] || 0);
const unit = String(match[2] || '').toLowerCase();
if (!amount) {
return 0;
}
if (unit.startsWith('г') || unit === 'g') {
return amount / 1000;
}
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) => {
if (!isCartItemAvailable(item)) {
return sum;
}
return sum + (Number(item.basePackagePrice || 0) * Number(item.quantity || 0));
}, 0);
const saveCartItems = (items) => {
@@ -431,7 +487,11 @@ try {
};
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 deliveryAddressTimer = null;
@@ -481,6 +541,188 @@ try {
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 normalizedAddress = String(address || '').trim();
@@ -494,24 +736,15 @@ try {
updateAllPrices();
try {
await loadYandexMapsApi();
const result = await window.ymaps.geocode(normalizedAddress, { results: 1 });
const first = result.geoObjects.get(0);
const resolved = await resolveDeliveryDistance(normalizedAddress);
setDeliveryGeo(resolved.distance, resolved.zone, resolved.status);
if (!first) {
setDeliveryGeo(0, '', 'Адрес не найден. Проверьте написание, менеджер уточнит доставку по телефону.');
updateAllPrices();
return;
}
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} км от МКАД.`);
if (getDeliveryType() !== 'pickup') {
if (resolved.zone === 'outside') {
localStorage.setItem(storageKeys.deliveryType, 'outside_mkad');
} else if (resolved.zone === 'inside') {
localStorage.setItem(storageKeys.deliveryType, 'inside_mkad');
}
}
} catch (error) {
setDeliveryGeo(0, '', 'Не удалось автоматически рассчитать адрес. Менеджер уточнит доставку по телефону.');
@@ -525,17 +758,16 @@ try {
deliveryAddressTimer = window.setTimeout(() => resolveDeliveryAddress(address), 700);
};
const deliveryCalculation = (items = getCartItems()) => {
const type = getDeliveryType();
const amount = cartCurrentTotal(items);
const lift = getDeliveryLift() && type !== 'pickup' ? 390 : 0;
const distance = getDeliveryDistance();
const zone = getDeliveryZone();
const minimum = deliveryMinimums[type] || 0;
const minimumOk = type ? amount >= minimum : false;
const minimumMessage = type ? deliveryMinimumMessage(type, amount) : '';
const deliveryQuote = (type, amount, distance = 0, liftSelected = false, zone = '') => {
const normalizedType = type || '';
const normalizedAmount = Math.max(0, Number(amount) || 0);
const normalizedDistance = Math.max(0, Math.ceil(Number(distance) || 0));
const lift = liftSelected && normalizedType !== 'pickup' ? 390 : 0;
const minimum = deliveryMinimums[normalizedType] || 0;
const minimumOk = normalizedType ? normalizedAmount >= minimum : false;
const minimumMessage = normalizedType ? deliveryMinimumMessage(normalizedType, normalizedAmount) : '';
if (!type) {
if (!normalizedType) {
return {
selected: false,
minimumOk: false,
@@ -548,7 +780,7 @@ try {
};
}
if (type === 'pickup') {
if (normalizedType === 'pickup') {
return {
selected: true,
minimumOk,
@@ -561,8 +793,16 @@ try {
};
}
if (type === 'inside_mkad') {
const free = amount >= 5000;
if (normalizedType === 'inside_mkad' && (zone === 'outside' || normalizedDistance > 0)) {
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;
return {
@@ -579,7 +819,7 @@ try {
};
}
if (type === 'outside_mkad' && distance <= 0) {
if (normalizedType === 'outside_mkad' && normalizedDistance <= 0) {
const zoneNote = zone === 'inside'
? 'Адрес похож на адрес внутри МКАД. Выберите доставку внутри МКАД, если это верно.'
: 'Введите адрес доставки, и сайт рассчитает примерное расстояние за МКАД автоматически.';
@@ -589,16 +829,16 @@ try {
minimumOk,
minimumMessage,
price: 0,
lift,
total: lift,
lift: 0,
total: 0,
label: 'Доставка за МКАД',
note: `${zoneNote} Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимум для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.`
};
}
const freeThreshold = distance <= 10 ? 10000 : distance * 1000;
const free = amount >= freeThreshold;
const price = free ? 0 : Math.floor(199 + distance * 60);
const freeThreshold = normalizedDistance <= 10 ? 10000 : normalizedDistance * 1000;
const free = normalizedAmount >= freeThreshold;
const price = free ? 0 : Math.floor(199 + normalizedDistance * 60);
return {
selected: true,
@@ -608,14 +848,18 @@ try {
lift,
total: price + lift,
label: free ? 'Бесплатно за МКАД' : 'Доставка за МКАД',
note: distance > 0
? (free
? `Заказ проходит на бесплатную доставку: расстояние ${distance} км от МКАД.`
: `Расчет доставки: 199 руб. + ${distance} км × 60 руб. Минимум для бесплатной доставки: ${formatRub(freeThreshold)}.`)
: 'Доставка за МКАД доступна от 5000 руб. До 10 км от МКАД бесплатная доставка от 10000 руб.; дальше минимальная сумма для бесплатной доставки считается как 1000 руб. за каждый км от МКАД. Если заказ не проходит на бесплатную доставку, расчет: 199 руб. + 60 руб. за км.'
note: free
? `Заказ проходит на бесплатную доставку: расстояние ${normalizedDistance} км от МКАД.`
: `Расчет доставки: 199 руб. + ${normalizedDistance} км × 60 руб. Минимум для бесплатной доставки: ${formatRub(freeThreshold)}.`
};
};
const deliveryCalculation = (items = getCartItems()) => {
const type = getDeliveryType();
const amount = cartCurrentTotal(items);
return deliveryQuote(type, amount, getDeliveryDistance(), getDeliveryLift(), getDeliveryZone());
};
const tierValues = (base) => ({
base: base,
cash20: base * 0.95,
@@ -643,6 +887,34 @@ try {
const unitNode = card.querySelector('[data-dynamic-unit-price]');
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) {
const unitBase = Number(unitNode?.dataset.baseUnitPrice || 0);
const packageBase = Number(packageNode?.dataset.basePackagePrice || 0);
@@ -667,7 +939,7 @@ try {
};
const updateCartCount = () => {
const count = getCartItems().reduce((sum, item) => sum + Number(item.quantity || 0), 0);
const count = purchasableCartItems().reduce((sum, item) => sum + Number(item.quantity || 0), 0);
document.querySelectorAll('[data-cart-count]').forEach((node) => {
node.textContent = String(count);
@@ -677,7 +949,7 @@ try {
const updateMiniCart = () => {
const items = getCartItems();
const count = items.reduce((sum, item) => sum + Number(item.quantity || 0), 0);
const count = purchasableCartItems(items).reduce((sum, item) => sum + Number(item.quantity || 0), 0);
const total = cartCurrentTotal(items);
document.querySelectorAll('[data-mini-cart-count]').forEach((node) => {
@@ -689,7 +961,9 @@ try {
});
};
const itemCurrentPrice = (item) => Math.floor(Number(item.basePackagePrice || 0) * priceMode().multiplier);
const itemCurrentPrice = (item) => isCartItemAvailable(item)
? Math.floor(Number(item.basePackagePrice || 0) * priceMode().multiplier)
: 0;
const renderCartPage = () => {
const cartPage = document.querySelector('[data-cart-page]');
@@ -715,11 +989,16 @@ try {
const addressStatusNode = cartPage.querySelector('[data-cart-address-status]');
const distanceInput = cartPage.querySelector('[data-cart-distance]');
const liftInput = cartPage.querySelector('[data-cart-lift]');
const syncNoteNode = cartPage.querySelector('[data-cart-sync-note]');
const clearButton = cartPage.querySelector('[data-cart-clear]');
const items = getCartItems();
const unavailableCount = items.filter((item) => !isCartItemAvailable(item)).length;
const loweredCount = items.filter((item) => isCartItemAvailable(item) && item.priceChange === 'down').length;
const packageChangedCount = items.filter((item) => isCartItemAvailable(item) && item.packageChanged).length;
const baseTotal = cartBaseTotal(items);
const currentTotal = cartCurrentTotal(items);
const savingTotal = Math.max(0, baseTotal - currentTotal);
const delivery = deliveryCalculation(items);
let delivery = deliveryCalculation(items);
if (emptyNode) {
emptyNode.hidden = items.length !== 0;
@@ -729,27 +1008,67 @@ try {
itemsNode.innerHTML = '';
items.forEach((item) => {
const available = isCartItemAvailable(item);
const currentPrice = itemCurrentPrice(item);
const rowTotal = currentPrice * Number(item.quantity || 0);
const priceChangedDown = available && item.priceChange === 'down';
const packageChanged = available && item.packageChanged;
const currentPackageLabel = formatPackageQuantity(item.packageQuantity, item.unit);
const originalPackageLabel = formatPackageQuantity(item.originalPackageQuantity, item.unit);
const priceTitle = priceChangedDown
? `Цена снизилась. Было ${formatRub(item.previousPackagePrice)}, сейчас ${formatRub(item.basePackagePrice)} за фасовку.`
: '';
const packageTitle = packageChanged
? `Изменилась фасовка: было ${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');
row.className = 'cart-row';
row.className = 'cart-row' + (available ? '' : ' is-unavailable') + (priceChangedDown ? ' has-price-drop' : '') + (packageChanged ? ' has-package-change' : '');
row.dataset.cartKey = item.key;
row.innerHTML = `
<div class="cart-row-image">${item.image ? `<img src="${escapeHtml(item.image)}" alt="">` : ''}</div>
<div class="cart-row-main">
<div class="cart-row-states">${rowStateBadges}</div>
<h2>${escapeHtml(item.name)}</h2>
<p>${escapeHtml(item.variantName || 'Фасовка')} · ${formatRub(itemCurrentPrice(item))} за фасовку</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>`}
</div>
${packageChanged ? `<p class="cart-row-package-change" title="${escapeHtml(packageTitle)}">Фасовка обновилась после Вашего добавления в корзину: сейчас ${escapeHtml(currentPackageLabel)} вместо ${escapeHtml(originalPackageLabel)}. Сумма пересчитана автоматически.</p>` : ''}
<div class="cart-row-controls">
<button type="button" data-cart-minus>-</button>
<button type="button" data-cart-minus ${available ? '' : 'disabled'}>-</button>
<strong>${Number(item.quantity || 0)}</strong>
<button type="button" data-cart-plus>+</button>
<button type="button" data-cart-plus ${available ? '' : 'disabled'}>+</button>
</div>
<strong class="cart-row-total">${formatRub(itemCurrentPrice(item) * Number(item.quantity || 0))}</strong>
<strong class="cart-row-total">${available ? formatRub(rowTotal) : 'Не входит в заказ'}</strong>
<button class="cart-row-remove" type="button" data-cart-remove>Убрать</button>
`;
itemsNode.append(row);
});
}
if (syncNoteNode) {
const messages = [];
if (unavailableCount > 0) {
messages.push(`${unavailableCount} поз. сейчас недоступно и не участвует в сумме.`);
}
if (loweredCount > 0) {
messages.push(`${loweredCount} поз. подешевело с момента добавления.`);
}
if (packageChangedCount > 0) {
messages.push(`${packageChangedCount} поз. получили новую фасовку и пересчитаны по актуальному весу.`);
}
syncNoteNode.hidden = messages.length === 0;
syncNoteNode.textContent = messages.join(' ');
}
if (clearButton) {
clearButton.hidden = items.length === 0;
}
if (baseTotalNode) {
baseTotalNode.textContent = formatRub(baseTotal);
}
@@ -837,6 +1156,129 @@ try {
renderCartPage();
};
let cartSyncPromise = null;
const syncCartWithServer = async () => {
const items = getCartItems();
if (items.length === 0) {
return;
}
if (cartSyncPromise) {
return cartSyncPromise;
}
cartSyncPromise = fetch('/api/cart/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
items: items.map((item) => ({
key: item.key,
productId: item.productId,
variantId: item.variantId,
packageQuantity: item.packageQuantity || item.originalPackageQuantity || parsePackageQuantity(item.variantName || item.originalVariantName) || 0
}))
})
})
.then((response) => {
if (!response.ok) {
throw new Error('Cart sync failed');
}
return response.json();
})
.then((payload) => {
const snapshots = new Map((payload.items || []).map((item) => [String(item.key), item]));
const syncedItems = items.map((item) => {
const snapshot = snapshots.get(String(item.key));
const firstBasePackagePrice = Number(item.firstBasePackagePrice || item.basePackagePrice || snapshot?.basePackagePrice || 0);
const originalPackageQuantity = Number(item.originalPackageQuantity || item.packageQuantity || parsePackageQuantity(item.originalVariantName || item.variantName) || snapshot?.packageQuantity || 0);
const originalVariantName = item.originalVariantName || item.variantName || '';
if (!snapshot) {
return {
...item,
firstBasePackagePrice,
originalPackageQuantity,
originalVariantName,
isAvailable: false,
unavailable: true,
unavailableReason: 'Товар больше не найден в каталоге.',
priceChange: ''
};
}
const basePackagePrice = Number(snapshot.basePackagePrice || 0);
const baseUnitPrice = Number(snapshot.baseUnitPrice || 0);
const packageQuantity = Number(snapshot.packageQuantity || 0);
const isAvailable = snapshot.isAvailable !== false && basePackagePrice > 0;
const packageChanged = isAvailable && originalPackageQuantity > 0 && packageQuantity > 0 && Math.abs(packageQuantity - originalPackageQuantity) > 0.001;
const priceWentDown = !packageChanged && isAvailable && firstBasePackagePrice > 0 && basePackagePrice > 0 && basePackagePrice < firstBasePackagePrice;
return {
...item,
productId: String(snapshot.productId || item.productId || ''),
variantId: String(snapshot.variantId || item.variantId || ''),
name: snapshot.name || item.name,
slug: snapshot.slug || item.slug,
image: snapshot.image || item.image,
variantName: snapshot.variantName || item.variantName || 'Фасовка',
originalVariantName,
unit: snapshot.unit || item.unit || '',
basePackagePrice,
baseUnitPrice,
packageQuantity,
originalPackageQuantity,
firstBasePackagePrice,
isAvailable,
unavailable: !isAvailable,
unavailableReason: isAvailable ? '' : (snapshot.reason || 'Позиция сейчас недоступна.'),
packageChanged,
priceChange: priceWentDown ? 'down' : '',
previousPackagePrice: priceWentDown ? firstBasePackagePrice : 0
};
});
const mergedItems = [];
syncedItems.forEach((item) => {
const normalizedKey = item.productId && item.variantId ? `${item.productId}:${item.variantId}` : item.key;
const existingItem = mergedItems.find((mergedItem) => mergedItem.key === normalizedKey);
item.key = normalizedKey;
if (existingItem) {
existingItem.quantity = Number(existingItem.quantity || 0) + Number(item.quantity || 0);
const existingFirstBasePackagePrice = Number(existingItem.firstBasePackagePrice || 0);
const itemFirstBasePackagePrice = Number(item.firstBasePackagePrice || 0);
existingItem.firstBasePackagePrice = existingFirstBasePackagePrice > 0 && itemFirstBasePackagePrice > 0
? Math.min(existingFirstBasePackagePrice, itemFirstBasePackagePrice)
: Math.max(existingFirstBasePackagePrice, itemFirstBasePackagePrice);
existingItem.originalPackageQuantity = Number(existingItem.originalPackageQuantity || item.originalPackageQuantity || 0);
} else {
mergedItems.push(item);
}
});
saveCartItems(mergedItems);
updateAllPrices();
})
.catch(() => {
const note = document.querySelector('[data-cart-sync-note]');
if (note) {
note.hidden = false;
note.textContent = 'Не удалось сверить корзину с актуальным наличием. Менеджер дополнительно проверит заказ по телефону.';
}
})
.finally(() => {
cartSyncPromise = null;
});
return cartSyncPromise;
};
window.RybStockPricing = {
setCartTotal(total) {
localStorage.setItem(storageKeys.cartTotal, String(Math.max(0, Number(total) || 0)));
@@ -852,6 +1294,30 @@ try {
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();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
syncCartWithServer();
}
});
const catalogScrollKey = 'rybstock.catalogScrollY';
const catalogMenuScrollKey = 'rybstock.catalogMenuScrollY';
@@ -981,7 +1447,7 @@ try {
}
});
document.addEventListener('submit', (event) => {
document.addEventListener('submit', async (event) => {
const form = event.target.closest('[data-cart-order-form]');
if (!form) {
@@ -989,11 +1455,13 @@ try {
}
event.preventDefault();
await syncCartWithServer();
const cartPage = form.closest('[data-cart-page]');
const successNode = cartPage?.querySelector('[data-cart-order-success]');
const items = getCartItems();
const delivery = deliveryCalculation(items);
let delivery = deliveryCalculation(items);
const purchasableItems = purchasableCartItems(items);
const name = form.querySelector('[data-order-name]')?.value.trim() || '';
const phone = form.querySelector('[data-order-phone]')?.value.trim() || '';
const email = form.querySelector('[data-order-email]')?.value.trim() || '';
@@ -1002,7 +1470,7 @@ try {
const invoiceRequisites = form.closest('[data-cart-page]')?.querySelector('[data-order-invoice-requisites]')?.value.trim() || '';
const invoiceFiles = [...(form.closest('[data-cart-page]')?.querySelector('[data-order-invoice-file]')?.files || [])].map((file) => file.name);
if (items.length === 0) {
if (purchasableItems.length === 0) {
window.alert('Добавьте товары в корзину.');
return;
}
@@ -1012,6 +1480,38 @@ try {
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) {
window.alert(delivery.minimumMessage);
return;
@@ -1036,12 +1536,12 @@ try {
invoiceRequisites,
invoiceFiles,
paymentMode: getPaymentMode(),
deliveryType: getDeliveryType(),
address: localStorage.getItem(storageKeys.deliveryAddress) || '',
deliveryType,
address: deliveryAddress,
distance: getDeliveryDistance(),
deliveryZone: getDeliveryZone(),
lift: getDeliveryLift(),
items,
items: purchasableItems,
productsTotal,
deliveryTotal: delivery.total,
grandTotal
@@ -1116,9 +1616,26 @@ try {
const key = String(productScope.dataset.productId || '') + ':' + variantId;
const items = getCartItems();
const existing = items.find((item) => item.key === key);
const basePackagePrice = Number(packagePriceNode.dataset.basePackagePrice || 0);
const baseUnitPrice = Number(row?.dataset.baseUnitPrice || variantButton?.dataset.baseUnitPrice || 0);
const packageQuantity = Number(row?.dataset.packageQuantity || variantButton?.dataset.packageQuantity || 0);
const unit = row?.dataset.unit || variantButton?.dataset.unit || '';
const variantName = row?.dataset.variantName || variantButton?.dataset.variantName || 'Фасовка';
if (existing) {
const storedPackagePrice = Number(existing.basePackagePrice || basePackagePrice || 0);
const storedPackageQuantity = Number(existing.packageQuantity || packageQuantity || 0);
existing.quantity = Number(existing.quantity || 0) + addQuantity;
existing.basePackagePrice = basePackagePrice;
existing.baseUnitPrice = baseUnitPrice || Number(existing.baseUnitPrice || 0);
existing.packageQuantity = packageQuantity || Number(existing.packageQuantity || 0);
existing.unit = unit || existing.unit || '';
existing.firstBasePackagePrice = Number(existing.firstBasePackagePrice || storedPackagePrice || basePackagePrice || 0);
existing.originalPackageQuantity = Number(existing.originalPackageQuantity || storedPackageQuantity || packageQuantity || 0);
existing.originalVariantName = existing.originalVariantName || existing.variantName || '';
existing.isAvailable = true;
existing.unavailable = false;
existing.unavailableReason = '';
} else {
items.push({
key,
@@ -1127,15 +1644,23 @@ try {
name: productScope.dataset.productName || '',
image: productScope.dataset.productImage || '',
variantId,
variantName: row?.dataset.variantName || variantButton?.dataset.variantName || 'Фасовка',
basePackagePrice: Number(packagePriceNode.dataset.basePackagePrice || 0),
baseUnitPrice: Number(row?.dataset.baseUnitPrice || variantButton?.dataset.baseUnitPrice || 0),
variantName,
originalVariantName: variantName,
basePackagePrice,
firstBasePackagePrice: basePackagePrice,
baseUnitPrice,
unit,
packageQuantity,
originalPackageQuantity: packageQuantity,
isAvailable: true,
unavailable: false,
quantity: addQuantity
});
}
saveCartItems(items);
updateAllPrices();
syncCartWithServer();
addButton.classList.add('is-added');
addButton.textContent = 'Добавлено';
window.setTimeout(() => {
@@ -1155,6 +1680,19 @@ try {
notifyButton.textContent = 'Запрос принят';
});
document.addEventListener('click', (event) => {
const clearButton = event.target.closest('[data-cart-clear]');
if (!clearButton) {
return;
}
if (window.confirm('Очистить корзину полностью?')) {
saveCartItems([]);
updateAllPrices();
}
});
document.addEventListener('click', (event) => {
const control = event.target.closest('[data-cart-plus], [data-cart-minus], [data-cart-remove]');
+8 -1
View File
@@ -6,6 +6,9 @@
<div class="cart-weight-notice">
Весовые позиции пересчитываются при сборке по фактическому весу. Если Вам нужна итоговая сумма до получения товара, отметьте это в форме заказа: после сборки менеджер перезвонит и сообщит сумму.
</div>
<div class="cart-weight-notice cart-live-notice">
При каждом открытии корзины сайт сверяет наличие, актуальные цены и фасовки. Если позиция закончилась, она не попадет в сумму заказа; если изменилась фасовка, мы покажем это рядом с товаром.
</div>
</div>
<div class="cart-layout cart-layout-checkout">
@@ -15,10 +18,14 @@
<p class="eyebrow">Товары</p>
<h2>Выбранные позиции</h2>
</div>
<a class="subtle-link" href="/catalog">Добавить товары</a>
<div class="cart-heading-actions">
<button class="subtle-button" type="button" data-cart-clear>Очистить корзину</button>
<a class="subtle-link" href="/catalog">Добавить товары</a>
</div>
</div>
<p class="cart-empty" data-cart-empty>Корзина пока пустая. Перейдите в каталог и добавьте нужные фасовки.</p>
<div class="cart-sync-note" data-cart-sync-note hidden></div>
<div class="cart-items" data-cart-items></div>
<section class="cart-checkout-panel">
+5 -2
View File
@@ -45,7 +45,10 @@
<p class="eyebrow">Как пройти</p>
<h2>Маршрут к пункту самовывоза</h2>
</div>
<a class="subtle-link" href="https://yandex.ru/maps/?text=Москва%2C%20улица%20Привольная%2C%2013%D0%BA1" target="_blank" rel="noopener">Открыть в Яндекс.Картах</a>
<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="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 class="route-layout">
@@ -60,7 +63,7 @@
<div class="route-steps">
<ol>
<li>Доезжайте до станции метро “Лермонтовский проспект”.</li>
<li>Идите к адресу: ул. Привольная, д. 13к1.</li>
<li>Выходите в сторону ул. Привольная и идите к дому 13к1.</li>
<li>Зайдите во двор дома.</li>
<li>Ориентир - крыльцо Wildberries, вход со двора.</li>
<li>Перед самовывозом дождитесь подтверждения заказа по телефону.</li>
+117 -41
View File
@@ -25,6 +25,7 @@
<label>
Адрес доставки
<input type="text" name="address" placeholder="Например: Москва, ул. Привольная, 13к1">
<small class="cart-address-status" id="delivery-address-status" hidden></small>
</label>
<label>
@@ -35,21 +36,18 @@
<label>
Способ получения
<select name="zone">
<option value="inside">Доставка внутри МКАД</option>
<option value="outside">Доставка за МКАД</option>
<option value="inside_mkad">Доставка внутри МКАД</option>
<option value="outside_mkad">Доставка за МКАД</option>
<option value="pickup">Самовывоз</option>
</select>
</label>
<label class="km-field">
Км от МКАД
<input type="number" name="km" min="0" step="1" value="0">
</label>
<label class="checkbox-label">
<input type="checkbox" name="lift">
Нужен подъем до квартиры/офиса
</label>
<button class="secondary-button calculator-submit" type="button" data-delivery-calc-button>Рассчитать</button>
</form>
<div class="calculator-result" id="delivery-result">
@@ -60,7 +58,9 @@
<div class="info-grid">
<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>
<ul class="clean-list">
<li>Заказы от 5000 руб. - бесплатно до подъезда.</li>
@@ -72,7 +72,9 @@
</section>
<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>
<ul class="clean-list">
<li>До 10 км от МКАД бесплатная доставка от 10000 руб.</li>
@@ -113,57 +115,131 @@
</section>
<script>
(() => {
window.addEventListener('load', () => {
const form = document.getElementById('delivery-calculator');
const result = document.getElementById('delivery-result');
const addressStatus = document.getElementById('delivery-address-status');
if (!form || !result) {
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 data = new FormData(form);
const orderSum = Number(data.get('order_sum') || 0);
const zone = String(data.get('zone') || 'inside');
const km = Math.max(0, Number(data.get('km') || 0));
const zone = String(data.get('zone') || 'inside_mkad');
const lift = data.get('lift') === 'on';
const liftPrice = lift ? 390 : 0;
let delivery = 0;
let title = '';
let note = '';
const effectiveZone = resolvedZone === 'outside'
? 'outside_mkad'
: (resolvedZone === 'inside' ? 'inside_mkad' : zone);
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') {
delivery = 0;
title = 'Самовывоз: бесплатно';
note = orderSum < 1
? 'Минимальная сумма заказа на самовывоз - 1 руб.'
: 'Самовывоз возможен после подтверждения заказа.';
} 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 руб.';
}
resolvedDistance = 0;
resolvedZone = '';
setAddressStatus('');
calculate();
return;
}
const total = delivery + liftPrice;
result.innerHTML = '<strong>' + title + '</strong><span>' + note + (lift ? ' Подъем: 390 руб. Итого с подъемом: ' + formatRub(total) + '.' : '') + '</span>';
if (address.length < 6) {
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('change', calculate);
form.addEventListener('input', (event) => {
if (event.target.matches('[name="address"]')) {
scheduleResolveAddress();
}
});
form.addEventListener('change', () => {
resolveAddress();
calculate();
});
form.querySelector('[data-delivery-calc-button]')?.addEventListener('click', () => {
resolveAddress();
calculate();
});
window.RybStockDelivery?.setupAddressSuggestions?.();
resolveAddress();
calculate();
})();
});
</script>
+129
View File
@@ -6,6 +6,7 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $categories */
/** @var array<int, array<string, mixed>> $catalogProducts */
/** @var array<int, array<string, mixed>> $popularProducts */
/** @var array<int, array<string, mixed>> $homeCatalogSections */
/** @var array<string, mixed> $settings */
/** @var string|null $notice */
/** @var string $buildVersion */
@@ -230,6 +231,133 @@ $arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->for
<div class="status-box danger"><?= e($notice) ?></div>
<?php endif; ?>
<section class="home-catalog-showcase" aria-label="Витрина каталога Рыбсток">
<div class="home-catalog-heading">
<div>
<p class="eyebrow">Каталог продукции</p>
<h2>Акции и новые поступления</h2>
</div>
<a class="home-catalog-main-link" href="/catalog">Перейти во весь каталог</a>
</div>
<?php if (($homeCatalogSections ?? []) === []): ?>
<div class="panel">
<p class="muted">После наполнения каталога товары появятся здесь по категориям.</p>
</div>
<?php else: ?>
<?php foreach ($homeCatalogSections as $section): ?>
<?php
$sectionProducts = $section['products'] ?? [];
if ($sectionProducts === []) {
continue;
}
$sectionKind = (string) ($section['kind'] ?? 'category');
?>
<section class="home-product-section is-<?= e($sectionKind) ?>" id="home-<?= e((string) ($section['key'] ?? 'section')) ?>">
<div class="home-product-section-head">
<h3><?= e((string) ($section['title'] ?? 'Категория')) ?></h3>
<div class="home-product-section-actions">
<?php if (!empty($section['url'])): ?>
<a href="<?= e((string) $section['url']) ?>">Показать <?= e((string) ($section['count'] ?? count($sectionProducts))) ?> товаров</a>
<?php endif; ?>
<div class="home-strip-controls" aria-label="Прокрутка подборки">
<button type="button" data-home-strip-prev aria-label="Назад">&larr;</button>
<button type="button" data-home-strip-next aria-label="Вперед">&rarr;</button>
</div>
</div>
</div>
<div class="home-products-strip" aria-label="<?= e((string) ($section['title'] ?? 'Категория')) ?>">
<?php foreach ($sectionProducts as $product): ?>
<?php require base_path('views/partials/product-card.php'); ?>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
<?php endif; ?>
</section>
<script>
document.querySelectorAll('[data-home-strip-prev], [data-home-strip-next]').forEach((button) => {
button.addEventListener('click', () => {
const section = button.closest('.home-product-section');
const strip = section ? section.querySelector('.home-products-strip') : null;
if (!strip) {
return;
}
const direction = button.hasAttribute('data-home-strip-prev') ? -1 : 1;
strip.scrollBy({
left: direction * Math.max(260, strip.clientWidth * 0.86),
behavior: 'smooth',
});
});
});
</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): ?>
<div class="content-grid">
<section class="panel">
<h2>Каталог</h2>
@@ -300,4 +428,5 @@ $arrivalDate = $lastArrival->format('j') . ' ' . $months[(int) $lastArrival->for
</div>
<?php endif; ?>
</section>
<?php endif; ?>
</section>
+11
View File
@@ -33,6 +33,17 @@ if (!function_exists('category_tree_has_current')) {
}
?>
<ul class="category-list depth-<?= e((string) $depth) ?>">
<?php if ($depth === 0): ?>
<li class="category-special-link is-all">
<a href="/catalog"><span>Весь каталог</span></a>
</li>
<li class="category-special-link">
<a href="/promo"><span>Акции</span></a>
</li>
<li class="category-special-link">
<a href="/new"><span>Новинки</span></a>
</li>
<?php endif; ?>
<?php foreach ($items as $category): ?>
<?php $isCurrent = ($currentSlug ?? '') === $category['slug']; ?>
<?php $hasChildren = !empty($category['children']); ?>
+11 -1
View File
@@ -31,8 +31,18 @@ if (!function_exists('render_mega_category_children')) {
<?php if ($megaCategories !== []): ?>
<nav class="category-mega-bar" aria-label="Категории товаров">
<div class="category-mega-inner">
<a class="mega-root mega-all-link" href="/catalog">Весь каталог</a>
<a class="mega-root mega-virtual-link is-promo" href="/promo">Акции</a>
<a class="mega-root mega-virtual-link is-new" href="/new">Новинки</a>
<?php foreach ($megaCategories as $category): ?>
<?php if (($category['slug'] ?? '') === 'myasnaya-gastronomiya' || ($category['name'] ?? '') === 'Мясная гастрономия') {
<?php
$hiddenTopCategorySlugs = ['myasnaya-gastronomiya', 'molochnaya-produkciya'];
$hiddenTopCategoryNames = ['Мясная гастрономия', 'Молочная продукция'];
if (
in_array((string) ($category['slug'] ?? ''), $hiddenTopCategorySlugs, true)
|| in_array((string) ($category['name'] ?? ''), $hiddenTopCategoryNames, true)
) {
continue;
} ?>
<?php $iconName = category_tree_icon((string) $category['name']); ?>
+13 -5
View File
@@ -60,6 +60,10 @@ $activeTierUnit = $activeShowsUnitPrice ? unit_label((string) ($activeVariant['u
$isPublished = (int) ($product['is_published'] ?? 1) === 1;
$isAvailable = (int) ($product['is_available'] ?? 1) === 1;
$isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
$sourceImagePath = (string) ($product['main_image_path'] ?? '');
$previewImagePath = function_exists('optimized_image_path')
? optimized_image_path($sourceImagePath, 'preview')
: $sourceImagePath;
?>
<article
class="product-card price-aware<?= $isPurchasable ? '' : ' is-unavailable' ?>"
@@ -67,20 +71,23 @@ $isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
data-product-id="<?= e((string) ($product['id'] ?? '')) ?>"
data-product-name="<?= e((string) ($product['name'] ?? '')) ?>"
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' ?>"
>
<?php if (!$isPurchasable): ?>
<span class="product-unavailable-badge"><?= !$isPublished ? 'Ожидается поступление' : 'Нет в наличии' ?></span>
<?php endif; ?>
<?php if (!empty($product['badge_label']) && $isPurchasable): ?>
<span class="product-card-badge is-<?= e((string) ($product['badge_kind'] ?? 'default')) ?>"><?= e((string) $product['badge_label']) ?></span>
<?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']) ?>">
<img src="<?= e((string) $product['main_image_path']) ?>" alt="<?= e((string) $product['name']) ?>">
<img src="<?= e($previewImagePath) ?>" alt="<?= e((string) $product['name']) ?>" loading="lazy" decoding="async">
</a>
<?php elseif (!empty($product['main_image_path'])): ?>
<?php elseif ($previewImagePath !== ''): ?>
<div class="product-card-image" aria-label="<?= e($product['name']) ?>">
<img src="<?= e((string) $product['main_image_path']) ?>" alt="<?= e((string) $product['name']) ?>">
<img src="<?= e($previewImagePath) ?>" alt="<?= e((string) $product['name']) ?>" loading="lazy" decoding="async">
</div>
<?php elseif ($isPurchasable): ?>
<a class="product-card-image product-card-image-placeholder" href="/product/<?= e($product['slug']) ?>" aria-label="<?= e($product['name']) ?>">
@@ -131,6 +138,7 @@ $isPurchasable = $isPublished && $isAvailable && $activePackagePrice > 0;
data-variant-id="<?= e((string) ($variant['id'] ?? $index)) ?>"
data-variant-name="<?= e((string) ($variant['name'] ?? '')) ?>"
data-unit="<?= e(unit_label((string) $variant['unit'])) ?>"
data-package-quantity="<?= e((string) ($variant['package_quantity'] ?? '')) ?>"
data-base-unit-price="<?= e((string) ($variant['price_per_unit'] ?? '')) ?>"
data-old-unit-price="<?= e($variantOldUnitPrice === null ? '' : (string) $variantOldUnitPrice) ?>"
data-base-package-price="<?= e((string) ($variant['package_price'] ?? '')) ?>"
+42
View File
@@ -0,0 +1,42 @@
<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">
<?php if (($_GET['wishlist_request'] ?? '') === 'sent'): ?>
<div class="status-box success">Ваш запрос принят. Если найдем подходящий вариант, обязательно свяжемся с вами.</div>
<?php elseif (($_GET['wishlist_request'] ?? '') === 'error'): ?>
<div class="status-box danger">Не удалось отправить запрос. Укажите имя, что нужно найти, и телефон или email.</div>
<?php endif; ?>
<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>