Initial commit
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
ssww23
2026-03-10 00:55:37 +03:00
parent fc0f28d830
commit 93a655235a
155 changed files with 24768 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class AccountController extends Controller
{
public function show(Request $request)
{
$user = $request->user()->load(['orders' => function ($query) {
$query->latest('id');
}]);
return view('shop.account', [
'user' => $user,
'orders' => $user->orders,
]);
}
public function update(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email,' . $request->user()->id],
]);
$request->user()->update($validated);
return back()->with('status', 'Данные профиля обновлены.');
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Concerns\ManagesCaptcha;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
class AuthController extends Controller
{
use ManagesCaptcha;
private const LOGIN_CAPTCHA_CONTEXT = 'shop_login';
private const REGISTER_CAPTCHA_CONTEXT = 'shop_register';
public function showLoginForm(Request $request)
{
return view('shop.auth.login', [
'captchaQuestion' => $this->buildCaptchaQuestion($request, self::LOGIN_CAPTCHA_CONTEXT),
]);
}
public function login(Request $request)
{
$validated = $request->validate([
'email' => ['required', 'email'],
'password' => ['required', 'string'],
'captcha' => ['required', 'string', 'max:10'],
]);
if (!$this->captchaIsValid($request, self::LOGIN_CAPTCHA_CONTEXT)) {
return back()
->withInput($request->only('email', 'remember'))
->withErrors(['captcha' => 'Неверный ответ на капчу.']);
}
$credentials = [
'email' => $validated['email'],
'password' => $validated['password'],
];
$remember = $request->boolean('remember');
if (!Auth::attempt($credentials, $remember)) {
return back()
->withInput($request->only('email', 'remember'))
->withErrors(['email' => 'Неверный email или пароль.']);
}
$request->session()->regenerate();
$this->clearCaptcha($request, self::LOGIN_CAPTCHA_CONTEXT);
return redirect()->intended(route('account'));
}
public function showRegisterForm(Request $request)
{
return view('shop.auth.register', [
'captchaQuestion' => $this->buildCaptchaQuestion($request, self::REGISTER_CAPTCHA_CONTEXT),
]);
}
public function register(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
'captcha' => ['required', 'string', 'max:10'],
]);
if (!$this->captchaIsValid($request, self::REGISTER_CAPTCHA_CONTEXT)) {
return back()
->withInput($request->only('name', 'email'))
->withErrors(['captcha' => 'Неверный ответ на капчу.']);
}
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
]);
Auth::login($user);
$request->session()->regenerate();
$this->clearCaptcha($request, self::REGISTER_CAPTCHA_CONTEXT);
return redirect()->route('account');
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('home');
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Product;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function index()
{
$cart = (array) session()->get('cart', []);
$products = Product::query()
->whereIn('id', array_keys($cart))
->where('is_active', true)
->with('category')
->get()
->keyBy('id');
$items = collect($cart)
->map(function ($quantity, $productId) use ($products) {
$product = $products->get((int) $productId);
if (!$product) {
return null;
}
$qty = max(1, (int) $quantity);
return [
'product' => $product,
'quantity' => $qty,
'subtotal' => (float) $product->price * $qty,
];
})
->filter()
->values();
return view('shop.cart', [
'items' => $items,
'itemsCount' => $items->sum('quantity'),
'total' => $items->sum('subtotal'),
]);
}
public function add(Product $product)
{
if (!$product->is_active || $product->stock < 1) {
return back()->with('status', 'Товар сейчас недоступен для заказа.');
}
$cart = (array) session()->get('cart', []);
$current = (int) ($cart[$product->id] ?? 0);
if ($current >= $product->stock) {
return back()->with('status', 'В корзине уже максимальное доступное количество.');
}
$cart[$product->id] = $current + 1;
session()->put('cart', $cart);
return back()->with('status', "Товар \"{$product->name}\" добавлен в корзину.");
}
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'quantity' => ['required', 'integer', 'min:1', 'max:99'],
]);
$cart = (array) session()->get('cart', []);
if (!isset($cart[$product->id])) {
return back();
}
$available = max(0, (int) $product->stock);
$quantity = min((int) $validated['quantity'], $available);
if ($quantity < 1) {
unset($cart[$product->id]);
session()->put('cart', $cart);
return back()->with('status', "Товар \"{$product->name}\" удален из корзины.");
}
$cart[$product->id] = $quantity;
session()->put('cart', $cart);
$message = $quantity < (int) $validated['quantity']
? 'Количество ограничено текущим остатком.'
: 'Количество товара обновлено.';
return back()->with('status', $message);
}
public function remove(Product $product)
{
$cart = (array) session()->get('cart', []);
if (isset($cart[$product->id])) {
unset($cart[$product->id]);
session()->put('cart', $cart);
}
return back()->with('status', "Товар \"{$product->name}\" удален из корзины.");
}
}

View File

@@ -0,0 +1,325 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Product;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
class CatalogController extends Controller
{
private const PER_PAGE = 20;
public function search(Request $request)
{
$searchQuery = trim((string) $request->input('q', ''));
$query = Product::query()
->where('is_active', true)
->with('category');
if ($searchQuery === '') {
$query->whereRaw('1 = 0');
} else {
$this->applyNameSearch($query, $searchQuery);
}
$sort = $request->string('sort')->toString();
match ($sort) {
'price_asc' => $query->orderBy('price'),
'price_desc' => $query->orderByDesc('price'),
'name_asc' => $query->orderBy('name'),
default => $query->orderByDesc('id'),
};
$products = $query->paginate(self::PER_PAGE)->withQueryString();
return view('shop.search', [
'products' => $products,
'searchQuery' => $searchQuery,
'sort' => $sort ?: 'newest',
]);
}
public function index(Request $request)
{
$query = Product::query()->where('is_active', true)->with('category');
if ($request->filled('q')) {
$this->applyNameSearch($query, (string) $request->input('q'));
}
if ($request->filled('category')) {
$query->whereHas('category', function ($builder) use ($request) {
$builder->where('slug', $request->string('category'));
});
}
$products = $query->orderByDesc('id')->paginate(self::PER_PAGE)->withQueryString();
$categories = Category::query()
->where('is_active', true)
->orderBy('name')
->get();
return view('shop.catalog', [
'products' => $products,
'categories' => $categories,
]);
}
public function category(Request $request, Category $category)
{
$baseQuery = Product::query()
->where('category_id', $category->id)
->where('is_active', true);
$query = (clone $baseQuery)->with('category');
$appliedFilters = collect((array) $request->input('filters', []))
->map(function ($value) {
if (!is_scalar($value)) {
return '';
}
return trim((string) $value);
})
->filter(fn (string $value) => $value !== '')
->all();
if ($request->filled('q')) {
$searchTerm = (string) $request->input('q');
$this->applyNameSearch($baseQuery, $searchTerm);
$this->applyNameSearch($query, $searchTerm);
}
$filters = $this->filtersForCategory($category->slug);
$priceBounds = $this->priceBounds($baseQuery);
$priceFrom = $this->parseFilterNumber($request->input('price_from'));
if ($priceFrom !== null) {
$query->where('price', '>=', $priceFrom);
}
$priceTo = $this->parseFilterNumber($request->input('price_to'));
if ($priceTo !== null) {
$query->where('price', '<=', $priceTo);
}
$rangeFilters = [];
foreach ($filters as $filter) {
if (!$this->isRangeFilter($filter)) {
continue;
}
$key = (string) ($filter['key'] ?? '');
if ($key === '') {
continue;
}
$bounds = $this->numericSpecBounds($baseQuery, $key);
$fromParam = $key . '_from';
$toParam = $key . '_to';
$rangeFrom = $this->parseFilterNumber($request->input($fromParam));
if ($rangeFrom !== null) {
$query->whereRaw(
"NULLIF(regexp_replace(specs->>?, '[^0-9.]', '', 'g'), '')::numeric >= ?",
[$key, $rangeFrom]
);
}
$rangeTo = $this->parseFilterNumber($request->input($toParam));
if ($rangeTo !== null) {
$query->whereRaw(
"NULLIF(regexp_replace(specs->>?, '[^0-9.]', '', 'g'), '')::numeric <= ?",
[$key, $rangeTo]
);
}
$rangeFilters[$key] = [
'from' => $request->filled($fromParam)
? trim((string) $request->input($fromParam))
: $this->formatFilterNumber($bounds['min']),
'to' => $request->filled($toParam)
? trim((string) $request->input($toParam))
: $this->formatFilterNumber($bounds['max']),
'min' => $this->formatFilterNumber($bounds['min']),
'max' => $this->formatFilterNumber($bounds['max']),
];
}
foreach ($filters as $filter) {
if ($this->isRangeFilter($filter)) {
continue;
}
$key = (string) ($filter['key'] ?? '');
if ($key === '') {
continue;
}
$value = $appliedFilters[$key] ?? null;
if ($value !== null) {
$query->whereRaw("specs->>? = ?", [$key, $value]);
}
}
$sort = $request->string('sort')->toString();
match ($sort) {
'price_asc' => $query->orderBy('price'),
'price_desc' => $query->orderByDesc('price'),
'name_asc' => $query->orderBy('name'),
default => $query->orderByDesc('id'),
};
$products = $query->paginate(self::PER_PAGE)->withQueryString();
$filterOptions = $this->filterOptions($category, $filters);
$priceFilter = [
'from' => $request->filled('price_from')
? trim((string) $request->input('price_from'))
: $this->formatFilterNumber($priceBounds['min']),
'to' => $request->filled('price_to')
? trim((string) $request->input('price_to'))
: $this->formatFilterNumber($priceBounds['max']),
'min' => $this->formatFilterNumber($priceBounds['min']),
'max' => $this->formatFilterNumber($priceBounds['max']),
];
return view('shop.category', [
'category' => $category,
'products' => $products,
'filters' => $filters,
'filterOptions' => $filterOptions,
'appliedFilters' => $appliedFilters,
'rangeFilters' => $rangeFilters,
'priceFilter' => $priceFilter,
'sort' => $sort ?: 'newest',
]);
}
private function filtersForCategory(string $slug): array
{
return config('product_specs.categories.' . $slug, []);
}
private function filterOptions(Category $category, array $filters): array
{
if (empty($filters)) {
return [];
}
$specs = $category->products()
->where('is_active', true)
->pluck('specs');
$options = [];
foreach ($filters as $filter) {
if ($this->isRangeFilter($filter)) {
continue;
}
$key = (string) ($filter['key'] ?? '');
if ($key === '') {
continue;
}
$valuesFromProducts = $specs
->pluck($key)
->filter()
->unique()
->sort()
->values()
->all();
$presetValues = collect((array) ($filter['options'] ?? []))
->filter(fn ($value) => $value !== null && $value !== '')
->map(fn ($value) => (string) $value)
->values()
->all();
$options[$key] = collect($presetValues)
->concat($valuesFromProducts)
->map(fn ($value) => (string) $value)
->filter()
->unique()
->values()
->all();
}
return $options;
}
private function applyNameSearch(Builder $query, string $term): void
{
$normalizedTerm = mb_strtolower(trim($term));
if ($normalizedTerm === '') {
return;
}
$query->whereRaw('LOWER(name) LIKE ?', ['%' . $normalizedTerm . '%']);
}
private function isRangeFilter(array $filter): bool
{
return (string) ($filter['filter'] ?? 'select') === 'range';
}
private function parseFilterNumber(mixed $value): ?float
{
$string = trim((string) $value);
if ($string === '') {
return null;
}
$normalized = str_replace([' ', ','], ['', '.'], $string);
if (!is_numeric($normalized)) {
return null;
}
return (float) $normalized;
}
private function formatFilterNumber(?float $value): string
{
if ($value === null) {
return '';
}
if (abs($value - round($value)) < 0.000001) {
return (string) (int) round($value);
}
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function priceBounds(Builder $query): array
{
$bounds = (clone $query)
->selectRaw('MIN(price) as min_value, MAX(price) as max_value')
->first();
return [
'min' => $this->parseFilterNumber($bounds?->min_value),
'max' => $this->parseFilterNumber($bounds?->max_value),
];
}
private function numericSpecBounds(Builder $query, string $key): array
{
if ($key === '') {
return ['min' => null, 'max' => null];
}
$bounds = (clone $query)
->selectRaw(
"MIN(NULLIF(regexp_replace(specs->>?, '[^0-9.]', '', 'g'), '')::numeric) as min_value, MAX(NULLIF(regexp_replace(specs->>?, '[^0-9.]', '', 'g'), '')::numeric) as max_value",
[$key, $key]
)
->first();
return [
'min' => $this->parseFilterNumber($bounds?->min_value),
'max' => $this->parseFilterNumber($bounds?->max_value),
];
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\ChatConversation;
use App\Models\ChatMessage;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class ChatController extends Controller
{
private const SESSION_TOKEN_KEY = 'chat.visitor_token';
public function messages(Request $request): JsonResponse
{
$conversation = $this->resolveConversation($request);
$messages = $this->conversationMessages($conversation);
$conversation->messages()
->where('sender', 'admin')
->where('is_read', false)
->update(['is_read' => true]);
return response()->json([
'conversation' => $this->conversationPayload($conversation),
'conversation_id' => $conversation->id,
'messages' => $messages->map(fn (ChatMessage $message) => $this->messagePayload($message))->values(),
]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:2000'],
]);
$messageText = $this->sanitizeMessage((string) $validated['message']);
if ($messageText === '') {
throw ValidationException::withMessages([
'message' => 'Сообщение содержит недопустимые символы.',
]);
}
$conversation = $this->resolveConversationForWriting($request);
$message = $conversation->messages()->create([
'sender' => 'customer',
'body' => $messageText,
'is_read' => false,
]);
$conversation->update([
'status' => 'open',
'last_message_at' => now(),
]);
return response()->json([
'conversation' => $this->conversationPayload($conversation),
'message' => $this->messagePayload($message),
]);
}
private function resolveConversation(Request $request): ChatConversation
{
$token = $this->sessionToken($request);
$user = $request->user();
if (!$user) {
$conversation = ChatConversation::query()->where('visitor_token', $token)->first();
if ($conversation && $conversation->user_id !== null) {
$token = $this->rotateSessionToken($request);
$conversation = null;
}
return $conversation ?: ChatConversation::query()->firstOrCreate(
['visitor_token' => $token],
['status' => 'open']
);
}
$userConversation = ChatConversation::query()
->where('user_id', $user->id)
->latest('id')
->first();
if ($userConversation) {
return $userConversation;
}
$guestConversation = ChatConversation::query()
->where('visitor_token', $token)
->whereNull('user_id')
->first();
if ($guestConversation) {
$guestConversation->update(['user_id' => $user->id]);
return $guestConversation;
}
return ChatConversation::query()->create([
'user_id' => $user->id,
'visitor_token' => $this->generateUniqueToken(),
'status' => ChatConversation::STATUS_OPEN,
]);
}
private function resolveConversationForWriting(Request $request): ChatConversation
{
$conversation = $this->resolveConversation($request);
if (!$conversation->isClosed()) {
return $conversation;
}
$user = $request->user();
if (!$user) {
return ChatConversation::query()->create([
'visitor_token' => $this->rotateSessionToken($request),
'status' => ChatConversation::STATUS_OPEN,
]);
}
return ChatConversation::query()->create([
'user_id' => $user->id,
'visitor_token' => $this->generateUniqueToken(),
'status' => ChatConversation::STATUS_OPEN,
]);
}
private function conversationMessages(ChatConversation $conversation)
{
return $conversation->messages()
->latest('id')
->limit(100)
->get()
->reverse()
->values();
}
private function messagePayload(ChatMessage $message): array
{
return [
'id' => $message->id,
'sender' => $message->sender,
'body' => $message->body,
'created_at' => $message->created_at?->toIso8601String(),
'time' => $message->created_at?->format('H:i'),
];
}
private function conversationPayload(ChatConversation $conversation): array
{
return [
'id' => $conversation->id,
'status' => $conversation->status,
'is_closed' => $conversation->isClosed(),
'notice' => $conversation->isClosed()
? 'Чат закрыт администратором. Отправьте новое сообщение, чтобы начать новый диалог.'
: null,
];
}
private function sanitizeMessage(string $value): string
{
$text = strip_tags($value);
$text = str_replace(["\r\n", "\r"], "\n", $text);
$text = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $text) ?? $text;
return trim(Str::limit($text, 2000, ''));
}
private function sessionToken(Request $request): string
{
$token = (string) $request->session()->get(self::SESSION_TOKEN_KEY, '');
if ($token !== '') {
return $token;
}
return $this->rotateSessionToken($request);
}
private function rotateSessionToken(Request $request): string
{
$token = $this->generateUniqueToken();
$request->session()->put(self::SESSION_TOKEN_KEY, $token);
return $token;
}
private function generateUniqueToken(): string
{
do {
$token = (string) Str::uuid();
} while (ChatConversation::query()->where('visitor_token', $token)->exists());
return $token;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class CheckoutController extends Controller
{
private const CHECKOUT_CUSTOMER_KEY = 'checkout.customer';
public function show(Request $request)
{
$items = $this->cartItems($request);
if ($items->isEmpty()) {
return redirect()->route('cart.index')->with('status', 'Корзина пустая. Добавьте товары перед оформлением.');
}
return view('shop.checkout', [
'items' => $items,
'total' => $items->sum('subtotal'),
'itemsCount' => $items->sum('quantity'),
]);
}
public function prepare(Request $request)
{
$items = $this->cartItems($request);
if ($items->isEmpty()) {
return redirect()->route('cart.index')->with('status', 'Корзина пустая. Добавьте товары перед оформлением.');
}
$validated = $request->validate([
'customer_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'address' => ['required', 'string', 'max:500'],
'comment' => ['nullable', 'string', 'max:1000'],
]);
$validated['payment_method'] = 'card_transfer';
$request->session()->put(self::CHECKOUT_CUSTOMER_KEY, $validated);
return redirect()->route('checkout.payment');
}
public function payment(Request $request)
{
$items = $this->cartItems($request);
if ($items->isEmpty()) {
return redirect()->route('cart.index')->with('status', 'Корзина пустая. Добавьте товары перед оформлением.');
}
$customer = $request->session()->get(self::CHECKOUT_CUSTOMER_KEY);
if (!is_array($customer)) {
return redirect()->route('checkout.show')->with('status', 'Сначала заполните данные получателя.');
}
return view('shop.checkout-payment', [
'items' => $items,
'total' => $items->sum('subtotal'),
'itemsCount' => $items->sum('quantity'),
'customer' => $customer,
]);
}
public function store(Request $request)
{
$items = $this->cartItems($request);
if ($items->isEmpty()) {
return redirect()->route('cart.index')->with('status', 'Корзина пустая. Добавьте товары перед оформлением.');
}
$validated = $request->session()->get(self::CHECKOUT_CUSTOMER_KEY);
if (!is_array($validated)) {
return redirect()->route('checkout.show')->with('status', 'Сначала заполните данные получателя.');
}
$validator = validator($validated, [
'customer_name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'address' => ['required', 'string', 'max:500'],
'comment' => ['nullable', 'string', 'max:1000'],
'payment_method' => ['required', 'string', Rule::in(['card_transfer'])],
]);
$validated = $validator->validate();
$order = DB::transaction(function () use ($request, $items, $validated) {
$order = Order::create([
'user_id' => optional($request->user())->id,
'status' => 'new',
'payment_method' => $validated['payment_method'],
'total' => $items->sum('subtotal'),
'items_count' => $items->sum('quantity'),
'customer_name' => $validated['customer_name'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? null,
'address' => $validated['address'],
'comment' => $validated['comment'] ?? null,
]);
$rows = $items->map(function (array $item) use ($order) {
return [
'order_id' => $order->id,
'product_id' => $item['product']->id,
'name' => $item['product']->name,
'price' => $item['product']->price,
'quantity' => $item['quantity'],
'subtotal' => $item['subtotal'],
'created_at' => now(),
'updated_at' => now(),
];
})->all();
OrderItem::insert($rows);
return $order;
});
$request->session()->forget('cart');
$request->session()->forget(self::CHECKOUT_CUSTOMER_KEY);
return redirect()->route('checkout.success', $order);
}
public function success(Order $order)
{
return view('shop.checkout-success', [
'order' => $order,
]);
}
private function cartItems(Request $request)
{
$cart = (array) $request->session()->get('cart', []);
$products = Product::query()
->whereIn('id', array_keys($cart))
->where('is_active', true)
->get()
->keyBy('id');
return collect($cart)
->map(function ($quantity, $productId) use ($products) {
$product = $products->get((int) $productId);
if (!$product) {
return null;
}
$qty = max(1, min((int) $quantity, (int) $product->stock));
if ($qty < 1) {
return null;
}
return [
'product' => $product,
'quantity' => $qty,
'subtotal' => (float) $product->price * $qty,
];
})
->filter()
->values();
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Product;
use Illuminate\Support\Str;
class CompareController extends Controller
{
public function index()
{
$compareIds = $this->compareIds();
$position = array_flip($compareIds);
$products = Product::query()
->whereIn('id', $compareIds)
->where('is_active', true)
->with('category')
->get()
->sortBy(fn (Product $product) => $position[$product->id] ?? PHP_INT_MAX)
->values();
$specKeys = $products
->flatMap(fn (Product $product) => array_keys((array) $product->specs))
->unique()
->values();
$specLabels = $specKeys->mapWithKeys(fn (string $key) => [$key => $this->specLabel($key)]);
return view('shop.compare', [
'products' => $products,
'specKeys' => $specKeys,
'specLabels' => $specLabels,
]);
}
public function toggle(Product $product)
{
$compare = $this->compareIds();
$exists = in_array($product->id, $compare, true);
if ($exists) {
$compare = array_values(array_filter($compare, fn (int $id) => $id !== $product->id));
session()->put('compare', $compare);
return back()->with('status', "Товар \"{$product->name}\" удален из сравнения.");
}
if (count($compare) >= 4) {
return back()->with('status', 'Можно сравнить не более 4 товаров одновременно.');
}
$compare[] = $product->id;
session()->put('compare', array_values(array_unique($compare)));
return back()->with('status', "Товар \"{$product->name}\" добавлен в сравнение.");
}
public function clear()
{
session()->forget('compare');
return back()->with('status', 'Список сравнения очищен.');
}
private function compareIds(): array
{
return array_values(array_map('intval', (array) session()->get('compare', [])));
}
private function specLabel(string $key): string
{
return match ($key) {
'manufacturer' => 'Производитель',
'socket_type' => 'Тип сокета',
'cpu_type' => 'Тип процессора',
'form_factor' => 'Форм-фактор',
'cpu_socket' => 'Сокет для процессора',
'condition' => 'Состояние',
'chipset' => 'Чипсет',
'memory_type' => 'Тип памяти',
'pcie_version' => 'Версия PCI Express',
'wifi_standard' => 'Стандарт Wi-Fi',
'max_memory' => 'Максимальный объем памяти',
'cache' => 'Объем кэша',
'capacity' => 'Объем',
'gpu' => 'GPU',
'vram' => 'Объем видеопамяти',
'vram_type' => 'Тип видеопамяти',
'kit' => 'Количество модулей',
'frequency' => 'Частота',
'power' => 'Мощность',
'efficiency' => 'Сертификат 80 Plus',
'size' => 'Типоразмер',
'gpu_length' => 'Макс. длина видеокарты',
'intel_socket' => 'Сокет Intel',
'amd_socket' => 'Сокет AMD',
'fan_speed' => 'Скорость вращения',
'fans' => 'Количество вентиляторов',
'type' => 'Тип',
'model' => 'Модель',
'color' => 'Цвет',
'screen_size' => 'Диагональ экрана',
'cpu_brand' => 'Производитель процессора',
'cpu_model' => 'Модель процессора',
'ram' => 'Оперативная память',
'storage' => 'Накопитель',
'panel' => 'Тип матрицы',
'resolution' => 'Разрешение экрана',
'refresh_rate' => 'Частота обновления',
'smart_tv' => 'Smart TV',
'cores' => 'Количество ядер',
'gpu_brand' => 'Производитель видеокарты',
'gpu_model' => 'Модель видеокарты',
default => Str::of($key)->replace('_', ' ')->title()->toString(),
};
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Throwable;
class ContactController extends Controller
{
public function submit(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email:rfc,dns', 'max:190'],
'message' => ['required', 'string', 'max:2000'],
]);
$botToken = trim((string) config('shop.telegram_bot_token'));
$chatId = trim((string) config('shop.telegram_chat_id'));
if ($botToken === '' || $chatId === '') {
return back()
->withInput()
->withErrors(['contact' => 'Не настроена отправка в Telegram. Заполните SHOP_TELEGRAM_BOT_TOKEN и SHOP_TELEGRAM_CHAT_ID.']);
}
$message = $this->buildTelegramMessage($validated, $request);
$telegramUrl = sprintf('https://api.telegram.org/bot%s/sendMessage', $botToken);
try {
$response = Http::asForm()
->timeout(12)
->post($telegramUrl, [
'chat_id' => $chatId,
'text' => $message,
'disable_web_page_preview' => true,
]);
} catch (Throwable) {
return back()
->withInput()
->withErrors(['contact' => 'Не удалось отправить заявку в Telegram. Попробуйте еще раз.']);
}
if (!$response->successful() || $response->json('ok') !== true) {
return back()
->withInput()
->withErrors(['contact' => 'Telegram не принял заявку. Проверьте токен бота и chat id.']);
}
return back()->with('status', 'Заявка отправлена. Мы свяжемся с вами в ближайшее время.');
}
private function buildTelegramMessage(array $data, Request $request): string
{
$siteName = (string) config('shop.company_name', config('app.name', 'PC Shop'));
return implode("\n", [
'Новая заявка с формы контактов',
'Сайт: ' . $siteName,
'Имя: ' . trim((string) ($data['name'] ?? '')),
'Email: ' . trim((string) ($data['email'] ?? '')),
'Сообщение:',
trim((string) ($data['message'] ?? '')),
'IP: ' . $request->ip(),
'Дата: ' . now()->format('d.m.Y H:i'),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Product;
class FavoriteController extends Controller
{
public function index()
{
$favoriteIds = $this->favoriteIds();
$position = array_flip($favoriteIds);
$products = Product::query()
->whereIn('id', $favoriteIds)
->where('is_active', true)
->with('category')
->get()
->sortBy(fn (Product $product) => $position[$product->id] ?? PHP_INT_MAX)
->values();
return view('shop.favorites', [
'products' => $products,
]);
}
public function toggle(Product $product)
{
$favorites = $this->favoriteIds();
$exists = in_array($product->id, $favorites, true);
if ($exists) {
$favorites = array_values(array_filter($favorites, fn (int $id) => $id !== $product->id));
session()->put('favorites', $favorites);
return back()->with('status', "Товар \"{$product->name}\" удален из избранного.");
}
$favorites[] = $product->id;
session()->put('favorites', array_values(array_unique($favorites)));
return back()->with('status', "Товар \"{$product->name}\" добавлен в избранное.");
}
private function favoriteIds(): array
{
return array_values(array_map('intval', (array) session()->get('favorites', [])));
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Order;
use Illuminate\Http\Request;
class OrderController extends Controller
{
public function show(Request $request, Order $order)
{
if ((int) $order->user_id !== (int) $request->user()->id) {
abort(403);
}
$order->load('items.product');
return view('shop.order', [
'order' => $order,
]);
}
}

View File

@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Product;
class ProductController extends Controller
{
public function show(Product $product)
{
$product->load('category');
$related = Product::query()
->where('is_active', true)
->where('stock', '>', 0)
->where('category_id', $product->category_id)
->whereKeyNot($product->id)
->with('category')
->latest('id')
->take(4)
->get();
return view('shop.product', [
'product' => $product,
'specLabels' => $this->specLabelsForProduct($product),
'related' => $related,
]);
}
private function specLabelsForCategory(?string $slug): array
{
if (!$slug) {
return [];
}
return collect(config('product_specs.categories.' . $slug, []))
->mapWithKeys(fn (array $definition) => [$definition['key'] => $definition['label']])
->all();
}
private function specLabelsForProduct(Product $product): array
{
$labels = $this->specLabelsForCategory($product->category?->slug);
$fallback = $this->defaultSpecLabels();
foreach (array_keys((array) ($product->specs ?? [])) as $key) {
if (!isset($labels[$key])) {
$labels[$key] = $fallback[$key] ?? str_replace('_', ' ', $key);
}
}
return $labels;
}
private function defaultSpecLabels(): array
{
return [
'manufacturer' => 'Производитель',
'socket_type' => 'Тип сокета',
'cpu_type' => 'Тип процессора',
'form_factor' => 'Форм-фактор',
'cpu_socket' => 'Сокет для процессора',
'condition' => 'Состояние',
'chipset' => 'Чипсет',
'memory_type' => 'Тип памяти',
'pcie_version' => 'Версия PCI Express',
'wifi_standard' => 'Стандарт Wi-Fi',
'max_memory' => 'Максимальный объем памяти',
'cache' => 'Объем кэша',
'capacity' => 'Объем',
'gpu' => 'GPU',
'vram' => 'Объем видеопамяти',
'vram_type' => 'Тип видеопамяти',
'kit' => 'Количество модулей',
'frequency' => 'Частота',
'power' => 'Мощность',
'efficiency' => 'Сертификат 80 Plus',
'size' => 'Типоразмер',
'gpu_length' => 'Макс. длина видеокарты',
'intel_socket' => 'Сокет Intel',
'amd_socket' => 'Сокет AMD',
'fan_speed' => 'Скорость вращения',
'fans' => 'Количество вентиляторов',
'type' => 'Тип',
'model' => 'Модель',
'color' => 'Цвет',
'screen_size' => 'Диагональ экрана',
'cpu_brand' => 'Производитель процессора',
'cpu_model' => 'Модель процессора',
'ram' => 'Оперативная память',
'storage' => 'Накопитель',
'panel' => 'Тип матрицы',
'resolution' => 'Разрешение экрана',
'refresh_rate' => 'Частота обновления',
'smart_tv' => 'Smart TV',
'cores' => 'Количество ядер',
'gpu_brand' => 'Производитель видеокарты',
'gpu_model' => 'Модель видеокарты',
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Shop;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\HomeSlide;
use App\Models\Product;
class ShopController extends Controller
{
public function home()
{
$categories = Category::query()
->where('is_active', true)
->orderBy('name')
->take(12)
->get();
$featured = Product::query()
->where('is_active', true)
->where('stock', '>', 0)
->with('category')
->orderByDesc('stock')
->orderByDesc('id')
->take(12)
->get();
$newProducts = Product::query()
->where('is_active', true)
->where('stock', '>', 0)
->with('category')
->orderByDesc('created_at')
->orderByDesc('id')
->take(12)
->get();
$leftSlides = HomeSlide::query()
->active()
->forZone('left')
->ordered()
->get();
$rightSlides = HomeSlide::query()
->active()
->forZone('right')
->ordered()
->get();
return view('shop.home', [
'categories' => $categories,
'featured' => $featured,
'newProducts' => $newProducts,
'leftSlides' => $leftSlides,
'rightSlides' => $rightSlides,
]);
}
}