This commit is contained in:
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
86
.env.example
Normal file
86
.env.example
Normal file
@@ -0,0 +1,86 @@
|
||||
APP_NAME="PC Shop"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
APP_LOCALE=ru
|
||||
APP_FALLBACK_LOCALE=ru
|
||||
APP_FAKER_LOCALE=ru_RU
|
||||
|
||||
SHOP_COMPANY_NAME="PC Shop"
|
||||
SHOP_COMPANY_DESCRIPTION="Интернет-магазин компьютерных комплектующих, ноутбуков и периферии. Подбор, сравнение и заказ в одном месте."
|
||||
SHOP_CONTACT_PHONE="+7 900 000 00 00"
|
||||
SHOP_CONTACT_EMAIL="support@pcshop.test"
|
||||
SHOP_CONTACT_TELEGRAM="@pcshop_support"
|
||||
SHOP_TELEGRAM_BOT_TOKEN=
|
||||
SHOP_TELEGRAM_CHAT_ID=
|
||||
SHOP_CONTACT_ADDRESS="ул. Технопарк, 24, Техноград"
|
||||
SHOP_CONTACT_HOURS="Пн-Вс: 10:00-20:00"
|
||||
SHOP_CURRENCY_CODE="RUB"
|
||||
SHOP_CURRENCY_SYMBOL="₽"
|
||||
SHOP_PAYMENT_BANK="Сбербанк"
|
||||
SHOP_PAYMENT_CARD_HOLDER="ИВАНОВА ИВАННА ИВАНОВНА"
|
||||
SHOP_PAYMENT_CARD_NUMBER="0000 0000 0000 0000"
|
||||
|
||||
SEO_SITE_NAME="${APP_NAME}"
|
||||
SEO_DEFAULT_TITLE="Интернет-магазин компьютерных комплектующих"
|
||||
SEO_DEFAULT_DESCRIPTION="Процессоры, видеокарты, материнские платы, ноутбуки и периферия с доставкой по стране."
|
||||
SEO_DEFAULT_KEYWORDS="комплектующие для пк, процессоры, видеокарты, материнские платы, ноутбуки, интернет-магазин"
|
||||
SEO_DEFAULT_IMAGE="/favicon.ico"
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=pc-shop
|
||||
DB_USERNAME=workeruser
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
42
.github/workflows/deploy.yml
vendored
Normal file
42
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: Git branch to deploy
|
||||
required: true
|
||||
default: main
|
||||
|
||||
concurrency:
|
||||
group: production-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Prepare SSH key and known_hosts
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
|
||||
run: |
|
||||
install -m 700 -d ~/.ssh
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
|
||||
printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
||||
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/known_hosts
|
||||
|
||||
- name: Run deployment on server
|
||||
env:
|
||||
DEPLOY_BRANCH: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref_name }}
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/id_ed25519 -p "${DEPLOY_PORT:-22}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
|
||||
"cd \"${DEPLOY_PATH}\" && bash scripts/update-from-github.sh \"${DEPLOY_BRANCH}\""
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/bootstrap/cache/*.php
|
||||
!/bootstrap/cache/.gitignore
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
228
DEPLOY.md
Normal file
228
DEPLOY.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Деплой PC Shop
|
||||
|
||||
В репозитории подготовлены production-артефакты:
|
||||
|
||||
- `scripts/deploy.sh` - полный деплой в текущем checkout.
|
||||
- `scripts/update-from-github.sh` - `git pull` + production-деплой.
|
||||
- `deploy/nginx/pc-shop.http.conf` - HTTP-конфиг для первого запуска и выпуска сертификата.
|
||||
- `deploy/nginx/pc-shop.conf` - финальный HTTPS-конфиг для Nginx.
|
||||
- `.github/workflows/deploy.yml` - автодеплой по SSH из GitHub Actions.
|
||||
|
||||
## 1. Что должно быть на сервере
|
||||
|
||||
- PHP 8.2+ с расширениями `bcmath`, `ctype`, `curl`, `fileinfo`, `intl`, `mbstring`, `openssl`, `pdo_pgsql`, `tokenizer`, `xml`, `zip`
|
||||
- Composer 2.x
|
||||
- Node.js 20+ и npm
|
||||
- PostgreSQL 14+
|
||||
- Nginx + PHP-FPM
|
||||
- Certbot
|
||||
- Git
|
||||
|
||||
Если деплой будет обновляться из GitHub, проект на сервере должен быть именно `git clone`, а не архивом без `.git`.
|
||||
|
||||
## 2. Первый запуск на сервере
|
||||
|
||||
```bash
|
||||
cd /var/www
|
||||
git clone git@github.com:YOUR_ACCOUNT/pc-shop.git
|
||||
cd pc-shop
|
||||
|
||||
cp .env.example .env
|
||||
# заполните .env и переключите его на production значения
|
||||
|
||||
php artisan key:generate --force
|
||||
bash scripts/deploy.sh
|
||||
```
|
||||
|
||||
Опционально добавить демо-данные:
|
||||
|
||||
```bash
|
||||
php artisan db:seed --class=ShopCatalogSeeder
|
||||
```
|
||||
|
||||
## 3. Production `.env`
|
||||
|
||||
Базой остаётся обычный `.env.example`, отдельный production-файл не нужен. После копирования в `.env` минимально проверьте:
|
||||
|
||||
```dotenv
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://shop.example.com
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=pc-shop
|
||||
DB_USERNAME=pc_shop
|
||||
DB_PASSWORD=secret
|
||||
|
||||
SESSION_DRIVER=database
|
||||
CACHE_STORE=database
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="sales@example.com"
|
||||
|
||||
SHOP_CONTACT_PHONE="+7 900 000 00 00"
|
||||
SHOP_CONTACT_EMAIL="support@example.com"
|
||||
SHOP_TELEGRAM_BOT_TOKEN=
|
||||
SHOP_TELEGRAM_CHAT_ID=
|
||||
```
|
||||
|
||||
Отдельный queue worker и cron сейчас не обязательны: в коде нет фоновых jobs или scheduler-задач. Если они появятся позже, для воркера уже подготовлен безопасный `php artisan queue:restart` в deploy-скрипте.
|
||||
|
||||
## 4. Что делает `scripts/deploy.sh`
|
||||
|
||||
Скрипт:
|
||||
|
||||
1. Переводит приложение в maintenance mode.
|
||||
2. Выполняет `composer install --no-dev`.
|
||||
3. Выполняет `npm ci` и `npm run build`.
|
||||
4. Проверяет `storage:link`.
|
||||
5. Выполняет `composer run deploy`.
|
||||
6. Делает `php artisan queue:restart`, если воркеры запущены.
|
||||
7. Возвращает приложение в online.
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
bash scripts/deploy.sh
|
||||
```
|
||||
|
||||
## 5. Обновление с GitHub на сервере
|
||||
|
||||
После обычного `git clone` обновление становится одной командой:
|
||||
|
||||
```bash
|
||||
bash scripts/update-from-github.sh main
|
||||
```
|
||||
|
||||
Скрипт:
|
||||
|
||||
- останавливается, если есть локальные изменения в tracked-файлах;
|
||||
- делает `git fetch --prune`;
|
||||
- переключается на нужную ветку;
|
||||
- выполняет `git pull --ff-only`;
|
||||
- запускает production-деплой.
|
||||
|
||||
По умолчанию ветка `main`, но можно передать другую:
|
||||
|
||||
```bash
|
||||
bash scripts/update-from-github.sh release
|
||||
```
|
||||
|
||||
## 6. Nginx
|
||||
|
||||
В репозитории два шаблона:
|
||||
|
||||
- `deploy/nginx/pc-shop.http.conf` - стартовый HTTP-конфиг для первого запуска и `/.well-known/acme-challenge/`
|
||||
- `deploy/nginx/pc-shop.conf` - основной HTTPS-конфиг с редиректом `80 -> 443`
|
||||
|
||||
До выпуска сертификата включите именно HTTP-шаблон:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx/pc-shop.http.conf /etc/nginx/sites-available/pc-shop.conf
|
||||
sudo ln -s /etc/nginx/sites-available/pc-shop.conf /etc/nginx/sites-enabled/pc-shop.conf
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
Что нужно проверить в шаблонах:
|
||||
|
||||
- `server_name`
|
||||
- `root`
|
||||
- путь в `fastcgi_pass` под ваш PHP-FPM socket
|
||||
|
||||
Корень сайта должен смотреть в `public/`, не в корень проекта.
|
||||
|
||||
## 7. HTTPS и сертификат
|
||||
|
||||
1. Убедитесь, что домен уже указывает на сервер и порт `80` открыт.
|
||||
2. В `.env` укажите `APP_URL=https://shop.example.com`.
|
||||
3. Выпустите сертификат через `certbot`:
|
||||
|
||||
```bash
|
||||
sudo certbot certonly --webroot -w /var/www/pc-shop/public -d shop.example.com -d www.shop.example.com
|
||||
```
|
||||
|
||||
4. После успешного выпуска переключите nginx на HTTPS-шаблон:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx/pc-shop.conf /etc/nginx/sites-available/pc-shop.conf
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
5. Обновите Laravel-кеши после смены `APP_URL`:
|
||||
|
||||
```bash
|
||||
composer run deploy
|
||||
```
|
||||
|
||||
Что нужно проверить в `deploy/nginx/pc-shop.conf`:
|
||||
|
||||
- `server_name`
|
||||
- `fastcgi_pass`
|
||||
- путь в `ssl_certificate`
|
||||
- путь в `ssl_certificate_key`
|
||||
|
||||
После этого сайт должен открываться по `https://`, а `http://` будет перекидываться на защищённую версию.
|
||||
|
||||
Если вы предпочитаете, чтобы `certbot` сам правил nginx, можно вместо `certonly --webroot` использовать:
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d shop.example.com -d www.shop.example.com
|
||||
```
|
||||
|
||||
Но для предсказуемого деплоя удобнее оставить конфиг под контролем репозитория и использовать `certonly`.
|
||||
|
||||
## 8. Автообновление из GitHub Actions
|
||||
|
||||
В репозитории добавлен workflow `.github/workflows/deploy.yml`.
|
||||
|
||||
Он:
|
||||
|
||||
- запускается на push в `main`;
|
||||
- умеет ручной `workflow_dispatch`;
|
||||
- подключается по SSH на сервер;
|
||||
- вызывает `bash scripts/update-from-github.sh <branch>`.
|
||||
|
||||
Нужные GitHub Secrets:
|
||||
|
||||
- `DEPLOY_HOST`
|
||||
- `DEPLOY_PORT`
|
||||
- `DEPLOY_USER`
|
||||
- `DEPLOY_PATH`
|
||||
- `DEPLOY_SSH_KEY`
|
||||
- `DEPLOY_KNOWN_HOSTS`
|
||||
|
||||
`DEPLOY_PATH` должен указывать на директорию проекта на сервере, например `/var/www/pc-shop`.
|
||||
|
||||
`DEPLOY_KNOWN_HOSTS` можно получить так:
|
||||
|
||||
```bash
|
||||
ssh-keyscan -H your-server-hostname
|
||||
```
|
||||
|
||||
## 9. Проверка после деплоя
|
||||
|
||||
```bash
|
||||
php artisan about
|
||||
php artisan migrate:status
|
||||
php artisan route:list
|
||||
```
|
||||
|
||||
Проверьте страницы:
|
||||
|
||||
- `https://shop.example.com`
|
||||
- `/`
|
||||
- `/catalog`
|
||||
- `/cart`
|
||||
- `/favorites`
|
||||
- `/compare`
|
||||
- `/login`
|
||||
- `/admin/login`
|
||||
66
app/Http/Controllers/Admin/AdminAuthController.php
Normal file
66
app/Http/Controllers/Admin/AdminAuthController.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Concerns\ManagesCaptcha;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
class AdminAuthController extends Controller
|
||||
{
|
||||
use ManagesCaptcha;
|
||||
|
||||
private const LOGIN_CAPTCHA_CONTEXT = 'admin_login';
|
||||
|
||||
public function showLoginForm(Request $request)
|
||||
{
|
||||
return view('admin.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()->withErrors(['captcha' => 'Неверный ответ на капчу.'])->withInput();
|
||||
}
|
||||
|
||||
$credentials = [
|
||||
'email' => $validated['email'],
|
||||
'password' => $validated['password'],
|
||||
];
|
||||
|
||||
if (!Auth::attempt($credentials, $request->boolean('remember'))) {
|
||||
return back()->withErrors(['email' => 'Неверный email или пароль.'])->withInput();
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
$this->clearCaptcha($request, self::LOGIN_CAPTCHA_CONTEXT);
|
||||
|
||||
if (!$request->user()->is_admin) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return back()->withErrors(['email' => 'Доступ разрешен только администраторам.']);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.dashboard');
|
||||
}
|
||||
|
||||
public function logout(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
}
|
||||
24
app/Http/Controllers/Admin/AdminDashboardController.php
Normal file
24
app/Http/Controllers/Admin/AdminDashboardController.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
|
||||
class AdminDashboardController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.dashboard', [
|
||||
'stats' => [
|
||||
'categories' => Category::count(),
|
||||
'products' => Product::count(),
|
||||
'orders' => Order::count(),
|
||||
'revenue' => (float) Order::sum('total'),
|
||||
],
|
||||
'recentOrders' => Order::query()->latest('id')->take(8)->get(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Admin/CategoryController.php
Normal file
94
app/Http/Controllers/Admin/CategoryController.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.categories.index', [
|
||||
'categories' => Category::query()->latest('id')->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return view('admin.categories.create');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $this->validateCategory($request);
|
||||
$data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name']);
|
||||
Category::create($data);
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('status', 'Категория создана.');
|
||||
}
|
||||
|
||||
public function edit(Category $category)
|
||||
{
|
||||
return view('admin.categories.edit', [
|
||||
'category' => $category,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Category $category)
|
||||
{
|
||||
$data = $this->validateCategory($request, $category);
|
||||
$data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name'], $category->id);
|
||||
$category->update($data);
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('status', 'Категория обновлена.');
|
||||
}
|
||||
|
||||
public function destroy(Category $category)
|
||||
{
|
||||
$category->delete();
|
||||
|
||||
return redirect()->route('admin.categories.index')->with('status', 'Категория удалена.');
|
||||
}
|
||||
|
||||
private function validateCategory(Request $request, ?Category $category = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('categories', 'slug')->ignore($category?->id),
|
||||
],
|
||||
'description' => ['nullable', 'string'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
]) + ['is_active' => $request->boolean('is_active')];
|
||||
}
|
||||
|
||||
private function makeUniqueSlug(string $value, ?int $ignoreId = null): string
|
||||
{
|
||||
$base = Str::slug($value);
|
||||
if ($base === '') {
|
||||
$base = 'category';
|
||||
}
|
||||
|
||||
$slug = $base;
|
||||
$index = 2;
|
||||
|
||||
while (
|
||||
Category::query()
|
||||
->where('slug', $slug)
|
||||
->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))
|
||||
->exists()
|
||||
) {
|
||||
$slug = "{$base}-{$index}";
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
178
app/Http/Controllers/Admin/ChatController.php
Normal file
178
app/Http/Controllers/Admin/ChatController.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ChatConversation;
|
||||
use App\Models\ChatMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$conversations = ChatConversation::query()
|
||||
->with('user', 'latestMessage')
|
||||
->withCount([
|
||||
'messages as unread_count' => fn ($query) => $query
|
||||
->where('sender', 'customer')
|
||||
->where('is_read', false),
|
||||
])
|
||||
->orderByDesc('last_message_at')
|
||||
->orderByDesc('id')
|
||||
->paginate(30);
|
||||
|
||||
$selectedConversation = $this->resolveSelectedConversation($request, $conversations);
|
||||
$initialMessages = collect();
|
||||
|
||||
if ($selectedConversation) {
|
||||
$this->markCustomerMessagesAsRead($selectedConversation);
|
||||
$initialMessages = $this->conversationMessages($selectedConversation);
|
||||
}
|
||||
|
||||
return view('admin.chats.index', [
|
||||
'conversations' => $conversations,
|
||||
'selectedConversation' => $selectedConversation,
|
||||
'initialMessages' => $initialMessages->map(fn (ChatMessage $message) => $this->messagePayload($message))->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function messages(ChatConversation $conversation): JsonResponse
|
||||
{
|
||||
$this->markCustomerMessagesAsRead($conversation);
|
||||
|
||||
return response()->json([
|
||||
'conversation' => $this->conversationPayload($conversation->loadMissing('user')),
|
||||
'messages' => $this->conversationMessages($conversation)
|
||||
->map(fn (ChatMessage $message) => $this->messagePayload($message))
|
||||
->values(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function storeMessage(Request $request, ChatConversation $conversation): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
]);
|
||||
|
||||
$messageText = $this->sanitizeMessage((string) $validated['message']);
|
||||
if ($messageText === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'message' => 'Сообщение содержит недопустимые символы.',
|
||||
]);
|
||||
}
|
||||
|
||||
$message = $conversation->messages()->create([
|
||||
'sender' => 'admin',
|
||||
'body' => $messageText,
|
||||
'is_read' => false,
|
||||
]);
|
||||
|
||||
$conversation->update([
|
||||
'status' => 'open',
|
||||
'last_message_at' => now(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $this->messagePayload($message),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request, ChatConversation $conversation): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', 'string', Rule::in([
|
||||
ChatConversation::STATUS_OPEN,
|
||||
ChatConversation::STATUS_CLOSED,
|
||||
])],
|
||||
]);
|
||||
|
||||
$conversation->update([
|
||||
'status' => $validated['status'],
|
||||
]);
|
||||
|
||||
return redirect()
|
||||
->route('admin.chats.index', ['conversation' => $conversation->id])
|
||||
->with('status', $conversation->isClosed() ? 'Чат закрыт.' : 'Чат открыт.');
|
||||
}
|
||||
|
||||
public function destroy(ChatConversation $conversation): RedirectResponse
|
||||
{
|
||||
$conversation->delete();
|
||||
|
||||
return redirect()
|
||||
->route('admin.chats.index')
|
||||
->with('status', 'Чат удален.');
|
||||
}
|
||||
|
||||
private function resolveSelectedConversation(Request $request, $conversations): ?ChatConversation
|
||||
{
|
||||
$selectedId = $request->integer('conversation');
|
||||
if ($selectedId > 0) {
|
||||
return ChatConversation::query()->with('user')->find($selectedId);
|
||||
}
|
||||
|
||||
$firstConversation = $conversations->first();
|
||||
if (!$firstConversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ChatConversation::query()->with('user')->find($firstConversation->id);
|
||||
}
|
||||
|
||||
private function markCustomerMessagesAsRead(ChatConversation $conversation): void
|
||||
{
|
||||
$conversation->messages()
|
||||
->where('sender', 'customer')
|
||||
->where('is_read', false)
|
||||
->update(['is_read' => true]);
|
||||
}
|
||||
|
||||
private function conversationMessages(ChatConversation $conversation)
|
||||
{
|
||||
return $conversation->messages()
|
||||
->orderBy('id')
|
||||
->limit(200)
|
||||
->get();
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
$preview = $conversation->latestMessage?->body;
|
||||
|
||||
return [
|
||||
'id' => $conversation->id,
|
||||
'title' => $conversation->display_name,
|
||||
'subtitle' => $conversation->user?->email ?: ('Токен: ' . Str::limit($conversation->visitor_token, 14, '...')),
|
||||
'preview' => $preview ? Str::limit($preview, 80) : 'Нет сообщений',
|
||||
'status' => $conversation->status,
|
||||
'is_closed' => $conversation->isClosed(),
|
||||
];
|
||||
}
|
||||
|
||||
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, ''));
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/Admin/HomeSlideController.php
Normal file
167
app/Http/Controllers/Admin/HomeSlideController.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HomeSlide;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HomeSlideController extends Controller
|
||||
{
|
||||
private const ZONES = ['left', 'right'];
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$slides = HomeSlide::query()
|
||||
->orderByRaw("CASE WHEN zone = 'left' THEN 0 ELSE 1 END")
|
||||
->ordered()
|
||||
->get()
|
||||
->groupBy('zone');
|
||||
|
||||
return view('admin.home-slides.index', [
|
||||
'slides' => $slides,
|
||||
'zoneLabels' => $this->zoneLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $request): View
|
||||
{
|
||||
$zone = in_array($request->query('zone'), self::ZONES, true) ? $request->query('zone') : 'left';
|
||||
|
||||
return view('admin.home-slides.create', [
|
||||
'slide' => new HomeSlide([
|
||||
'zone' => $zone,
|
||||
'show_title' => true,
|
||||
'show_subtitle' => true,
|
||||
'show_button' => true,
|
||||
'sort_order' => 100,
|
||||
'is_active' => true,
|
||||
]),
|
||||
'zoneLabels' => $this->zoneLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateSlide($request, true);
|
||||
$data['image_path'] = $this->storeImage($request);
|
||||
|
||||
HomeSlide::create($data);
|
||||
|
||||
return redirect()->route('admin.home-slides.index')->with('status', 'Слайд добавлен.');
|
||||
}
|
||||
|
||||
public function edit(HomeSlide $homeSlide): View
|
||||
{
|
||||
return view('admin.home-slides.edit', [
|
||||
'slide' => $homeSlide,
|
||||
'zoneLabels' => $this->zoneLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, HomeSlide $homeSlide): RedirectResponse
|
||||
{
|
||||
$data = $this->validateSlide($request);
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$newPath = $this->storeImage($request);
|
||||
$oldPath = $homeSlide->image_path;
|
||||
|
||||
$data['image_path'] = $newPath;
|
||||
$homeSlide->update($data);
|
||||
|
||||
if ($oldPath && $oldPath !== $newPath) {
|
||||
$this->deleteImage($oldPath);
|
||||
}
|
||||
|
||||
return redirect()->route('admin.home-slides.index')->with('status', 'Слайд обновлен.');
|
||||
}
|
||||
|
||||
$homeSlide->update($data);
|
||||
|
||||
return redirect()->route('admin.home-slides.index')->with('status', 'Слайд обновлен.');
|
||||
}
|
||||
|
||||
public function destroy(HomeSlide $homeSlide): RedirectResponse
|
||||
{
|
||||
$this->deleteImage($homeSlide->image_path);
|
||||
$homeSlide->delete();
|
||||
|
||||
return redirect()->route('admin.home-slides.index')->with('status', 'Слайд удален.');
|
||||
}
|
||||
|
||||
private function validateSlide(Request $request, bool $requireImage = false): array
|
||||
{
|
||||
return $request->validate([
|
||||
'zone' => ['required', Rule::in(self::ZONES)],
|
||||
'title' => ['nullable', 'string', 'max:160'],
|
||||
'show_title' => ['nullable', 'boolean'],
|
||||
'subtitle' => ['nullable', 'string', 'max:1500'],
|
||||
'show_subtitle' => ['nullable', 'boolean'],
|
||||
'button_text' => ['nullable', 'string', 'max:60'],
|
||||
'button_url' => ['nullable', 'string', 'max:255', 'regex:/^(\\/|https?:\\/\\/).+/i'],
|
||||
'show_button' => ['nullable', 'boolean'],
|
||||
'sort_order' => ['required', 'integer', 'min:0', 'max:9999'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'image' => [
|
||||
$requireImage ? 'required' : 'nullable',
|
||||
'image',
|
||||
'mimes:jpg,jpeg,png,webp',
|
||||
'max:5120',
|
||||
],
|
||||
]) + [
|
||||
'show_title' => $request->boolean('show_title'),
|
||||
'show_subtitle' => $request->boolean('show_subtitle'),
|
||||
'show_button' => $request->boolean('show_button'),
|
||||
'is_active' => $request->boolean('is_active'),
|
||||
];
|
||||
}
|
||||
|
||||
private function storeImage(Request $request): string
|
||||
{
|
||||
$file = $request->file('image');
|
||||
if (!$file) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
if ($extension === '') {
|
||||
$extension = 'jpg';
|
||||
}
|
||||
|
||||
$name = now()->format('YmdHis') . '-' . Str::uuid() . '.' . $extension;
|
||||
return (string) $file->storeAs('home-slides', $name, 'public');
|
||||
}
|
||||
|
||||
private function deleteImage(?string $path): void
|
||||
{
|
||||
if (!$path) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'uploads/')) {
|
||||
$filePath = public_path(ltrim($path, '/'));
|
||||
if (File::exists($filePath)) {
|
||||
File::delete($filePath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
|
||||
private function zoneLabels(): array
|
||||
{
|
||||
return [
|
||||
'left' => 'Большой слайдер (2/3)',
|
||||
'right' => 'Малый слайдер (1/3)',
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Http/Controllers/Admin/OrderController.php
Normal file
38
app/Http/Controllers/Admin/OrderController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.orders.index', [
|
||||
'orders' => Order::query()->latest('id')->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Order $order)
|
||||
{
|
||||
$order->load('items.product', 'user');
|
||||
|
||||
return view('admin.orders.show', [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Order $order)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => ['required', 'string', Rule::in(['new', 'processing', 'paid', 'shipped', 'completed', 'cancelled'])],
|
||||
]);
|
||||
|
||||
$order->update($data);
|
||||
|
||||
return back()->with('status', 'Статус заказа обновлен.');
|
||||
}
|
||||
}
|
||||
816
app/Http/Controllers/Admin/ProductController.php
Normal file
816
app/Http/Controllers/Admin/ProductController.php
Normal file
@@ -0,0 +1,816 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.products.index', [
|
||||
'products' => Product::query()->with('category')->latest('id')->paginate(20),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$categories = Category::query()->where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('admin.products.create', [
|
||||
'categories' => $categories,
|
||||
'categorySpecsById' => $this->categorySpecDefinitionsById($categories),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validateProduct($request);
|
||||
unset($data['image'], $data['gallery_images'], $data['remove_image'], $data['remove_gallery_paths']);
|
||||
$data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name']);
|
||||
$category = Category::query()->find($data['category_id']);
|
||||
$data['specs'] = $this->parseSpecs(
|
||||
(array) $request->input('specs', []),
|
||||
$request->input('specs_text'),
|
||||
$category?->slug
|
||||
);
|
||||
|
||||
$imagePath = $request->hasFile('image') ? $this->storeImage($request) : null;
|
||||
$galleryPaths = $request->hasFile('gallery_images')
|
||||
? $this->storeGalleryImages($request)
|
||||
: [];
|
||||
|
||||
[$imagePath, $galleryPaths] = $this->normalizeProductImageSet($imagePath, $galleryPaths);
|
||||
|
||||
$data['image_path'] = $imagePath;
|
||||
$data['gallery_paths'] = $galleryPaths;
|
||||
|
||||
if ($data['image_path'] === null) {
|
||||
unset($data['image_path']);
|
||||
}
|
||||
|
||||
if ($data['gallery_paths'] === []) {
|
||||
unset($data['gallery_paths']);
|
||||
}
|
||||
|
||||
Product::create($data);
|
||||
|
||||
return redirect()->route('admin.products.index')->with('status', 'Товар создан.');
|
||||
}
|
||||
|
||||
public function edit(Product $product)
|
||||
{
|
||||
$categories = Category::query()->where('is_active', true)->orderBy('name')->get();
|
||||
|
||||
return view('admin.products.edit', [
|
||||
'product' => $product,
|
||||
'categories' => $categories,
|
||||
'categorySpecsById' => $this->categorySpecDefinitionsById($categories),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Product $product): RedirectResponse
|
||||
{
|
||||
$data = $this->validateProduct($request, $product);
|
||||
unset($data['image'], $data['gallery_images'], $data['remove_image'], $data['remove_gallery_paths']);
|
||||
$data['slug'] = $this->makeUniqueSlug($data['slug'] ?: $data['name'], $product->id);
|
||||
$category = Category::query()->find($data['category_id']);
|
||||
$data['specs'] = $this->parseSpecs(
|
||||
(array) $request->input('specs', []),
|
||||
$request->input('specs_text'),
|
||||
$category?->slug
|
||||
);
|
||||
|
||||
$oldImagePath = $product->image_path;
|
||||
$oldGalleryPaths = $this->normalizeImagePaths((array) ($product->gallery_paths ?? []));
|
||||
$removeImage = $request->boolean('remove_image');
|
||||
$removeGalleryPaths = $this->normalizeImagePaths((array) $request->input('remove_gallery_paths', []));
|
||||
|
||||
$nextImagePath = $oldImagePath;
|
||||
$nextGalleryPaths = $oldGalleryPaths;
|
||||
|
||||
if ($request->hasFile('image')) {
|
||||
$nextImagePath = $this->storeImage($request);
|
||||
} elseif ($removeImage) {
|
||||
$nextImagePath = null;
|
||||
if ($oldImagePath) {
|
||||
$removeGalleryPaths[] = $oldImagePath;
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->hasFile('gallery_images')) {
|
||||
$nextGalleryPaths = $this->storeGalleryImages($request);
|
||||
} elseif ($removeGalleryPaths !== []) {
|
||||
$removablePaths = array_values(array_intersect($oldGalleryPaths, $removeGalleryPaths));
|
||||
$nextGalleryPaths = array_values(array_filter(
|
||||
$oldGalleryPaths,
|
||||
fn (string $path) => !in_array($path, $removablePaths, true)
|
||||
));
|
||||
}
|
||||
|
||||
[$nextImagePath, $nextGalleryPaths] = $this->normalizeProductImageSet($nextImagePath, $nextGalleryPaths);
|
||||
|
||||
$data['image_path'] = $nextImagePath;
|
||||
$data['gallery_paths'] = $nextGalleryPaths;
|
||||
|
||||
$product->update($data);
|
||||
|
||||
$activePaths = $this->normalizeImagePaths(
|
||||
array_merge(
|
||||
[$product->image_path],
|
||||
(array) ($product->gallery_paths ?? [])
|
||||
)
|
||||
);
|
||||
|
||||
if ($oldImagePath && !in_array($oldImagePath, $activePaths, true)) {
|
||||
$this->deleteImage($oldImagePath);
|
||||
}
|
||||
|
||||
foreach ($oldGalleryPaths as $oldPath) {
|
||||
if (!in_array($oldPath, $activePaths, true)) {
|
||||
$this->deleteImage($oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.products.index')->with('status', 'Товар обновлен.');
|
||||
}
|
||||
|
||||
public function destroy(Product $product): RedirectResponse
|
||||
{
|
||||
$this->deleteImages(array_merge([$product->image_path], (array) ($product->gallery_paths ?? [])));
|
||||
$product->delete();
|
||||
|
||||
return redirect()->route('admin.products.index')->with('status', 'Товар удален.');
|
||||
}
|
||||
|
||||
public function exportCsv(): StreamedResponse
|
||||
{
|
||||
$specKeys = $this->allSpecKeys();
|
||||
$headers = array_merge(
|
||||
[
|
||||
'id',
|
||||
'category_slug',
|
||||
'category_name',
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
'price',
|
||||
'old_price',
|
||||
'stock',
|
||||
'short_description',
|
||||
'description',
|
||||
'is_active',
|
||||
],
|
||||
$specKeys,
|
||||
['specs_json']
|
||||
);
|
||||
|
||||
return response()->streamDownload(function () use ($headers, $specKeys) {
|
||||
$output = fopen('php://output', 'wb');
|
||||
if (!$output) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite($output, "\xEF\xBB\xBF");
|
||||
fputcsv($output, $headers, ';');
|
||||
|
||||
Product::query()
|
||||
->with('category')
|
||||
->orderBy('id')
|
||||
->chunk(200, function (EloquentCollection $products) use ($output, $specKeys) {
|
||||
foreach ($products as $product) {
|
||||
$specs = (array) ($product->specs ?? []);
|
||||
$row = [
|
||||
$product->id,
|
||||
$product->category?->slug ?? '',
|
||||
$product->category?->name ?? '',
|
||||
$product->name,
|
||||
$product->slug,
|
||||
$product->sku,
|
||||
$product->price,
|
||||
$product->old_price,
|
||||
$product->stock,
|
||||
$product->short_description,
|
||||
$product->description,
|
||||
$product->is_active ? 1 : 0,
|
||||
];
|
||||
|
||||
foreach ($specKeys as $key) {
|
||||
$row[] = $specs[$key] ?? '';
|
||||
}
|
||||
|
||||
$row[] = $specs === [] ? '' : json_encode($specs, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
fputcsv($output, $row, ';');
|
||||
}
|
||||
});
|
||||
|
||||
fclose($output);
|
||||
}, 'products-' . now()->format('Ymd-His') . '.csv', [
|
||||
'Content-Type' => 'text/csv; charset=UTF-8',
|
||||
]);
|
||||
}
|
||||
|
||||
public function importCsv(Request $request): RedirectResponse
|
||||
{
|
||||
$request->validate([
|
||||
'csv_file' => ['required', 'file', 'mimes:csv,txt', 'max:5120'],
|
||||
]);
|
||||
|
||||
$path = $request->file('csv_file')?->getRealPath();
|
||||
if (!$path) {
|
||||
return back()->withErrors(['csv_file' => 'Не удалось прочитать файл.']);
|
||||
}
|
||||
|
||||
[$handle, $delimiter] = $this->openCsv($path);
|
||||
if (!$handle) {
|
||||
return back()->withErrors(['csv_file' => 'Не удалось открыть CSV файл.']);
|
||||
}
|
||||
|
||||
$headerRow = fgetcsv($handle, 0, $delimiter);
|
||||
if ($headerRow === false) {
|
||||
fclose($handle);
|
||||
return back()->withErrors(['csv_file' => 'CSV файл пустой.']);
|
||||
}
|
||||
|
||||
$headers = array_map(fn ($header) => $this->normalizeCsvHeader((string) $header), $headerRow);
|
||||
if (!in_array('name', $headers, true) || !in_array('price', $headers, true)) {
|
||||
fclose($handle);
|
||||
return back()->withErrors(['csv_file' => 'В CSV должны быть колонки name и price.']);
|
||||
}
|
||||
|
||||
if (!in_array('category_slug', $headers, true) && !in_array('category_name', $headers, true)) {
|
||||
fclose($handle);
|
||||
return back()->withErrors(['csv_file' => 'В CSV должна быть category_slug или category_name.']);
|
||||
}
|
||||
|
||||
$categories = Category::query()->get();
|
||||
$categoriesBySlug = $categories->keyBy(fn (Category $category) => mb_strtolower($category->slug));
|
||||
$categoriesByName = $categories->keyBy(fn (Category $category) => mb_strtolower($category->name));
|
||||
$allSpecKeys = $this->allSpecKeys();
|
||||
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
|
||||
if ($this->rowIsEmpty($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowData = $this->mapCsvRow($headers, $row);
|
||||
$category = $this->resolveCategory($rowData, $categoriesBySlug, $categoriesByName);
|
||||
|
||||
$name = trim((string) ($rowData['name'] ?? ''));
|
||||
$price = $this->parseDecimal($rowData['price'] ?? null);
|
||||
$stock = $this->parseInteger($rowData['stock'] ?? 0);
|
||||
|
||||
if (!$category || $name === '' || $price === null || $stock === null) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$sku = $this->trimToNull((string) ($rowData['sku'] ?? ''));
|
||||
$slugInput = trim((string) ($rowData['slug'] ?? ''));
|
||||
|
||||
$existing = null;
|
||||
if ($slugInput !== '') {
|
||||
$existing = Product::query()->where('slug', Str::slug($slugInput))->first();
|
||||
}
|
||||
|
||||
if (!$existing && $sku !== null) {
|
||||
$existing = Product::query()->where('sku', $sku)->first();
|
||||
}
|
||||
|
||||
$slug = $this->makeUniqueSlug($slugInput !== '' ? $slugInput : $name, $existing?->id);
|
||||
$specs = $this->extractSpecsFromCsvRow($rowData, $allSpecKeys, $category->slug);
|
||||
|
||||
$payload = [
|
||||
'category_id' => $category->id,
|
||||
'name' => $name,
|
||||
'slug' => $slug,
|
||||
'sku' => $this->trimToNull((string) ($rowData['sku'] ?? '')),
|
||||
'price' => $price,
|
||||
'old_price' => $this->parseDecimal($rowData['old_price'] ?? null),
|
||||
'stock' => $stock,
|
||||
'short_description' => $this->trimToNull((string) ($rowData['short_description'] ?? '')),
|
||||
'description' => $this->trimToNull((string) ($rowData['description'] ?? '')),
|
||||
'is_active' => $this->parseBooleanValue($rowData['is_active'] ?? null, true),
|
||||
'specs' => $specs,
|
||||
];
|
||||
|
||||
if ($existing) {
|
||||
$existing->update($payload);
|
||||
$updated++;
|
||||
} else {
|
||||
Product::create($payload);
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return redirect()
|
||||
->route('admin.products.index')
|
||||
->with('status', "Импорт завершен: создано {$created}, обновлено {$updated}, пропущено {$skipped}.");
|
||||
}
|
||||
|
||||
private function validateProduct(Request $request, ?Product $product = null): array
|
||||
{
|
||||
return $request->validate([
|
||||
'category_id' => ['required', 'integer', 'exists:categories,id'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('products', 'slug')->ignore($product?->id),
|
||||
],
|
||||
'sku' => ['nullable', 'string', 'max:255'],
|
||||
'price' => ['required', 'numeric', 'min:0'],
|
||||
'old_price' => ['nullable', 'numeric', 'min:0'],
|
||||
'stock' => ['required', 'integer', 'min:0'],
|
||||
'short_description' => ['nullable', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'image' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'gallery_images' => ['nullable', 'array', 'max:12'],
|
||||
'gallery_images.*' => ['image', 'mimes:jpg,jpeg,png,webp', 'max:5120'],
|
||||
'remove_image' => ['nullable', 'boolean'],
|
||||
'remove_gallery_paths' => ['nullable', 'array'],
|
||||
'remove_gallery_paths.*' => ['string', 'max:255'],
|
||||
'is_active' => ['nullable', 'boolean'],
|
||||
'specs' => ['nullable', 'array'],
|
||||
'specs.*' => ['nullable', 'string', 'max:255'],
|
||||
'specs_text' => ['nullable', 'string'],
|
||||
]) + ['is_active' => $request->boolean('is_active')];
|
||||
}
|
||||
|
||||
private function makeUniqueSlug(string $value, ?int $ignoreId = null): string
|
||||
{
|
||||
$base = Str::slug($value);
|
||||
if ($base === '') {
|
||||
$base = 'product';
|
||||
}
|
||||
|
||||
$slug = $base;
|
||||
$index = 2;
|
||||
|
||||
while (
|
||||
Product::query()
|
||||
->where('slug', $slug)
|
||||
->when($ignoreId, fn ($query) => $query->whereKeyNot($ignoreId))
|
||||
->exists()
|
||||
) {
|
||||
$slug = "{$base}-{$index}";
|
||||
$index++;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
|
||||
private function parseSpecs(array $specsInput = [], ?string $specsText = null, ?string $categorySlug = null): array
|
||||
{
|
||||
$specs = [];
|
||||
|
||||
foreach ($specsInput as $key => $value) {
|
||||
$normalizedKey = $this->normalizeSpecKey((string) $key);
|
||||
$normalizedValue = trim((string) $value);
|
||||
|
||||
if ($normalizedKey === '' || $normalizedValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$specs[$normalizedKey] = $normalizedValue;
|
||||
}
|
||||
|
||||
if ($specsText !== null && trim($specsText) !== '') {
|
||||
$specs = array_merge($this->parseSpecsFromText($specsText), $specs);
|
||||
}
|
||||
|
||||
return $this->filterSpecsByCategory($specs, $categorySlug);
|
||||
}
|
||||
|
||||
private function parseSpecsFromText(string $specsText): array
|
||||
{
|
||||
$rows = preg_split('/\r\n|\r|\n/', $specsText);
|
||||
$specs = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$line = trim($row);
|
||||
if ($line === '' || !str_contains($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = array_map('trim', explode(':', $line, 2));
|
||||
$key = $this->normalizeSpecKey($key);
|
||||
|
||||
if ($key === '' || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$specs[$key] = $value;
|
||||
}
|
||||
|
||||
return $specs;
|
||||
}
|
||||
|
||||
private function normalizeSpecKey(string $key): string
|
||||
{
|
||||
return Str::of($key)
|
||||
->lower()
|
||||
->replace(' ', '_')
|
||||
->replace('-', '_')
|
||||
->trim()
|
||||
->toString();
|
||||
}
|
||||
|
||||
private function categorySpecDefinitionsById(Collection $categories): array
|
||||
{
|
||||
$specDefinitions = $this->specDefinitions();
|
||||
$savedValuesByCategory = $this->savedSpecValuesByCategory($categories);
|
||||
|
||||
return $categories
|
||||
->mapWithKeys(function (Category $category) use ($specDefinitions, $savedValuesByCategory) {
|
||||
$definitions = $specDefinitions[$category->slug] ?? [];
|
||||
$categoryValues = $savedValuesByCategory[$category->id] ?? [];
|
||||
|
||||
$prepared = array_map(function (array $definition) use ($categoryValues) {
|
||||
$key = (string) ($definition['key'] ?? '');
|
||||
if ($key === '') {
|
||||
return $definition;
|
||||
}
|
||||
|
||||
$presetOptions = collect((array) ($definition['options'] ?? []))
|
||||
->map(fn ($value) => trim((string) $value))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$savedOptions = collect($categoryValues[$key] ?? [])
|
||||
->map(fn ($value) => trim((string) $value))
|
||||
->filter()
|
||||
->values();
|
||||
|
||||
$definition['options'] = $presetOptions
|
||||
->concat($savedOptions)
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $definition;
|
||||
}, $definitions);
|
||||
|
||||
return [$category->id => $prepared];
|
||||
})
|
||||
->all();
|
||||
}
|
||||
|
||||
private function specDefinitions(): array
|
||||
{
|
||||
return config('product_specs.categories', []);
|
||||
}
|
||||
|
||||
private function allSpecKeys(): array
|
||||
{
|
||||
return collect($this->specDefinitions())
|
||||
->flatten(1)
|
||||
->pluck('key')
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function savedSpecValuesByCategory(Collection $categories): array
|
||||
{
|
||||
$categoryIds = $categories->pluck('id')->filter()->values();
|
||||
if ($categoryIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = [];
|
||||
|
||||
Product::query()
|
||||
->select(['id', 'category_id', 'specs'])
|
||||
->whereIn('category_id', $categoryIds->all())
|
||||
->whereNotNull('specs')
|
||||
->chunkById(200, function (EloquentCollection $products) use (&$values) {
|
||||
foreach ($products as $product) {
|
||||
$specs = (array) ($product->specs ?? []);
|
||||
if ($specs === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($specs as $key => $value) {
|
||||
if (!is_scalar($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedKey = $this->normalizeSpecKey((string) $key);
|
||||
$normalizedValue = trim((string) $value);
|
||||
|
||||
if ($normalizedKey === '' || $normalizedValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$values[$product->category_id][$normalizedKey][$normalizedValue] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
foreach ($values as $categoryId => $specValues) {
|
||||
foreach ($specValues as $key => $valueSet) {
|
||||
$options = array_keys($valueSet);
|
||||
natcasesort($options);
|
||||
$values[$categoryId][$key] = array_values($options);
|
||||
}
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
private function filterSpecsByCategory(array $specs, ?string $categorySlug): array
|
||||
{
|
||||
if (!$categorySlug) {
|
||||
return $specs;
|
||||
}
|
||||
|
||||
$allowedKeys = collect($this->specDefinitions()[$categorySlug] ?? [])
|
||||
->pluck('key')
|
||||
->all();
|
||||
|
||||
if ($allowedKeys === []) {
|
||||
return $specs;
|
||||
}
|
||||
|
||||
return collect($specs)->only($allowedKeys)->all();
|
||||
}
|
||||
|
||||
private function openCsv(string $path): array
|
||||
{
|
||||
$handle = fopen($path, 'rb');
|
||||
if (!$handle) {
|
||||
return [null, ';'];
|
||||
}
|
||||
|
||||
$sample = fgets($handle);
|
||||
rewind($handle);
|
||||
|
||||
$delimiter = substr_count((string) $sample, ';') >= substr_count((string) $sample, ',') ? ';' : ',';
|
||||
|
||||
return [$handle, $delimiter];
|
||||
}
|
||||
|
||||
private function normalizeCsvHeader(string $header): string
|
||||
{
|
||||
$value = str_replace("\xEF\xBB\xBF", '', $header);
|
||||
|
||||
return Str::of($value)
|
||||
->trim()
|
||||
->lower()
|
||||
->replace(' ', '_')
|
||||
->replace('-', '_')
|
||||
->toString();
|
||||
}
|
||||
|
||||
private function rowIsEmpty(array $row): bool
|
||||
{
|
||||
foreach ($row as $value) {
|
||||
if (trim((string) $value) !== '') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function mapCsvRow(array $headers, array $row): array
|
||||
{
|
||||
$row = array_pad($row, count($headers), '');
|
||||
$mapped = [];
|
||||
|
||||
foreach ($headers as $index => $header) {
|
||||
if ($header === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mapped[$header] = trim((string) ($row[$index] ?? ''));
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
private function resolveCategory(array $rowData, Collection $bySlug, Collection $byName): ?Category
|
||||
{
|
||||
$slug = mb_strtolower(trim((string) ($rowData['category_slug'] ?? '')));
|
||||
if ($slug !== '' && $bySlug->has($slug)) {
|
||||
return $bySlug->get($slug);
|
||||
}
|
||||
|
||||
$name = mb_strtolower(trim((string) ($rowData['category_name'] ?? '')));
|
||||
if ($name !== '' && $byName->has($name)) {
|
||||
return $byName->get($name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractSpecsFromCsvRow(array $rowData, array $allSpecKeys, ?string $categorySlug): array
|
||||
{
|
||||
$specs = [];
|
||||
|
||||
foreach ($allSpecKeys as $key) {
|
||||
$value = trim((string) ($rowData[$key] ?? ''));
|
||||
if ($value !== '') {
|
||||
$specs[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$specsJson = trim((string) ($rowData['specs_json'] ?? ''));
|
||||
if ($specsJson !== '') {
|
||||
$decoded = json_decode($specsJson, true);
|
||||
|
||||
if (is_array($decoded)) {
|
||||
foreach ($decoded as $key => $value) {
|
||||
if (!is_scalar($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedKey = $this->normalizeSpecKey((string) $key);
|
||||
$normalizedValue = trim((string) $value);
|
||||
|
||||
if ($normalizedKey === '' || $normalizedValue === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$specs[$normalizedKey] = $normalizedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->filterSpecsByCategory($specs, $categorySlug);
|
||||
}
|
||||
|
||||
private function parseDecimal(mixed $value): ?float
|
||||
{
|
||||
$string = trim((string) $value);
|
||||
if ($string === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = str_replace([' ', ','], ['', '.'], $string);
|
||||
if (!is_numeric($normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decimal = (float) $normalized;
|
||||
|
||||
return $decimal < 0 ? null : $decimal;
|
||||
}
|
||||
|
||||
private function parseInteger(mixed $value): ?int
|
||||
{
|
||||
$string = trim((string) $value);
|
||||
if ($string === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!preg_match('/^-?\d+$/', $string)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$integer = (int) $string;
|
||||
|
||||
return $integer < 0 ? null : $integer;
|
||||
}
|
||||
|
||||
private function parseBooleanValue(mixed $value, bool $default = true): bool
|
||||
{
|
||||
$string = mb_strtolower(trim((string) $value));
|
||||
if ($string === '') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return match ($string) {
|
||||
'1', 'true', 'yes', 'y', 'да', 'active', 'on' => true,
|
||||
'0', 'false', 'no', 'n', 'нет', 'inactive', 'off' => false,
|
||||
default => $default,
|
||||
};
|
||||
}
|
||||
|
||||
private function storeImage(Request $request): string
|
||||
{
|
||||
$file = $request->file('image');
|
||||
if (!$file instanceof UploadedFile) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $this->storeUploadedImage($file);
|
||||
}
|
||||
|
||||
private function storeGalleryImages(Request $request): array
|
||||
{
|
||||
$files = $request->file('gallery_images', []);
|
||||
if (!is_array($files)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
foreach ($files as $file) {
|
||||
if (!$file instanceof UploadedFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $this->storeUploadedImage($file);
|
||||
if ($path !== '') {
|
||||
$paths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->normalizeImagePaths($paths);
|
||||
}
|
||||
|
||||
private function storeUploadedImage(UploadedFile $file): string
|
||||
{
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
if ($extension === '') {
|
||||
$extension = 'jpg';
|
||||
}
|
||||
|
||||
$name = now()->format('YmdHis') . '-' . Str::uuid() . '.' . $extension;
|
||||
|
||||
return (string) $file->storeAs('products', $name, 'public');
|
||||
}
|
||||
|
||||
private function deleteImage(?string $path): void
|
||||
{
|
||||
if (!$path) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_starts_with($path, 'uploads/')) {
|
||||
$filePath = public_path(ltrim($path, '/'));
|
||||
if (File::exists($filePath)) {
|
||||
File::delete($filePath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Storage::disk('public')->delete($path);
|
||||
}
|
||||
|
||||
private function deleteImages(array $paths): void
|
||||
{
|
||||
foreach ($this->normalizeImagePaths($paths) as $path) {
|
||||
$this->deleteImage($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeImagePaths(array $paths): array
|
||||
{
|
||||
return collect($paths)
|
||||
->filter(fn ($path) => is_string($path) && trim($path) !== '')
|
||||
->map(fn (string $path) => trim($path))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function normalizeProductImageSet(?string $imagePath, array $galleryPaths): array
|
||||
{
|
||||
$normalizedImagePath = $this->trimToNull((string) $imagePath);
|
||||
$normalizedGalleryPaths = $this->normalizeImagePaths($galleryPaths);
|
||||
|
||||
if ($normalizedImagePath === null && $normalizedGalleryPaths !== []) {
|
||||
$normalizedImagePath = array_shift($normalizedGalleryPaths);
|
||||
}
|
||||
|
||||
if ($normalizedImagePath !== null) {
|
||||
$normalizedGalleryPaths = array_values(array_filter(
|
||||
$normalizedGalleryPaths,
|
||||
fn (string $path) => $path !== $normalizedImagePath
|
||||
));
|
||||
}
|
||||
|
||||
return [$normalizedImagePath, $normalizedGalleryPaths];
|
||||
}
|
||||
|
||||
private function trimToNull(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
}
|
||||
39
app/Http/Controllers/Concerns/ManagesCaptcha.php
Normal file
39
app/Http/Controllers/Concerns/ManagesCaptcha.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Concerns;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait ManagesCaptcha
|
||||
{
|
||||
protected function buildCaptchaQuestion(Request $request, string $context): string
|
||||
{
|
||||
$first = random_int(2, 9);
|
||||
$second = random_int(1, 9);
|
||||
$isSubtraction = random_int(0, 1) === 1;
|
||||
|
||||
if ($isSubtraction && $second > $first) {
|
||||
[$first, $second] = [$second, $first];
|
||||
}
|
||||
|
||||
$question = $isSubtraction ? "{$first} - {$second}" : "{$first} + {$second}";
|
||||
$answer = (string) ($isSubtraction ? $first - $second : $first + $second);
|
||||
|
||||
$request->session()->put("captcha.{$context}.answer", $answer);
|
||||
|
||||
return $question;
|
||||
}
|
||||
|
||||
protected function captchaIsValid(Request $request, string $context, string $field = 'captcha'): bool
|
||||
{
|
||||
$expected = (string) $request->session()->get("captcha.{$context}.answer", '');
|
||||
$provided = trim((string) $request->input($field, ''));
|
||||
|
||||
return $expected !== '' && hash_equals($expected, $provided);
|
||||
}
|
||||
|
||||
protected function clearCaptcha(Request $request, string $context): void
|
||||
{
|
||||
$request->session()->forget("captcha.{$context}.answer");
|
||||
}
|
||||
}
|
||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
33
app/Http/Controllers/Shop/AccountController.php
Normal file
33
app/Http/Controllers/Shop/AccountController.php
Normal 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', 'Данные профиля обновлены.');
|
||||
}
|
||||
}
|
||||
102
app/Http/Controllers/Shop/AuthController.php
Normal file
102
app/Http/Controllers/Shop/AuthController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
106
app/Http/Controllers/Shop/CartController.php
Normal file
106
app/Http/Controllers/Shop/CartController.php
Normal 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}\" удален из корзины.");
|
||||
}
|
||||
}
|
||||
325
app/Http/Controllers/Shop/CatalogController.php
Normal file
325
app/Http/Controllers/Shop/CatalogController.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
199
app/Http/Controllers/Shop/ChatController.php
Normal file
199
app/Http/Controllers/Shop/ChatController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
174
app/Http/Controllers/Shop/CheckoutController.php
Normal file
174
app/Http/Controllers/Shop/CheckoutController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
119
app/Http/Controllers/Shop/CompareController.php
Normal file
119
app/Http/Controllers/Shop/CompareController.php
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
71
app/Http/Controllers/Shop/ContactController.php
Normal file
71
app/Http/Controllers/Shop/ContactController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Shop/FavoriteController.php
Normal file
50
app/Http/Controllers/Shop/FavoriteController.php
Normal 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', [])));
|
||||
}
|
||||
}
|
||||
23
app/Http/Controllers/Shop/OrderController.php
Normal file
23
app/Http/Controllers/Shop/OrderController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
101
app/Http/Controllers/Shop/ProductController.php
Normal file
101
app/Http/Controllers/Shop/ProductController.php
Normal 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' => 'Модель видеокарты',
|
||||
];
|
||||
}
|
||||
}
|
||||
58
app/Http/Controllers/Shop/ShopController.php
Normal file
58
app/Http/Controllers/Shop/ShopController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
91
app/Http/Controllers/SitemapController.php
Normal file
91
app/Http/Controllers/SitemapController.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SitemapController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$latestProductUpdate = Product::query()->where('is_active', true)->max('updated_at');
|
||||
$latestCategoryUpdate = Category::query()->where('is_active', true)->max('updated_at');
|
||||
$catalogLastmod = collect([$latestProductUpdate, $latestCategoryUpdate])
|
||||
->filter()
|
||||
->map(fn ($value) => $value instanceof \Carbon\CarbonInterface ? $value : \Carbon\Carbon::parse((string) $value))
|
||||
->max();
|
||||
$catalogLastmodIso = $catalogLastmod?->toAtomString() ?? now()->toAtomString();
|
||||
|
||||
$urls = collect([
|
||||
[
|
||||
'loc' => route('home'),
|
||||
'lastmod' => $catalogLastmodIso,
|
||||
'changefreq' => 'daily',
|
||||
'priority' => '1.0',
|
||||
],
|
||||
[
|
||||
'loc' => route('catalog.index'),
|
||||
'lastmod' => $catalogLastmodIso,
|
||||
'changefreq' => 'daily',
|
||||
'priority' => '0.9',
|
||||
],
|
||||
[
|
||||
'loc' => route('pages.about'),
|
||||
'lastmod' => now()->toAtomString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => '0.5',
|
||||
],
|
||||
[
|
||||
'loc' => route('pages.contacts'),
|
||||
'lastmod' => now()->toAtomString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => '0.5',
|
||||
],
|
||||
[
|
||||
'loc' => route('pages.shipping-payment'),
|
||||
'lastmod' => now()->toAtomString(),
|
||||
'changefreq' => 'monthly',
|
||||
'priority' => '0.5',
|
||||
],
|
||||
]);
|
||||
|
||||
$categoryUrls = Category::query()
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->map(function (Category $category) {
|
||||
return [
|
||||
'loc' => route('catalog.category', $category),
|
||||
'lastmod' => $category->updated_at?->toAtomString() ?? now()->toAtomString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => '0.8',
|
||||
];
|
||||
});
|
||||
|
||||
$productUrls = Product::query()
|
||||
->where('is_active', true)
|
||||
->get()
|
||||
->map(function (Product $product) {
|
||||
return [
|
||||
'loc' => route('products.show', $product),
|
||||
'lastmod' => $product->updated_at?->toAtomString() ?? now()->toAtomString(),
|
||||
'changefreq' => 'weekly',
|
||||
'priority' => '0.7',
|
||||
];
|
||||
});
|
||||
|
||||
$allUrls = $this->mergeUrls($urls, $categoryUrls, $productUrls);
|
||||
|
||||
return response()
|
||||
->view('seo.sitemap', ['urls' => $allUrls])
|
||||
->header('Content-Type', 'application/xml; charset=UTF-8');
|
||||
}
|
||||
|
||||
private function mergeUrls(Collection ...$collections): Collection
|
||||
{
|
||||
return collect($collections)
|
||||
->flatten(1)
|
||||
->values();
|
||||
}
|
||||
}
|
||||
23
app/Http/Middleware/AdminMiddleware.php
Normal file
23
app/Http/Middleware/AdminMiddleware.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AdminMiddleware
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (!$request->user()) {
|
||||
return redirect()->route('admin.login');
|
||||
}
|
||||
|
||||
if (!$request->user()->is_admin) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
35
app/Models/Category.php
Normal file
35
app/Models/Category.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function products()
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
}
|
||||
57
app/Models/ChatConversation.php
Normal file
57
app/Models/ChatConversation.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ChatConversation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_OPEN = 'open';
|
||||
public const STATUS_CLOSED = 'closed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'visitor_token',
|
||||
'status',
|
||||
'last_message_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_message_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function messages()
|
||||
{
|
||||
return $this->hasMany(ChatMessage::class);
|
||||
}
|
||||
|
||||
public function latestMessage()
|
||||
{
|
||||
return $this->hasOne(ChatMessage::class)->latestOfMany();
|
||||
}
|
||||
|
||||
public function getDisplayNameAttribute(): string
|
||||
{
|
||||
if ($this->user?->name) {
|
||||
return $this->user->name;
|
||||
}
|
||||
|
||||
return 'Гость #' . $this->id;
|
||||
}
|
||||
|
||||
public function isClosed(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CLOSED;
|
||||
}
|
||||
}
|
||||
30
app/Models/ChatMessage.php
Normal file
30
app/Models/ChatMessage.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ChatMessage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'chat_conversation_id',
|
||||
'sender',
|
||||
'body',
|
||||
'is_read',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_read' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function conversation()
|
||||
{
|
||||
return $this->belongsTo(ChatConversation::class, 'chat_conversation_id');
|
||||
}
|
||||
}
|
||||
69
app/Models/HomeSlide.php
Normal file
69
app/Models/HomeSlide.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class HomeSlide extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'zone',
|
||||
'title',
|
||||
'show_title',
|
||||
'subtitle',
|
||||
'show_subtitle',
|
||||
'button_text',
|
||||
'button_url',
|
||||
'show_button',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'show_title' => 'boolean',
|
||||
'show_subtitle' => 'boolean',
|
||||
'show_button' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeForZone($query, string $zone)
|
||||
{
|
||||
return $query->where('zone', $zone);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('id');
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
if (!$this->image_path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::startsWith($this->image_path, ['http://', 'https://', '/'])) {
|
||||
return $this->image_path;
|
||||
}
|
||||
|
||||
if (Str::startsWith($this->image_path, 'uploads/')) {
|
||||
return asset($this->image_path);
|
||||
}
|
||||
|
||||
return '/storage/' . ltrim($this->image_path, '/');
|
||||
}
|
||||
}
|
||||
50
app/Models/Order.php
Normal file
50
app/Models/Order.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'status',
|
||||
'payment_method',
|
||||
'total',
|
||||
'items_count',
|
||||
'customer_name',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'comment',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'total' => 'decimal:2',
|
||||
'items_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function getPaymentMethodLabelAttribute(): string
|
||||
{
|
||||
return match ($this->payment_method) {
|
||||
'card_transfer' => 'Перевод по реквизитам (на карту)',
|
||||
default => 'Не указан',
|
||||
};
|
||||
}
|
||||
}
|
||||
39
app/Models/OrderItem.php
Normal file
39
app/Models/OrderItem.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class OrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'product_id',
|
||||
'name',
|
||||
'price',
|
||||
'quantity',
|
||||
'subtotal',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'decimal:2',
|
||||
'subtotal' => 'decimal:2',
|
||||
'quantity' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function order()
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function product()
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
89
app/Models/Product.php
Normal file
89
app/Models/Product.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'category_id',
|
||||
'name',
|
||||
'slug',
|
||||
'sku',
|
||||
'price',
|
||||
'old_price',
|
||||
'stock',
|
||||
'short_description',
|
||||
'description',
|
||||
'image_path',
|
||||
'gallery_paths',
|
||||
'specs',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'decimal:2',
|
||||
'old_price' => 'decimal:2',
|
||||
'stock' => 'integer',
|
||||
'gallery_paths' => 'array',
|
||||
'specs' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function getRouteKeyName(): string
|
||||
{
|
||||
return 'slug';
|
||||
}
|
||||
|
||||
public function getImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->resolveImagePath($this->image_path);
|
||||
}
|
||||
|
||||
public function getGalleryUrlsAttribute(): array
|
||||
{
|
||||
$paths = collect((array) ($this->gallery_paths ?? []))
|
||||
->prepend($this->image_path)
|
||||
->filter(fn ($path) => is_string($path) && trim($path) !== '')
|
||||
->map(fn (string $path) => trim($path))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return $paths
|
||||
->map(fn (string $path) => $this->resolveImagePath($path))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function resolveImagePath(?string $path): ?string
|
||||
{
|
||||
$path = trim((string) $path);
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::startsWith($path, ['http://', 'https://', '/'])) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
if (Str::startsWith($path, 'uploads/')) {
|
||||
return asset($path);
|
||||
}
|
||||
|
||||
return '/storage/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
60
app/Models/User.php
Normal file
60
app/Models/User.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'is_admin',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be hidden for serialization.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
protected $hidden = [
|
||||
'password',
|
||||
'remember_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'is_admin' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function orders()
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
}
|
||||
|
||||
public function chatConversations()
|
||||
{
|
||||
return $this->hasMany(ChatConversation::class);
|
||||
}
|
||||
}
|
||||
68
app/Providers/AppServiceProvider.php
Normal file
68
app/Providers/AppServiceProvider.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
RateLimiter::for('auth', function (Request $request) {
|
||||
$email = Str::lower((string) $request->input('email'));
|
||||
|
||||
return Limit::perMinute(10)->by($email . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('admin-login', function (Request $request) {
|
||||
$email = Str::lower((string) $request->input('email'));
|
||||
|
||||
return Limit::perMinute(5)->by($email . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('chat-read', function (Request $request) {
|
||||
$identity = $request->user()?->id
|
||||
? 'user:' . $request->user()->id
|
||||
: 'session:' . $request->session()->getId();
|
||||
|
||||
return Limit::perMinute(180)->by($identity . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('chat-send', function (Request $request) {
|
||||
$identity = $request->user()?->id
|
||||
? 'user:' . $request->user()->id
|
||||
: 'session:' . $request->session()->getId();
|
||||
|
||||
return Limit::perMinute(20)->by($identity . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('contact-send', function (Request $request) {
|
||||
$email = Str::lower(trim((string) $request->input('email', '')));
|
||||
|
||||
return Limit::perMinute(6)->by(($email !== '' ? $email : 'guest') . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('admin-chat-read', function (Request $request) {
|
||||
return Limit::perMinute(240)->by('admin:' . ($request->user()?->id ?? 'guest') . '|' . $request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('admin-chat-send', function (Request $request) {
|
||||
return Limit::perMinute(60)->by('admin:' . ($request->user()?->id ?? 'guest') . '|' . $request->ip());
|
||||
});
|
||||
}
|
||||
}
|
||||
18
artisan
Executable file
18
artisan
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
18
bootstrap/app.php
Normal file
18
bootstrap/app.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
//
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
2
bootstrap/cache/.gitignore
vendored
Normal file
2
bootstrap/cache/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
5
bootstrap/providers.php
Normal file
5
bootstrap/providers.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
];
|
||||
94
composer.json
Normal file
94
composer.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.24",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"deploy": [
|
||||
"@php artisan optimize:clear",
|
||||
"@php artisan migrate --force",
|
||||
"@php artisan config:cache",
|
||||
"@php artisan route:cache",
|
||||
"@php artisan view:cache",
|
||||
"@php artisan event:cache"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
8385
composer.lock
generated
Normal file
8385
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
126
config/app.php
Normal file
126
config/app.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
115
config/auth.php
Normal file
115
config/auth.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', App\Models\User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
117
config/cache.php
Normal file
117
config/cache.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "octane",
|
||||
| "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
];
|
||||
183
config/database.php
Normal file
183
config/database.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
80
config/filesystems.php
Normal file
80
config/filesystems.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
132
config/logging.php
Normal file
132
config/logging.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'days' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
118
config/mail.php
Normal file
118
config/mail.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
|
||||
];
|
||||
90
config/product_specs.php
Normal file
90
config/product_specs.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'categories' => [
|
||||
'processors' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'socket_type', 'label' => 'Тип сокета'],
|
||||
['key' => 'cpu_type', 'label' => 'Тип процессора'],
|
||||
['key' => 'cores', 'label' => 'Количество ядер', 'options' => ['6', '8', '10', '12', '16']],
|
||||
['key' => 'cache', 'label' => 'Объем кэша', 'options' => ['16 МБ', '32 МБ', '36 МБ', '64 МБ']],
|
||||
],
|
||||
'motherboards' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'form_factor', 'label' => 'Форм-фактор'],
|
||||
['key' => 'cpu_socket', 'label' => 'Сокет для процессора'],
|
||||
['key' => 'chipset', 'label' => 'Чипсет'],
|
||||
['key' => 'memory_type', 'label' => 'Тип поддерживаемой памяти'],
|
||||
['key' => 'max_memory', 'label' => 'Максимальный объем памяти (ГБ)', 'filter' => 'range'],
|
||||
['key' => 'pcie_version', 'label' => 'Версия PCI Express'],
|
||||
['key' => 'wifi_standard', 'label' => 'Стандарт Wi-Fi'],
|
||||
],
|
||||
'hard-drives' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'capacity', 'label' => 'Объем накопителя'],
|
||||
['key' => 'form_factor', 'label' => 'Форм-фактор'],
|
||||
],
|
||||
'graphics-cards' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'gpu', 'label' => 'GPU', 'options' => ['GeForce RTX 4060', 'GeForce RTX 4070', 'Radeon RX 7800 XT']],
|
||||
['key' => 'vram', 'label' => 'Объем видеопамяти'],
|
||||
['key' => 'vram_type', 'label' => 'Тип видеопамяти'],
|
||||
],
|
||||
'memory' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'memory_type', 'label' => 'Тип памяти'],
|
||||
['key' => 'form_factor', 'label' => 'Форм-фактор'],
|
||||
['key' => 'kit', 'label' => 'Количество модулей'],
|
||||
['key' => 'capacity', 'label' => 'Объем памяти'],
|
||||
['key' => 'frequency', 'label' => 'Частота памяти'],
|
||||
],
|
||||
'psu' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'power', 'label' => 'Мощность'],
|
||||
['key' => 'efficiency', 'label' => 'Сертификат 80 Plus'],
|
||||
],
|
||||
'cases' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'size', 'label' => 'Типоразмер'],
|
||||
['key' => 'gpu_length', 'label' => 'Макс. длина видеокарты'],
|
||||
],
|
||||
'cooling' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'intel_socket', 'label' => 'Сокет Intel'],
|
||||
['key' => 'amd_socket', 'label' => 'Сокет AMD'],
|
||||
['key' => 'fan_speed', 'label' => 'Скорость вращения'],
|
||||
['key' => 'fans', 'label' => 'Количество вентиляторов'],
|
||||
],
|
||||
'laptops' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'type', 'label' => 'Тип'],
|
||||
['key' => 'screen_size', 'label' => 'Диагональ экрана'],
|
||||
['key' => 'cpu_brand', 'label' => 'Производитель процессора'],
|
||||
['key' => 'cpu_model', 'label' => 'Модель процессора'],
|
||||
['key' => 'ram', 'label' => 'Объем оперативной памяти'],
|
||||
['key' => 'storage', 'label' => 'Объем накопителя'],
|
||||
['key' => 'panel', 'label' => 'Тип матрицы'],
|
||||
['key' => 'resolution', 'label' => 'Разрешение экрана'],
|
||||
['key' => 'cores', 'label' => 'Количество ядер'],
|
||||
['key' => 'gpu_brand', 'label' => 'Производитель видеокарты'],
|
||||
['key' => 'gpu_model', 'label' => 'Модель видеокарты'],
|
||||
],
|
||||
'televizory' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'screen_size', 'label' => 'Диагональ экрана'],
|
||||
['key' => 'resolution', 'label' => 'Разрешение экрана'],
|
||||
['key' => 'panel', 'label' => 'Тип матрицы'],
|
||||
['key' => 'refresh_rate', 'label' => 'Частота обновления'],
|
||||
['key' => 'smart_tv', 'label' => 'Smart TV'],
|
||||
],
|
||||
'apple' => [
|
||||
['key' => 'manufacturer', 'label' => 'Производитель'],
|
||||
['key' => 'type', 'label' => 'Тип устройства'],
|
||||
['key' => 'model', 'label' => 'Модель'],
|
||||
['key' => 'color', 'label' => 'Цвет', 'options' => ['Черный', 'Белый', 'Синий', 'Серебристый', 'Золотой', 'Фиолетовый']],
|
||||
['key' => 'screen_size', 'label' => 'Диагональ экрана'],
|
||||
['key' => 'storage', 'label' => 'Объем памяти'],
|
||||
['key' => 'condition', 'label' => 'Состояние', 'options' => ['Новое', 'Б/у']],
|
||||
],
|
||||
],
|
||||
];
|
||||
129
config/queue.php
Normal file
129
config/queue.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
16
config/seo.php
Normal file
16
config/seo.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'site_name' => env('SEO_SITE_NAME', env('APP_NAME', 'PC Shop')),
|
||||
'default_title' => env('SEO_DEFAULT_TITLE', 'Интернет-магазин компьютерных комплектующих'),
|
||||
'default_description' => env(
|
||||
'SEO_DEFAULT_DESCRIPTION',
|
||||
'Процессоры, видеокарты, материнские платы, ноутбуки и периферия с доставкой по стране.'
|
||||
),
|
||||
'default_keywords' => env(
|
||||
'SEO_DEFAULT_KEYWORDS',
|
||||
'комплектующие для пк, процессоры, видеокарты, материнские платы, ноутбуки, интернет-магазин'
|
||||
),
|
||||
'default_image' => env('SEO_DEFAULT_IMAGE', '/favicon.ico'),
|
||||
];
|
||||
|
||||
38
config/services.php
Normal file
38
config/services.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Third Party Services
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This file is for storing the credentials for third party services such
|
||||
| as Mailgun, Postmark, AWS and more. This file provides the de facto
|
||||
| location for this type of information, allowing packages to have
|
||||
| a conventional file to locate the various service credentials.
|
||||
|
|
||||
*/
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
217
config/session.php
Normal file
217
config/session.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
];
|
||||
21
config/shop.php
Normal file
21
config/shop.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'company_name' => env('SHOP_COMPANY_NAME', env('APP_NAME', 'PC Shop')),
|
||||
'company_description' => env(
|
||||
'SHOP_COMPANY_DESCRIPTION',
|
||||
'Интернет-магазин компьютерных комплектующих, ноутбуков и периферии. Подбор, сравнение и заказ в одном месте.'
|
||||
),
|
||||
'contact_phone' => env('SHOP_CONTACT_PHONE', '+7 900 000 00 00'),
|
||||
'contact_email' => env('SHOP_CONTACT_EMAIL', 'support@pcshop.test'),
|
||||
'contact_telegram' => env('SHOP_CONTACT_TELEGRAM', '@pcshop_support'),
|
||||
'telegram_bot_token' => env('SHOP_TELEGRAM_BOT_TOKEN', ''),
|
||||
'telegram_chat_id' => env('SHOP_TELEGRAM_CHAT_ID', ''),
|
||||
'contact_address' => env('SHOP_CONTACT_ADDRESS', 'ул. Технопарк, 24, Техноград'),
|
||||
'contact_hours' => env('SHOP_CONTACT_HOURS', 'Пн-Вс: 10:00-20:00'),
|
||||
'currency_code' => env('SHOP_CURRENCY_CODE', 'RUB'),
|
||||
'currency_symbol' => env('SHOP_CURRENCY_SYMBOL', '₽'),
|
||||
'payment_bank' => env('SHOP_PAYMENT_BANK', 'Сбербанк'),
|
||||
'payment_card_holder' => env('SHOP_PAYMENT_CARD_HOLDER', 'ИВАНОВА ИВАННА ИВАНОВНА'),
|
||||
'payment_card_number' => env('SHOP_PAYMENT_CARD_NUMBER', '0000 0000 0000 0000'),
|
||||
];
|
||||
1
database/.gitignore
vendored
Normal file
1
database/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
44
database/factories/UserFactory.php
Normal file
44
database/factories/UserFactory.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
49
database/migrations/0001_01_01_000000_create_users_table.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
35
database/migrations/0001_01_01_000001_create_cache_table.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->integer('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
57
database/migrations/0001_01_01_000002_create_jobs_table.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedTinyInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->boolean('is_admin')->default(false)->after('password');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('is_admin');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('categories');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('products', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('category_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->string('sku')->nullable();
|
||||
$table->decimal('price', 10, 2);
|
||||
$table->decimal('old_price', 10, 2)->nullable();
|
||||
$table->unsignedInteger('stock')->default(0);
|
||||
$table->text('short_description')->nullable();
|
||||
$table->longText('description')->nullable();
|
||||
$table->json('specs')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('products');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('status')->default('new');
|
||||
$table->decimal('total', 10, 2)->default(0);
|
||||
$table->unsignedInteger('items_count')->default(0);
|
||||
$table->string('customer_name');
|
||||
$table->string('email');
|
||||
$table->string('phone')->nullable();
|
||||
$table->string('address')->nullable();
|
||||
$table->text('comment')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('orders');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('order_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->decimal('price', 10, 2);
|
||||
$table->unsignedInteger('quantity');
|
||||
$table->decimal('subtotal', 10, 2);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('order_items');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->string('payment_method')->default('card_transfer')->after('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('orders', function (Blueprint $table) {
|
||||
$table->dropColumn('payment_method');
|
||||
});
|
||||
}
|
||||
};
|
||||
38
database/migrations/2026_02_21_000008_create_chat_tables.php
Normal file
38
database/migrations/2026_02_21_000008_create_chat_tables.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('chat_conversations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('visitor_token')->unique();
|
||||
$table->string('status')->default('open');
|
||||
$table->timestamp('last_message_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('chat_messages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('chat_conversation_id')->constrained('chat_conversations')->cascadeOnDelete();
|
||||
$table->string('sender', 20);
|
||||
$table->text('body');
|
||||
$table->boolean('is_read')->default(false);
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['chat_conversation_id', 'created_at']);
|
||||
$table->index(['chat_conversation_id', 'sender', 'is_read']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('chat_messages');
|
||||
Schema::dropIfExists('chat_conversations');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('home_slides', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('zone', 10)->index();
|
||||
$table->string('title', 160)->nullable();
|
||||
$table->text('subtitle')->nullable();
|
||||
$table->string('button_text', 60)->nullable();
|
||||
$table->string('button_url')->nullable();
|
||||
$table->string('image_path');
|
||||
$table->unsignedInteger('sort_order')->default(100)->index();
|
||||
$table->boolean('is_active')->default(true)->index();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['zone', 'is_active', 'sort_order']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('home_slides');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('sessions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('sessions')) {
|
||||
Schema::drop('sessions');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('products', 'image_path')) {
|
||||
$table->string('image_path')->nullable()->after('description');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('products', 'image_path')) {
|
||||
$table->dropColumn('image_path');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('home_slides', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('home_slides', 'show_title')) {
|
||||
$table->boolean('show_title')->default(true)->after('title');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('home_slides', 'show_subtitle')) {
|
||||
$table->boolean('show_subtitle')->default(true)->after('subtitle');
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('home_slides', 'show_button')) {
|
||||
$table->boolean('show_button')->default(true)->after('button_url');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('home_slides', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('home_slides', 'show_title')) {
|
||||
$table->dropColumn('show_title');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('home_slides', 'show_subtitle')) {
|
||||
$table->dropColumn('show_subtitle');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('home_slides', 'show_button')) {
|
||||
$table->dropColumn('show_button');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('products', 'gallery_paths')) {
|
||||
$table->json('gallery_paths')->nullable()->after('image_path');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('products', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('products', 'gallery_paths')) {
|
||||
$table->dropColumn('gallery_paths');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
27
database/seeders/DatabaseSeeder.php
Normal file
27
database/seeders/DatabaseSeeder.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
use WithoutModelEvents;
|
||||
|
||||
/**
|
||||
* Seed the application's database.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// User::factory(10)->create();
|
||||
|
||||
User::factory()->create([
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$this->call(ShopCatalogSeeder::class);
|
||||
}
|
||||
}
|
||||
545
database/seeders/ShopCatalogSeeder.php
Normal file
545
database/seeders/ShopCatalogSeeder.php
Normal file
@@ -0,0 +1,545 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ShopCatalogSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
[
|
||||
'name' => 'Процессоры',
|
||||
'slug' => 'processors',
|
||||
'description' => 'Процессоры для игровых и рабочих сборок.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'AMD Ryzen 7 7700X',
|
||||
'price' => 33990,
|
||||
'short_description' => '8 ядер, 16 потоков, до 5.4 ГГц.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'AMD',
|
||||
'socket_type' => 'AM5',
|
||||
'cpu_type' => 'Ryzen 7',
|
||||
'cores' => '8',
|
||||
'cache' => '32 МБ',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Intel Core i7-13700K',
|
||||
'price' => 38990,
|
||||
'short_description' => '16 ядер, для мощных систем.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Intel',
|
||||
'socket_type' => 'LGA1700',
|
||||
'cpu_type' => 'Core i7',
|
||||
'cores' => '16',
|
||||
'cache' => '30 МБ',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'AMD Ryzen 5 7600',
|
||||
'price' => 22990,
|
||||
'short_description' => 'Оптимален для игровых сборок.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'AMD',
|
||||
'socket_type' => 'AM5',
|
||||
'cpu_type' => 'Ryzen 5',
|
||||
'cores' => '6',
|
||||
'cache' => '32 МБ',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Материнские платы',
|
||||
'slug' => 'motherboards',
|
||||
'description' => 'Надежные платы для любых конфигураций.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'ASUS TUF B650-PLUS',
|
||||
'price' => 21990,
|
||||
'short_description' => 'AM5, усиленное питание, PCIe 4.0.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'ASUS',
|
||||
'form_factor' => 'ATX',
|
||||
'cpu_socket' => 'AM5',
|
||||
'chipset' => 'B650',
|
||||
'memory_type' => 'DDR5',
|
||||
'max_memory' => '192 ГБ',
|
||||
'pcie_version' => 'PCIe 4.0',
|
||||
'wifi_standard' => 'Wi-Fi 6',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'MSI MAG Z790 Tomahawk',
|
||||
'price' => 28990,
|
||||
'short_description' => 'LGA1700, мощное охлаждение VRM.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'MSI',
|
||||
'form_factor' => 'ATX',
|
||||
'cpu_socket' => 'LGA1700',
|
||||
'chipset' => 'Z790',
|
||||
'memory_type' => 'DDR5',
|
||||
'max_memory' => '256 ГБ',
|
||||
'pcie_version' => 'PCIe 5.0',
|
||||
'wifi_standard' => 'Wi-Fi 6E',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Gigabyte B760M DS3H',
|
||||
'price' => 13990,
|
||||
'short_description' => 'Компактная mATX для LGA1700.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Gigabyte',
|
||||
'form_factor' => 'mATX',
|
||||
'cpu_socket' => 'LGA1700',
|
||||
'chipset' => 'B760',
|
||||
'memory_type' => 'DDR4',
|
||||
'max_memory' => '128 ГБ',
|
||||
'pcie_version' => 'PCIe 4.0',
|
||||
'wifi_standard' => 'Нет',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Жесткие диски',
|
||||
'slug' => 'hard-drives',
|
||||
'description' => 'SSD и HDD для хранения данных.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'Samsung 990 PRO 1TB',
|
||||
'price' => 10990,
|
||||
'short_description' => 'NVMe SSD с высокой скоростью.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Samsung',
|
||||
'capacity' => '1 ТБ',
|
||||
'form_factor' => 'M.2 2280',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'WD Blue 2TB',
|
||||
'price' => 6990,
|
||||
'short_description' => 'Надежный HDD для хранения.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Western Digital',
|
||||
'capacity' => '2 ТБ',
|
||||
'form_factor' => '3.5\"',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Kingston NV2 500GB',
|
||||
'price' => 3990,
|
||||
'short_description' => 'Доступный NVMe накопитель.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Kingston',
|
||||
'capacity' => '500 ГБ',
|
||||
'form_factor' => 'M.2 2280',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Видеокарты',
|
||||
'slug' => 'graphics-cards',
|
||||
'description' => 'GPU для игр и работы с графикой.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'NVIDIA GeForce RTX 4070',
|
||||
'price' => 69990,
|
||||
'short_description' => '12 ГБ GDDR6X, DLSS 3.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'NVIDIA',
|
||||
'gpu' => 'GeForce RTX 4070',
|
||||
'vram' => '12 ГБ',
|
||||
'vram_type' => 'GDDR6X',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'AMD Radeon RX 7800 XT',
|
||||
'price' => 64990,
|
||||
'short_description' => '16 ГБ GDDR6, отличная производительность.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'AMD',
|
||||
'gpu' => 'Radeon RX 7800 XT',
|
||||
'vram' => '16 ГБ',
|
||||
'vram_type' => 'GDDR6',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'NVIDIA GeForce RTX 4060',
|
||||
'price' => 39990,
|
||||
'short_description' => '8 ГБ GDDR6 для 1080p.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'NVIDIA',
|
||||
'gpu' => 'GeForce RTX 4060',
|
||||
'vram' => '8 ГБ',
|
||||
'vram_type' => 'GDDR6',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Оперативная память',
|
||||
'slug' => 'memory',
|
||||
'description' => 'DDR4/DDR5 комплекты для скорости.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'Corsair Vengeance 32GB',
|
||||
'price' => 10990,
|
||||
'short_description' => 'DDR5 6000 МГц, 2x16 ГБ.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Corsair',
|
||||
'memory_type' => 'DDR5',
|
||||
'form_factor' => 'DIMM',
|
||||
'kit' => '2 модуля',
|
||||
'capacity' => '32 ГБ',
|
||||
'frequency' => '6000 МГц',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Kingston Fury Beast 16GB',
|
||||
'price' => 4990,
|
||||
'short_description' => 'DDR4 3200 МГц, 2x8 ГБ.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Kingston',
|
||||
'memory_type' => 'DDR4',
|
||||
'form_factor' => 'DIMM',
|
||||
'kit' => '2 модуля',
|
||||
'capacity' => '16 ГБ',
|
||||
'frequency' => '3200 МГц',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'G.Skill Ripjaws 32GB',
|
||||
'price' => 8990,
|
||||
'short_description' => 'DDR4 3600 МГц, 2x16 ГБ.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'G.Skill',
|
||||
'memory_type' => 'DDR4',
|
||||
'form_factor' => 'DIMM',
|
||||
'kit' => '2 модуля',
|
||||
'capacity' => '32 ГБ',
|
||||
'frequency' => '3600 МГц',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Блоки питания',
|
||||
'slug' => 'psu',
|
||||
'description' => 'Надежные блоки питания.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'Seasonic Focus GX-750',
|
||||
'price' => 11990,
|
||||
'short_description' => '750 Вт, 80 Plus Gold.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Seasonic',
|
||||
'power' => '750 Вт',
|
||||
'efficiency' => '80 Plus Gold',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Corsair RM850e',
|
||||
'price' => 13990,
|
||||
'short_description' => '850 Вт, тихий режим работы.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Corsair',
|
||||
'power' => '850 Вт',
|
||||
'efficiency' => '80 Plus Gold',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'be quiet! Pure Power 12 M 650',
|
||||
'price' => 9990,
|
||||
'short_description' => '650 Вт, стабильная линия питания.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'be quiet!',
|
||||
'power' => '650 Вт',
|
||||
'efficiency' => '80 Plus Gold',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Корпуса',
|
||||
'slug' => 'cases',
|
||||
'description' => 'Корпуса с отличным airflow.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'Lian Li Lancool 216',
|
||||
'price' => 8990,
|
||||
'short_description' => 'ATX, сетчатый фронт.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Lian Li',
|
||||
'size' => 'ATX Mid Tower',
|
||||
'gpu_length' => '392 мм',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'NZXT H5 Flow',
|
||||
'price' => 9490,
|
||||
'short_description' => 'Компактный и продуваемый.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'NZXT',
|
||||
'size' => 'ATX Mid Tower',
|
||||
'gpu_length' => '365 мм',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Cooler Master NR200P',
|
||||
'price' => 10990,
|
||||
'short_description' => 'Мини‑корпус для ITX.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Cooler Master',
|
||||
'size' => 'Mini ITX',
|
||||
'gpu_length' => '330 мм',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Системы охлаждения',
|
||||
'slug' => 'cooling',
|
||||
'description' => 'Охлаждение для процессора.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'Noctua NH-D15',
|
||||
'price' => 9990,
|
||||
'short_description' => 'Топовый воздушный кулер.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Noctua',
|
||||
'intel_socket' => 'LGA1700',
|
||||
'amd_socket' => 'AM5',
|
||||
'fan_speed' => '1500 об/мин',
|
||||
'fans' => '2 вентилятора',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'be quiet! Dark Rock 4',
|
||||
'price' => 7490,
|
||||
'short_description' => 'Тихая работа и охлаждение.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'be quiet!',
|
||||
'intel_socket' => 'LGA1700',
|
||||
'amd_socket' => 'AM4/AM5',
|
||||
'fan_speed' => '1400 об/мин',
|
||||
'fans' => '1 вентилятор',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'DeepCool LS520',
|
||||
'price' => 10490,
|
||||
'short_description' => 'Жидкостное охлаждение 240 мм.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'DeepCool',
|
||||
'intel_socket' => 'LGA1700',
|
||||
'amd_socket' => 'AM5',
|
||||
'fan_speed' => '2250 об/мин',
|
||||
'fans' => '2 вентилятора',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Ноутбуки',
|
||||
'slug' => 'laptops',
|
||||
'description' => 'Ноутбуки для работы и игр.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'ASUS TUF Gaming A15',
|
||||
'price' => 99990,
|
||||
'short_description' => '15.6\", Ryzen 7, RTX 4060.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'ASUS',
|
||||
'type' => 'Игровой',
|
||||
'screen_size' => '15.6\"',
|
||||
'cpu_brand' => 'AMD',
|
||||
'cpu_model' => 'Ryzen 7 7840HS',
|
||||
'ram' => '16 ГБ',
|
||||
'storage' => '1 ТБ SSD',
|
||||
'panel' => 'IPS',
|
||||
'resolution' => '1920x1080',
|
||||
'cores' => '8',
|
||||
'gpu_brand' => 'NVIDIA',
|
||||
'gpu_model' => 'RTX 4060',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Lenovo IdeaPad 5',
|
||||
'price' => 64990,
|
||||
'short_description' => '14\", легкий для работы.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Lenovo',
|
||||
'type' => 'Для работы',
|
||||
'screen_size' => '14\"',
|
||||
'cpu_brand' => 'Intel',
|
||||
'cpu_model' => 'Core i5-1340P',
|
||||
'ram' => '16 ГБ',
|
||||
'storage' => '512 ГБ SSD',
|
||||
'panel' => 'IPS',
|
||||
'resolution' => '1920x1200',
|
||||
'cores' => '12',
|
||||
'gpu_brand' => 'Intel',
|
||||
'gpu_model' => 'Iris Xe',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Acer Swift 3',
|
||||
'price' => 59990,
|
||||
'short_description' => '13.5\", компактный ультрабук.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Acer',
|
||||
'type' => 'Ультрабук',
|
||||
'screen_size' => '13.5\"',
|
||||
'cpu_brand' => 'Intel',
|
||||
'cpu_model' => 'Core i5-1235U',
|
||||
'ram' => '8 ГБ',
|
||||
'storage' => '512 ГБ SSD',
|
||||
'panel' => 'IPS',
|
||||
'resolution' => '2256x1504',
|
||||
'cores' => '10',
|
||||
'gpu_brand' => 'Intel',
|
||||
'gpu_model' => 'Iris Xe',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Телевизоры',
|
||||
'slug' => 'televizory',
|
||||
'description' => 'Телевизоры для дома и офиса.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'Samsung UE55CU7100',
|
||||
'price' => 49990,
|
||||
'short_description' => '55\", 4K UHD, Smart TV.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Samsung',
|
||||
'screen_size' => '55\"',
|
||||
'resolution' => '3840x2160',
|
||||
'panel' => 'LED',
|
||||
'refresh_rate' => '60 Гц',
|
||||
'smart_tv' => 'Да',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'LG 50NANO766QA',
|
||||
'price' => 57990,
|
||||
'short_description' => '50\", NanoCell, webOS.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'LG',
|
||||
'screen_size' => '50\"',
|
||||
'resolution' => '3840x2160',
|
||||
'panel' => 'NanoCell',
|
||||
'refresh_rate' => '60 Гц',
|
||||
'smart_tv' => 'Да',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Xiaomi TV A Pro 43',
|
||||
'price' => 29990,
|
||||
'short_description' => '43\", 4K UHD, Android TV.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Xiaomi',
|
||||
'screen_size' => '43\"',
|
||||
'resolution' => '3840x2160',
|
||||
'panel' => 'LED',
|
||||
'refresh_rate' => '60 Гц',
|
||||
'smart_tv' => 'Да',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Apple',
|
||||
'slug' => 'apple',
|
||||
'description' => 'Устройства Apple: ноутбуки, планшеты и смартфоны.',
|
||||
'products' => [
|
||||
[
|
||||
'name' => 'Apple MacBook Air 13 M2',
|
||||
'price' => 124990,
|
||||
'short_description' => '13.6\", чип M2, 8/256 ГБ.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Apple',
|
||||
'type' => 'Ноутбук',
|
||||
'model' => 'MacBook Air 13 M2',
|
||||
'color' => 'Серебристый',
|
||||
'screen_size' => '13.6\"',
|
||||
'storage' => '256 ГБ',
|
||||
'condition' => 'Новое',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Apple iPhone 14 128GB',
|
||||
'price' => 69990,
|
||||
'short_description' => '6.1\", 128 ГБ, отличное состояние.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Apple',
|
||||
'type' => 'Смартфон',
|
||||
'model' => 'iPhone 14',
|
||||
'color' => 'Синий',
|
||||
'screen_size' => '6.1\"',
|
||||
'storage' => '128 ГБ',
|
||||
'condition' => 'Б/у',
|
||||
],
|
||||
],
|
||||
[
|
||||
'name' => 'Apple iPad Air 11 M2',
|
||||
'price' => 87990,
|
||||
'short_description' => '11\", чип M2, Wi‑Fi.',
|
||||
'specs' => [
|
||||
'manufacturer' => 'Apple',
|
||||
'type' => 'Планшет',
|
||||
'model' => 'iPad Air 11 M2',
|
||||
'color' => 'Фиолетовый',
|
||||
'screen_size' => '11\"',
|
||||
'storage' => '256 ГБ',
|
||||
'condition' => 'Новое',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($categories as $categoryData) {
|
||||
$products = $categoryData['products'];
|
||||
unset($categoryData['products']);
|
||||
|
||||
$category = Category::query()->updateOrCreate(
|
||||
['slug' => $categoryData['slug']],
|
||||
$categoryData
|
||||
);
|
||||
|
||||
foreach ($products as $productData) {
|
||||
$slug = Str::slug($productData['name']);
|
||||
$skuPrefix = strtoupper(Str::replace('-', '', Str::substr($category->slug, 0, 3)));
|
||||
$sku = $skuPrefix . '-' . strtoupper(Str::substr(sha1($slug), 0, 6));
|
||||
$specs = (array) ($productData['specs'] ?? []);
|
||||
if (!isset($specs['condition']) || trim((string) $specs['condition']) === '') {
|
||||
$specs['condition'] = 'Новое';
|
||||
}
|
||||
|
||||
Product::query()->updateOrCreate(
|
||||
['slug' => $slug],
|
||||
array_merge($productData, [
|
||||
'category_id' => $category->id,
|
||||
'slug' => $slug,
|
||||
'sku' => $productData['sku'] ?? $sku,
|
||||
'stock' => $productData['stock'] ?? 12,
|
||||
'old_price' => $productData['old_price'] ?? round($productData['price'] * 1.12),
|
||||
'description' => $productData['description'] ?? ($productData['short_description'] ?? ''),
|
||||
'specs' => $specs,
|
||||
'is_active' => true,
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
deploy/nginx/pc-shop.conf
Normal file
63
deploy/nginx/pc-shop.conf
Normal file
@@ -0,0 +1,63 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name shop.example.com www.shop.example.com;
|
||||
root /var/www/pc-shop/public;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
default_type "text/plain";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name shop.example.com www.shop.example.com;
|
||||
root /var/www/pc-shop/public;
|
||||
index index.php;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/shop.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
charset utf-8;
|
||||
client_max_body_size 20m;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico {
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location = /robots.txt {
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||
fastcgi_read_timeout 60s;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
46
deploy/nginx/pc-shop.http.conf
Normal file
46
deploy/nginx/pc-shop.http.conf
Normal file
@@ -0,0 +1,46 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name shop.example.com www.shop.example.com;
|
||||
root /var/www/pc-shop/public;
|
||||
index index.php;
|
||||
|
||||
charset utf-8;
|
||||
client_max_body_size 20m;
|
||||
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
default_type "text/plain";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location = /favicon.ico {
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
location = /robots.txt {
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
error_page 404 /index.php;
|
||||
|
||||
location ~ \.php$ {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
fastcgi_param DOCUMENT_ROOT $realpath_root;
|
||||
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
|
||||
fastcgi_read_timeout 60s;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* {
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
2423
package-lock.json
generated
Normal file
2423
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^7.0.7"
|
||||
}
|
||||
}
|
||||
35
phpunit.xml
Normal file
35
phpunit.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
25
public/.htaccess
Normal file
25
public/.htaccess
Normal file
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
0
public/favicon.ico
Normal file
0
public/favicon.ico
Normal file
20
public/index.php
Normal file
20
public/index.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
2935
resources/css/app.css
Normal file
2935
resources/css/app.css
Normal file
File diff suppressed because it is too large
Load Diff
622
resources/js/app.js
Normal file
622
resources/js/app.js
Normal file
@@ -0,0 +1,622 @@
|
||||
import './bootstrap';
|
||||
|
||||
const SCROLL_KEY = 'pc-preserve-scroll-y';
|
||||
const ALERT_TIMEOUT = 5000;
|
||||
const NAV_HIDE_OFFSET = 96;
|
||||
const NAV_SCROLL_DELTA = 8;
|
||||
const CHAT_OPEN_KEY = 'pc-chat-widget-open';
|
||||
|
||||
const header = document.querySelector('.pc-header');
|
||||
const mobileMenuToggle = document.getElementById('pc-mobile-menu-toggle');
|
||||
let lastScrollY = window.scrollY;
|
||||
let isHeaderTicking = false;
|
||||
|
||||
const updateHeaderNavVisibility = () => {
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentScrollY = window.scrollY;
|
||||
const scrollDelta = currentScrollY - lastScrollY;
|
||||
const isMobileMenuOpen = mobileMenuToggle instanceof HTMLInputElement && mobileMenuToggle.checked;
|
||||
|
||||
if (currentScrollY <= NAV_HIDE_OFFSET || scrollDelta < -NAV_SCROLL_DELTA || isMobileMenuOpen) {
|
||||
header.classList.remove('is-nav-hidden');
|
||||
} else if (scrollDelta > NAV_SCROLL_DELTA) {
|
||||
header.classList.add('is-nav-hidden');
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
};
|
||||
|
||||
const initChatWidget = () => {
|
||||
const widget = document.querySelector('[data-chat-widget="true"]');
|
||||
if (!(widget instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = widget.querySelector('[data-chat-toggle]');
|
||||
const close = widget.querySelector('[data-chat-close]');
|
||||
const panel = widget.querySelector('[data-chat-panel]');
|
||||
const messages = widget.querySelector('[data-chat-messages]');
|
||||
const form = widget.querySelector('[data-chat-form]');
|
||||
const note = widget.querySelector('[data-chat-note]');
|
||||
|
||||
if (
|
||||
!(toggle instanceof HTMLButtonElement) ||
|
||||
!(panel instanceof HTMLElement) ||
|
||||
!(messages instanceof HTMLElement) ||
|
||||
!(form instanceof HTMLFormElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchUrl = widget.dataset.fetchUrl;
|
||||
const sendUrl = widget.dataset.sendUrl;
|
||||
const csrf = widget.dataset.csrf;
|
||||
const textarea = form.querySelector('textarea[name="message"]');
|
||||
const submitButton = form.querySelector('button[type="submit"]');
|
||||
|
||||
if (
|
||||
typeof fetchUrl !== 'string' ||
|
||||
fetchUrl === '' ||
|
||||
typeof sendUrl !== 'string' ||
|
||||
sendUrl === '' ||
|
||||
typeof csrf !== 'string' ||
|
||||
csrf === '' ||
|
||||
!(textarea instanceof HTMLTextAreaElement) ||
|
||||
!(submitButton instanceof HTMLButtonElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let pollTimer = null;
|
||||
let isFetching = false;
|
||||
let conversationClosed = false;
|
||||
const defaultNoteText = note instanceof HTMLElement ? note.textContent ?? '' : '';
|
||||
|
||||
const applyConversationState = (conversation) => {
|
||||
conversationClosed = Boolean(conversation && conversation.is_closed);
|
||||
|
||||
if (note instanceof HTMLElement) {
|
||||
const closedNotice = typeof conversation?.notice === 'string' ? conversation.notice : '';
|
||||
note.textContent = conversationClosed && closedNotice !== '' ? closedNotice : defaultNoteText;
|
||||
}
|
||||
|
||||
submitButton.textContent = conversationClosed ? 'Начать новый чат' : 'Отправить';
|
||||
|
||||
if (conversationClosed) {
|
||||
stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessages = (items) => {
|
||||
if (!Array.isArray(items)) {
|
||||
return;
|
||||
}
|
||||
|
||||
messages.innerHTML = '';
|
||||
items.forEach((message) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = `pc-chat-message ${message.sender === 'admin' ? 'is-admin' : 'is-customer'}`;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'pc-chat-bubble';
|
||||
bubble.textContent = typeof message.body === 'string' ? message.body : '';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'pc-chat-meta';
|
||||
meta.textContent = typeof message.time === 'string' ? message.time : '';
|
||||
|
||||
row.appendChild(bubble);
|
||||
row.appendChild(meta);
|
||||
messages.appendChild(row);
|
||||
});
|
||||
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
};
|
||||
|
||||
const fetchMessages = async () => {
|
||||
if (isFetching) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFetching = true;
|
||||
try {
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
renderMessages(payload.messages ?? []);
|
||||
applyConversationState(payload.conversation ?? null);
|
||||
} catch (error) {
|
||||
// Network errors are expected during reconnects; do nothing.
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (pollTimer !== null || conversationClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = window.setInterval(fetchMessages, 4000);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollTimer === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
};
|
||||
|
||||
const openPanel = async () => {
|
||||
panel.hidden = false;
|
||||
widget.classList.add('is-open');
|
||||
window.localStorage.setItem(CHAT_OPEN_KEY, '1');
|
||||
await fetchMessages();
|
||||
startPolling();
|
||||
window.setTimeout(() => textarea.focus(), 80);
|
||||
};
|
||||
|
||||
const closePanel = () => {
|
||||
panel.hidden = true;
|
||||
widget.classList.remove('is-open');
|
||||
window.localStorage.removeItem(CHAT_OPEN_KEY);
|
||||
stopPolling();
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
if (widget.classList.contains('is-open')) {
|
||||
closePanel();
|
||||
} else {
|
||||
void openPanel();
|
||||
}
|
||||
});
|
||||
|
||||
if (close instanceof HTMLButtonElement) {
|
||||
close.addEventListener('click', closePanel);
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const body = textarea.value.trim();
|
||||
if (body === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
submitButton.disabled = true;
|
||||
textarea.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(sendUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrf,
|
||||
},
|
||||
body: JSON.stringify({ message: body }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.value = '';
|
||||
await fetchMessages();
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
// Skip noisy errors and keep the form usable.
|
||||
} finally {
|
||||
submitButton.disabled = false;
|
||||
textarea.disabled = false;
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
|
||||
if (window.localStorage.getItem(CHAT_OPEN_KEY) === '1') {
|
||||
void openPanel();
|
||||
}
|
||||
};
|
||||
|
||||
const initHomeSliders = () => {
|
||||
const sliders = document.querySelectorAll('[data-home-slider]');
|
||||
if (sliders.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
sliders.forEach((slider) => {
|
||||
const slides = Array.from(slider.querySelectorAll('[data-home-slide]'));
|
||||
const prevButton = slider.querySelector('[data-home-slider-prev]');
|
||||
const nextButton = slider.querySelector('[data-home-slider-next]');
|
||||
|
||||
if (
|
||||
slides.length === 0 ||
|
||||
!(prevButton instanceof HTMLButtonElement) ||
|
||||
!(nextButton instanceof HTMLButtonElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let index = Math.max(
|
||||
0,
|
||||
slides.findIndex((slide) => slide.classList.contains('is-active')),
|
||||
);
|
||||
let timerId = null;
|
||||
|
||||
const setSlide = (newIndex) => {
|
||||
index = (newIndex + slides.length) % slides.length;
|
||||
slides.forEach((slide, slideIndex) => {
|
||||
slide.classList.toggle('is-active', slideIndex === index);
|
||||
});
|
||||
};
|
||||
|
||||
const runNext = () => setSlide(index + 1);
|
||||
const runPrev = () => setSlide(index - 1);
|
||||
|
||||
const startAuto = () => {
|
||||
if (slides.length <= 1 || timerId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
timerId = window.setInterval(runNext, 5000);
|
||||
};
|
||||
|
||||
const stopAuto = () => {
|
||||
if (timerId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearInterval(timerId);
|
||||
timerId = null;
|
||||
};
|
||||
|
||||
prevButton.addEventListener('click', () => {
|
||||
runPrev();
|
||||
stopAuto();
|
||||
startAuto();
|
||||
});
|
||||
|
||||
nextButton.addEventListener('click', () => {
|
||||
runNext();
|
||||
stopAuto();
|
||||
startAuto();
|
||||
});
|
||||
|
||||
slider.addEventListener('mouseenter', stopAuto);
|
||||
slider.addEventListener('mouseleave', startAuto);
|
||||
|
||||
setSlide(index);
|
||||
startAuto();
|
||||
});
|
||||
};
|
||||
|
||||
const initProductCarousels = () => {
|
||||
const carousels = document.querySelectorAll('[data-product-carousel]');
|
||||
if (carousels.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
carousels.forEach((carousel) => {
|
||||
const track = carousel.querySelector('[data-product-carousel-track]');
|
||||
const prevButton = carousel.querySelector('[data-product-carousel-prev]');
|
||||
const nextButton = carousel.querySelector('[data-product-carousel-next]');
|
||||
|
||||
if (
|
||||
!(track instanceof HTMLElement) ||
|
||||
!(prevButton instanceof HTMLButtonElement) ||
|
||||
!(nextButton instanceof HTMLButtonElement)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateButtons = () => {
|
||||
const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth);
|
||||
if (maxScroll <= 1) {
|
||||
prevButton.disabled = true;
|
||||
nextButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
prevButton.disabled = track.scrollLeft <= 2;
|
||||
nextButton.disabled = track.scrollLeft >= maxScroll - 2;
|
||||
};
|
||||
|
||||
const scrollByItem = (direction) => {
|
||||
const firstItem = track.querySelector('.pc-product-carousel-item');
|
||||
const styles = window.getComputedStyle(track);
|
||||
const gapValue = styles.columnGap || styles.gap || '0px';
|
||||
const gap = Number.parseFloat(gapValue) || 0;
|
||||
const itemWidth =
|
||||
firstItem instanceof HTMLElement
|
||||
? firstItem.getBoundingClientRect().width
|
||||
: Math.max(track.clientWidth * 0.9, 220);
|
||||
const scrollAmount = itemWidth + gap;
|
||||
|
||||
track.scrollBy({
|
||||
left: scrollAmount * direction,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
prevButton.addEventListener('click', () => {
|
||||
scrollByItem(-1);
|
||||
});
|
||||
|
||||
nextButton.addEventListener('click', () => {
|
||||
scrollByItem(1);
|
||||
});
|
||||
|
||||
track.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
window.requestAnimationFrame(updateButtons);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
window.addEventListener('resize', updateButtons);
|
||||
updateButtons();
|
||||
});
|
||||
};
|
||||
|
||||
const initProductGallery = () => {
|
||||
const galleries = document.querySelectorAll('[data-product-gallery]');
|
||||
if (galleries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
galleries.forEach((gallery) => {
|
||||
const mainImage = gallery.querySelector('[data-product-gallery-main]');
|
||||
if (!(mainImage instanceof HTMLImageElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const thumbs = Array.from(gallery.querySelectorAll('[data-product-gallery-thumb]')).filter(
|
||||
(thumb) => thumb instanceof HTMLButtonElement,
|
||||
);
|
||||
|
||||
if (thumbs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let activeIndex = 0;
|
||||
let touchStartX = null;
|
||||
let touchStartY = null;
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const setActiveThumb = (activeThumb) => {
|
||||
thumbs.forEach((thumb) => {
|
||||
const isActive = thumb === activeThumb;
|
||||
thumb.classList.toggle('is-active', isActive);
|
||||
thumb.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||
});
|
||||
};
|
||||
|
||||
const animateMainImage = (direction) => {
|
||||
if (prefersReducedMotion || typeof mainImage.animate !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const offset = direction === 0 ? 0 : 28 * direction;
|
||||
mainImage.getAnimations().forEach((animation) => animation.cancel());
|
||||
mainImage.animate([
|
||||
{
|
||||
opacity: 0.38,
|
||||
transform: `translate3d(${offset}px, 0, 0) scale(1.02)`,
|
||||
filter: 'blur(10px)',
|
||||
},
|
||||
{
|
||||
opacity: 1,
|
||||
transform: 'translate3d(0, 0, 0) scale(1)',
|
||||
filter: 'blur(0)',
|
||||
},
|
||||
], {
|
||||
duration: 320,
|
||||
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
});
|
||||
};
|
||||
|
||||
const applyImage = (index) => {
|
||||
const normalizedIndex = ((index % thumbs.length) + thumbs.length) % thumbs.length;
|
||||
const thumb = thumbs[normalizedIndex];
|
||||
if (!(thumb instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const src = (thumb.dataset.imageSrc || '').trim();
|
||||
if (src === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
const direction = normalizedIndex === activeIndex
|
||||
? 0
|
||||
: normalizedIndex > activeIndex
|
||||
? 1
|
||||
: -1;
|
||||
|
||||
activeIndex = normalizedIndex;
|
||||
|
||||
if (mainImage.getAttribute('src') !== src) {
|
||||
mainImage.setAttribute('src', src);
|
||||
}
|
||||
|
||||
const alt = (thumb.dataset.imageAlt || '').trim();
|
||||
if (alt !== '') {
|
||||
mainImage.setAttribute('alt', alt);
|
||||
}
|
||||
|
||||
animateMainImage(direction);
|
||||
setActiveThumb(thumb);
|
||||
thumb.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
inline: 'center',
|
||||
block: 'nearest',
|
||||
});
|
||||
};
|
||||
|
||||
thumbs.forEach((thumb, index) => {
|
||||
thumb.addEventListener('click', () => {
|
||||
applyImage(index);
|
||||
});
|
||||
});
|
||||
|
||||
mainImage.addEventListener('touchstart', (event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
touchStartX = touch.clientX;
|
||||
touchStartY = touch.clientY;
|
||||
}, { passive: true });
|
||||
|
||||
mainImage.addEventListener('touchend', (event) => {
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch || touchStartX === null || touchStartY === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.clientX - touchStartX;
|
||||
const deltaY = touch.clientY - touchStartY;
|
||||
|
||||
touchStartX = null;
|
||||
touchStartY = null;
|
||||
|
||||
if (Math.abs(deltaX) < 36 || Math.abs(deltaX) <= Math.abs(deltaY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deltaX < 0) {
|
||||
applyImage(activeIndex + 1);
|
||||
} else {
|
||||
applyImage(activeIndex - 1);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
const initialThumb = thumbs.find((thumb) => thumb.classList.contains('is-active')) ?? thumbs[0];
|
||||
if (initialThumb) {
|
||||
applyImage(thumbs.indexOf(initialThumb));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initCategoryFilterToggle = () => {
|
||||
const toggles = document.querySelectorAll('[data-filter-toggle]');
|
||||
if (toggles.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
toggles.forEach((toggle) => {
|
||||
if (!(toggle instanceof HTMLButtonElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = toggle.getAttribute('aria-controls');
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = document.getElementById(targetId);
|
||||
if (!(container instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncState = () => {
|
||||
const isOpen = container instanceof HTMLDetailsElement
|
||||
? container.open
|
||||
: container.classList.contains('is-open');
|
||||
|
||||
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
};
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
if (container instanceof HTMLDetailsElement) {
|
||||
container.open = !container.open;
|
||||
} else {
|
||||
container.classList.toggle('is-open');
|
||||
}
|
||||
|
||||
syncState();
|
||||
});
|
||||
|
||||
if (container instanceof HTMLDetailsElement) {
|
||||
container.addEventListener('toggle', syncState);
|
||||
}
|
||||
|
||||
syncState();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener('submit', (event) => {
|
||||
const form = event.target;
|
||||
if (!(form instanceof HTMLFormElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset.preserveScroll === 'true') {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY));
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
if (!header || isHeaderTicking) {
|
||||
return;
|
||||
}
|
||||
|
||||
isHeaderTicking = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
updateHeaderNavVisibility();
|
||||
isHeaderTicking = false;
|
||||
});
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
|
||||
if (mobileMenuToggle instanceof HTMLInputElement) {
|
||||
mobileMenuToggle.addEventListener('change', () => {
|
||||
if (mobileMenuToggle.checked) {
|
||||
header?.classList.remove('is-nav-hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('pageshow', () => {
|
||||
const savedScrollY = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (savedScrollY !== null) {
|
||||
sessionStorage.removeItem(SCROLL_KEY);
|
||||
window.requestAnimationFrame(() => {
|
||||
window.scrollTo(0, Number(savedScrollY) || 0);
|
||||
updateHeaderNavVisibility();
|
||||
});
|
||||
} else {
|
||||
updateHeaderNavVisibility();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.pc-alert').forEach((alert) => {
|
||||
window.setTimeout(() => {
|
||||
alert.classList.add('is-hiding');
|
||||
window.setTimeout(() => alert.remove(), 260);
|
||||
}, ALERT_TIMEOUT);
|
||||
});
|
||||
});
|
||||
|
||||
initChatWidget();
|
||||
initHomeSliders();
|
||||
initProductCarousels();
|
||||
initProductGallery();
|
||||
initCategoryFilterToggle();
|
||||
4
resources/js/bootstrap.js
vendored
Normal file
4
resources/js/bootstrap.js
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
31
resources/views/admin/auth/login.blade.php
Normal file
31
resources/views/admin/auth/login.blade.php
Normal file
@@ -0,0 +1,31 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Вход в админку</h2>
|
||||
<p>Доступ только для администраторов.</p>
|
||||
</div>
|
||||
|
||||
<form class="pc-card pc-form pc-auth-form" method="post" action="{{ route('admin.login.attempt') }}">
|
||||
@csrf
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" value="{{ old('email') }}" required>
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<input type="password" name="password" required>
|
||||
</label>
|
||||
<label>
|
||||
Капча: решите пример {{ $captchaQuestion }}
|
||||
<input type="text" name="captcha" inputmode="numeric" autocomplete="off" required>
|
||||
</label>
|
||||
<label class="pc-checkbox">
|
||||
<input type="checkbox" name="remember" value="1" @checked(old('remember'))>
|
||||
<span>Запомнить меня</span>
|
||||
</label>
|
||||
<button class="pc-btn primary" type="submit">Войти</button>
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
21
resources/views/admin/categories/_form.blade.php
Normal file
21
resources/views/admin/categories/_form.blade.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<label>
|
||||
Название
|
||||
<input type="text" name="name" value="{{ old('name', $category->name ?? '') }}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Slug
|
||||
<input type="text" name="slug" value="{{ old('slug', $category->slug ?? '') }}" placeholder="Можно оставить пустым">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Описание
|
||||
<textarea name="description">{{ old('description', $category->description ?? '') }}</textarea>
|
||||
</label>
|
||||
|
||||
<label class="pc-checkbox">
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $category->is_active ?? true))>
|
||||
<span>Категория активна</span>
|
||||
</label>
|
||||
|
||||
<button class="pc-btn primary" type="submit">{{ $submitLabel ?? 'Сохранить' }}</button>
|
||||
22
resources/views/admin/categories/create.blade.php
Normal file
22
resources/views/admin/categories/create.blade.php
Normal file
@@ -0,0 +1,22 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Категории', 'url' => route('admin.categories.index')],
|
||||
['label' => 'Новая категория', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Новая категория</h2>
|
||||
</div>
|
||||
|
||||
<form class="pc-card pc-form" method="post" action="{{ route('admin.categories.store') }}">
|
||||
@csrf
|
||||
@include('admin.categories._form', ['submitLabel' => 'Создать'])
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
23
resources/views/admin/categories/edit.blade.php
Normal file
23
resources/views/admin/categories/edit.blade.php
Normal file
@@ -0,0 +1,23 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Категории', 'url' => route('admin.categories.index')],
|
||||
['label' => 'Редактирование', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Редактирование категории</h2>
|
||||
</div>
|
||||
|
||||
<form class="pc-card pc-form" method="post" action="{{ route('admin.categories.update', $category) }}">
|
||||
@csrf
|
||||
@method('put')
|
||||
@include('admin.categories._form', ['category' => $category, 'submitLabel' => 'Сохранить'])
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
45
resources/views/admin/categories/index.blade.php
Normal file
45
resources/views/admin/categories/index.blade.php
Normal file
@@ -0,0 +1,45 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Категории', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-category-toolbar">
|
||||
<div class="pc-section-title">
|
||||
<h2>Категории</h2>
|
||||
</div>
|
||||
<a class="pc-btn primary" href="{{ route('admin.categories.create') }}">Добавить категорию</a>
|
||||
</div>
|
||||
|
||||
<div class="pc-card">
|
||||
@if ($categories->isEmpty())
|
||||
<p>Категории пока не созданы.</p>
|
||||
@else
|
||||
<div class="pc-account-orders">
|
||||
@foreach ($categories as $category)
|
||||
<div class="pc-account-order">
|
||||
<span>{{ $category->name }} ({{ $category->slug }})</span>
|
||||
<div class="pc-product-actions">
|
||||
<a class="pc-btn ghost" href="{{ route('admin.categories.edit', $category) }}">Редактировать</a>
|
||||
<form method="post" action="{{ route('admin.categories.destroy', $category) }}">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<button class="pc-btn ghost" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="pc-pagination">
|
||||
{{ $categories->links() }}
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
205
resources/views/admin/chats/index.blade.php
Normal file
205
resources/views/admin/chats/index.blade.php
Normal file
@@ -0,0 +1,205 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('meta_title', 'Чаты с клиентами')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Чаты', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Чаты с клиентами</h2>
|
||||
<p>Отвечайте клиентам прямо из админки.</p>
|
||||
</div>
|
||||
|
||||
@if ($conversations->isEmpty())
|
||||
<div class="pc-card">
|
||||
<p>Пока нет сообщений от клиентов.</p>
|
||||
</div>
|
||||
@else
|
||||
<div class="pc-admin-chat-layout">
|
||||
<aside class="pc-card pc-admin-chat-list">
|
||||
@foreach ($conversations as $conversation)
|
||||
@php
|
||||
$isActive = $selectedConversation && $selectedConversation->id === $conversation->id;
|
||||
$subtitle = $conversation->user?->email ?: ('Токен: ' . \Illuminate\Support\Str::limit($conversation->visitor_token, 14, '...'));
|
||||
$preview = $conversation->latestMessage?->body ? \Illuminate\Support\Str::limit($conversation->latestMessage->body, 80) : 'Нет сообщений';
|
||||
@endphp
|
||||
<a class="pc-admin-chat-item {{ $isActive ? 'is-active' : '' }}" href="{{ route('admin.chats.index', ['conversation' => $conversation->id]) }}">
|
||||
<div class="pc-admin-chat-item-top">
|
||||
<strong>{{ $conversation->display_name }}</strong>
|
||||
@if ($conversation->unread_count > 0)
|
||||
<span class="pc-admin-chat-badge">{{ $conversation->unread_count }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<span class="pc-muted">{{ $subtitle }}</span>
|
||||
@if ($conversation->status === \App\Models\ChatConversation::STATUS_CLOSED)
|
||||
<span class="pc-muted">Чат закрыт</span>
|
||||
@endif
|
||||
<span class="pc-muted">{{ $preview }}</span>
|
||||
</a>
|
||||
@endforeach
|
||||
</aside>
|
||||
|
||||
<div class="pc-card pc-admin-chat-panel">
|
||||
@if ($selectedConversation)
|
||||
@php
|
||||
$isClosed = $selectedConversation->status === \App\Models\ChatConversation::STATUS_CLOSED;
|
||||
@endphp
|
||||
<div
|
||||
id="pc-admin-chat"
|
||||
data-fetch-url="{{ route('admin.chats.messages', $selectedConversation) }}"
|
||||
data-send-url="{{ route('admin.chats.messages.store', $selectedConversation) }}"
|
||||
data-csrf="{{ csrf_token() }}"
|
||||
>
|
||||
<div class="pc-admin-chat-head">
|
||||
<div>
|
||||
<h3>{{ $selectedConversation->display_name }}</h3>
|
||||
<p class="pc-muted">{{ $selectedConversation->user?->email ?: 'Гость' }}</p>
|
||||
<p class="pc-muted">{{ $isClosed ? 'Чат закрыт' : 'Чат открыт' }}</p>
|
||||
</div>
|
||||
<div class="pc-admin-chat-actions">
|
||||
<form method="post" action="{{ route('admin.chats.status', $selectedConversation) }}">
|
||||
@csrf
|
||||
@method('patch')
|
||||
<input type="hidden" name="status" value="{{ $isClosed ? \App\Models\ChatConversation::STATUS_OPEN : \App\Models\ChatConversation::STATUS_CLOSED }}">
|
||||
<button class="pc-btn ghost" type="submit">{{ $isClosed ? 'Открыть чат' : 'Закрыть чат' }}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ route('admin.chats.destroy', $selectedConversation) }}" onsubmit="return confirm('Удалить чат и все сообщения?')">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<button class="pc-btn ghost" type="submit">Удалить чат</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-admin-chat-messages" id="pc-admin-chat-messages"></div>
|
||||
|
||||
<form class="pc-admin-chat-form" id="pc-admin-chat-form">
|
||||
<textarea name="message" rows="3" maxlength="2000" placeholder="{{ $isClosed ? 'Чат закрыт. Сначала откройте его.' : 'Введите ответ клиенту...' }}" required @disabled($isClosed)></textarea>
|
||||
<button class="pc-btn primary" type="submit" @disabled($isClosed)>{{ $isClosed ? 'Чат закрыт' : 'Отправить' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@else
|
||||
<p>Выберите чат слева.</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-pagination">
|
||||
{{ $conversations->links() }}
|
||||
</div>
|
||||
@endif
|
||||
</section>
|
||||
@endsection
|
||||
|
||||
@if ($selectedConversation)
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.getElementById('pc-admin-chat');
|
||||
const list = document.getElementById('pc-admin-chat-messages');
|
||||
const form = document.getElementById('pc-admin-chat-form');
|
||||
if (!root || !list || !form) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchUrl = root.dataset.fetchUrl;
|
||||
const sendUrl = root.dataset.sendUrl;
|
||||
const csrf = root.dataset.csrf;
|
||||
const initialMessages = @json($initialMessages);
|
||||
|
||||
const renderMessages = (messages) => {
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = '';
|
||||
messages.forEach((message) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `pc-admin-chat-message ${message.sender === 'admin' ? 'is-admin' : 'is-customer'}`;
|
||||
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'pc-admin-chat-bubble';
|
||||
bubble.textContent = message.body || '';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'pc-muted';
|
||||
meta.textContent = message.time || '';
|
||||
|
||||
item.appendChild(bubble);
|
||||
item.appendChild(meta);
|
||||
list.appendChild(item);
|
||||
});
|
||||
|
||||
list.scrollTop = list.scrollHeight;
|
||||
};
|
||||
|
||||
const fetchMessages = async () => {
|
||||
try {
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
renderMessages(payload.messages || []);
|
||||
} catch (error) {
|
||||
// Ignore temporary connection issues and continue polling.
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const textarea = form.querySelector('textarea[name="message"]');
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
if (!textarea || !button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = textarea.value.trim();
|
||||
if (message === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch(sendUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrf,
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.value = '';
|
||||
await fetchMessages();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
renderMessages(initialMessages);
|
||||
fetchMessages();
|
||||
window.setInterval(fetchMessages, 4000);
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@endif
|
||||
63
resources/views/admin/dashboard.blade.php
Normal file
63
resources/views/admin/dashboard.blade.php
Normal file
@@ -0,0 +1,63 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Панель администратора</h2>
|
||||
<p>Управление товарами, категориями, заказами, чатами и слайдерами.</p>
|
||||
</div>
|
||||
|
||||
<div class="pc-grid pc-grid-4">
|
||||
<div class="pc-card">
|
||||
<h3>Категории</h3>
|
||||
<p>{{ $stats['categories'] }}</p>
|
||||
</div>
|
||||
<div class="pc-card">
|
||||
<h3>Товары</h3>
|
||||
<p>{{ $stats['products'] }}</p>
|
||||
</div>
|
||||
<div class="pc-card">
|
||||
<h3>Заказы</h3>
|
||||
<p>{{ $stats['orders'] }}</p>
|
||||
</div>
|
||||
<div class="pc-card">
|
||||
<h3>Выручка</h3>
|
||||
<p>{{ number_format($stats['revenue'], 0, '.', ' ') }} {{ config('shop.currency_symbol', '₽') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-product-actions">
|
||||
<a class="pc-btn primary" href="{{ route('admin.products.index') }}">Товары</a>
|
||||
<a class="pc-btn ghost" href="{{ route('admin.categories.index') }}">Категории</a>
|
||||
<a class="pc-btn ghost" href="{{ route('admin.orders.index') }}">Заказы</a>
|
||||
<a class="pc-btn ghost" href="{{ route('admin.home-slides.index') }}">Слайдеры</a>
|
||||
<a class="pc-btn ghost" href="{{ route('admin.chats.index') }}">Чаты</a>
|
||||
<form method="post" action="{{ route('admin.logout') }}">
|
||||
@csrf
|
||||
<button class="pc-btn ghost" type="submit">Выйти</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="pc-card">
|
||||
<h3>Последние заказы</h3>
|
||||
@if ($recentOrders->isEmpty())
|
||||
<p>Пока нет заказов.</p>
|
||||
@else
|
||||
<div class="pc-account-orders">
|
||||
@foreach ($recentOrders as $order)
|
||||
<a class="pc-account-order" href="{{ route('admin.orders.show', $order) }}">
|
||||
<span>Заказ #{{ $order->id }} ({{ $order->status }})</span>
|
||||
<strong>{{ number_format($order->total, 0, '.', ' ') }} {{ config('shop.currency_symbol', '₽') }}</strong>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
71
resources/views/admin/home-slides/_form.blade.php
Normal file
71
resources/views/admin/home-slides/_form.blade.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<label>
|
||||
Блок на главной
|
||||
<select name="zone" required>
|
||||
@foreach ($zoneLabels as $zoneKey => $zoneLabel)
|
||||
<option value="{{ $zoneKey }}" @selected(old('zone', $slide->zone ?? 'left') === $zoneKey)>{{ $zoneLabel }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Заголовок
|
||||
<input type="text" name="title" value="{{ old('title', $slide->title ?? '') }}" maxlength="160" placeholder="Например: Скидки на видеокарты">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Подзаголовок
|
||||
<textarea name="subtitle" rows="3" maxlength="1500" placeholder="Короткий текст на баннере">{{ old('subtitle', $slide->subtitle ?? '') }}</textarea>
|
||||
</label>
|
||||
|
||||
<div class="pc-grid pc-grid-2">
|
||||
<label>
|
||||
Текст кнопки
|
||||
<input type="text" name="button_text" value="{{ old('button_text', $slide->button_text ?? '') }}" maxlength="60" placeholder="Подробнее">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Ссылка кнопки
|
||||
<input type="text" name="button_url" value="{{ old('button_url', $slide->button_url ?? '') }}" maxlength="255" placeholder="/catalog или https://...">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="pc-grid pc-grid-3">
|
||||
<label class="pc-checkbox">
|
||||
<input type="checkbox" name="show_title" value="1" @checked(old('show_title', $slide->show_title ?? true))>
|
||||
<span>Показывать заголовок</span>
|
||||
</label>
|
||||
<label class="pc-checkbox">
|
||||
<input type="checkbox" name="show_subtitle" value="1" @checked(old('show_subtitle', $slide->show_subtitle ?? true))>
|
||||
<span>Показывать описание</span>
|
||||
</label>
|
||||
<label class="pc-checkbox">
|
||||
<input type="checkbox" name="show_button" value="1" @checked(old('show_button', $slide->show_button ?? true))>
|
||||
<span>Показывать кнопку</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="pc-grid pc-grid-2">
|
||||
<label>
|
||||
Порядок
|
||||
<input type="number" min="0" max="9999" name="sort_order" value="{{ old('sort_order', $slide->sort_order ?? 100) }}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Изображение (jpg, png, webp)
|
||||
<input class="pc-file-input" type="file" name="image" accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp" @required(empty($slide?->id))>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (!empty($slide?->image_url))
|
||||
<div class="pc-admin-slide-current">
|
||||
<div class="pc-admin-slide-current-preview" style="background-image: url('{{ $slide->image_url }}')"></div>
|
||||
<span class="pc-muted">Текущее изображение. Загрузите новое, если хотите заменить.</span>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<label class="pc-checkbox">
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $slide->is_active ?? true))>
|
||||
<span>Слайд активен</span>
|
||||
</label>
|
||||
|
||||
<button class="pc-btn primary" type="submit">{{ $submitLabel ?? 'Сохранить' }}</button>
|
||||
22
resources/views/admin/home-slides/create.blade.php
Normal file
22
resources/views/admin/home-slides/create.blade.php
Normal file
@@ -0,0 +1,22 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Слайдеры главной', 'url' => route('admin.home-slides.index')],
|
||||
['label' => 'Новый слайд', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Новый слайд</h2>
|
||||
</div>
|
||||
|
||||
<form class="pc-card pc-form" method="post" action="{{ route('admin.home-slides.store') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@include('admin.home-slides._form', ['submitLabel' => 'Создать'])
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
23
resources/views/admin/home-slides/edit.blade.php
Normal file
23
resources/views/admin/home-slides/edit.blade.php
Normal file
@@ -0,0 +1,23 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Слайдеры главной', 'url' => route('admin.home-slides.index')],
|
||||
['label' => 'Редактирование', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Редактирование слайда</h2>
|
||||
</div>
|
||||
|
||||
<form class="pc-card pc-form" method="post" action="{{ route('admin.home-slides.update', $slide) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('put')
|
||||
@include('admin.home-slides._form', ['slide' => $slide, 'submitLabel' => 'Сохранить'])
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
57
resources/views/admin/home-slides/index.blade.php
Normal file
57
resources/views/admin/home-slides/index.blade.php
Normal file
@@ -0,0 +1,57 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Слайдеры главной', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-category-toolbar">
|
||||
<div class="pc-section-title">
|
||||
<h2>Слайдеры главной страницы</h2>
|
||||
<p>Загружайте баннеры для блока 2/3 и 1/3 на первом экране.</p>
|
||||
</div>
|
||||
<div class="pc-product-actions">
|
||||
<a class="pc-btn primary" href="{{ route('admin.home-slides.create', ['zone' => 'left']) }}">Добавить в 2/3</a>
|
||||
<a class="pc-btn ghost" href="{{ route('admin.home-slides.create', ['zone' => 'right']) }}">Добавить в 1/3</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-grid pc-grid-2">
|
||||
@foreach ($zoneLabels as $zone => $zoneLabel)
|
||||
@php
|
||||
$zoneSlides = $slides->get($zone, collect());
|
||||
@endphp
|
||||
<div class="pc-card">
|
||||
<h3>{{ $zoneLabel }}</h3>
|
||||
@if ($zoneSlides->isEmpty())
|
||||
<p>Слайды пока не добавлены.</p>
|
||||
@else
|
||||
<div class="pc-account-orders">
|
||||
@foreach ($zoneSlides as $slide)
|
||||
<div class="pc-admin-slide-item">
|
||||
<div class="pc-admin-slide-preview" style="background-image: url('{{ $slide->image_url }}')"></div>
|
||||
<div class="pc-admin-slide-main">
|
||||
<strong>{{ $slide->title ?: 'Без заголовка' }}</strong>
|
||||
<span class="pc-muted">Порядок: {{ $slide->sort_order }} · {{ $slide->is_active ? 'Активен' : 'Скрыт' }}</span>
|
||||
</div>
|
||||
<div class="pc-product-actions">
|
||||
<a class="pc-btn ghost" href="{{ route('admin.home-slides.edit', $slide) }}">Редактировать</a>
|
||||
<form method="post" action="{{ route('admin.home-slides.destroy', $slide) }}">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<button class="pc-btn ghost" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
35
resources/views/admin/orders/index.blade.php
Normal file
35
resources/views/admin/orders/index.blade.php
Normal file
@@ -0,0 +1,35 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Заказы', 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Заказы</h2>
|
||||
</div>
|
||||
|
||||
<div class="pc-card">
|
||||
@if ($orders->isEmpty())
|
||||
<p>Заказов пока нет.</p>
|
||||
@else
|
||||
<div class="pc-account-orders">
|
||||
@foreach ($orders as $order)
|
||||
<a class="pc-account-order" href="{{ route('admin.orders.show', $order) }}">
|
||||
<span>Заказ #{{ $order->id }} ({{ $order->status }})</span>
|
||||
<strong>{{ number_format($order->total, 0, '.', ' ') }} {{ config('shop.currency_symbol', '₽') }}</strong>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="pc-pagination">
|
||||
{{ $orders->links() }}
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user