js css
This commit is contained in:
@@ -18,6 +18,7 @@ final class HomeController
|
||||
$categories = [];
|
||||
$catalogProducts = [];
|
||||
$popularProducts = [];
|
||||
$homeCatalogSections = [];
|
||||
$settings = [];
|
||||
$notice = null;
|
||||
|
||||
@@ -25,10 +26,65 @@ 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' => '/catalog',
|
||||
'count' => 10,
|
||||
'kind' => 'promo',
|
||||
'products' => $productRepository->promoPreview(10),
|
||||
];
|
||||
$homeCatalogSections[] = [
|
||||
'key' => 'new',
|
||||
'title' => 'Новинки',
|
||||
'url' => '/catalog',
|
||||
'count' => 50,
|
||||
'kind' => 'new',
|
||||
'products' => $productRepository->newPreview(10),
|
||||
];
|
||||
|
||||
foreach ([
|
||||
['slugs' => ['ikra'], 'title' => 'Икра'],
|
||||
['slugs' => ['ryba'], 'title' => 'Рыба'],
|
||||
['slugs' => ['moreprodukty'], 'title' => 'Морепродукты'],
|
||||
['slugs' => ['myaso'], 'title' => 'Мясо'],
|
||||
['slugs' => ['polufabrikaty'], 'title' => 'Полуфабрикаты'],
|
||||
['slugs' => ['syry'], 'title' => 'Сыры'],
|
||||
['slugs' => ['frukty-ovoschi-yagody-griby'], 'title' => 'Фрукты, овощи, ягоды, грибы'],
|
||||
['slugs' => ['mramornaya-govyadina'], 'title' => 'Мраморная говядина'],
|
||||
['slugs' => ['kopchenaya-ryba', 'kopchenaya-riba-1'], 'title' => 'Копченая рыба'],
|
||||
['slugs' => ['vyalenaya-ryba', 'vylaenaya-riba'], 'title' => 'Вяленая рыба'],
|
||||
['slugs' => ['malosolnaya-ryba', 'malosolnaya-riba'], 'title' => 'Малосольная рыба'],
|
||||
] as $sectionConfig) {
|
||||
$categoryMatch = $this->homeCategoryShowcase(
|
||||
$categoryRepository,
|
||||
$productRepository,
|
||||
$categories,
|
||||
$sectionConfig['slugs'],
|
||||
$sectionConfig['title']
|
||||
);
|
||||
|
||||
if ($categoryMatch === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$category = $categoryMatch['category'];
|
||||
$showcase = $categoryMatch['showcase'];
|
||||
$slug = (string) ($sectionConfig['slugs'][0] ?? $category['slug']);
|
||||
$homeCatalogSections[] = [
|
||||
'key' => $slug,
|
||||
'title' => $sectionConfig['title'],
|
||||
'url' => '/catalog/' . $category['slug'],
|
||||
'count' => $showcase['total'],
|
||||
'kind' => 'category',
|
||||
'products' => $showcase['products'],
|
||||
];
|
||||
}
|
||||
$settings = (new SettingRepository())->allKeyed();
|
||||
} catch (Throwable $exception) {
|
||||
$dbStatus = app_env('APP_DEBUG', 'false') === 'true'
|
||||
@@ -49,9 +105,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;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,105 @@ final class ProductRepository extends BaseRepository
|
||||
return $this->preparePreviewProducts($statement->fetchAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function promoPreview(int $limit = 10): array
|
||||
{
|
||||
$limit = max(1, min($limit, 10));
|
||||
$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<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<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>>
|
||||
*/
|
||||
@@ -479,6 +578,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>>
|
||||
|
||||
@@ -22,6 +22,20 @@ 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 view(string $template, array $data = []): void
|
||||
{
|
||||
View::render($template, $data);
|
||||
|
||||
Reference in New Issue
Block a user