This commit is contained in:
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
|
||||
68
resources/views/admin/orders/show.blade.php
Normal file
68
resources/views/admin/orders/show.blade.php
Normal file
@@ -0,0 +1,68 @@
|
||||
@extends('layouts.shop')
|
||||
|
||||
@section('content')
|
||||
@include('partials.breadcrumbs', [
|
||||
'items' => [
|
||||
['label' => 'Админка', 'url' => route('admin.dashboard')],
|
||||
['label' => 'Заказы', 'url' => route('admin.orders.index')],
|
||||
['label' => 'Заказ #' . $order->id, 'url' => null],
|
||||
],
|
||||
])
|
||||
|
||||
<section class="pc-section">
|
||||
<div class="pc-section-title">
|
||||
<h2>Заказ #{{ $order->id }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="pc-grid pc-grid-2">
|
||||
<div class="pc-card">
|
||||
<h3>Статус</h3>
|
||||
<form class="pc-form" method="post" action="{{ route('admin.orders.update', $order) }}">
|
||||
@csrf
|
||||
@method('put')
|
||||
<label>
|
||||
Текущий статус
|
||||
<select name="status">
|
||||
@foreach (['new' => 'Новый', 'processing' => 'В обработке', 'paid' => 'Оплачен', 'shipped' => 'Отправлен', 'completed' => 'Завершен', 'cancelled' => 'Отменен'] as $value => $label)
|
||||
<option value="{{ $value }}" @selected($order->status === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
<button class="pc-btn primary" type="submit">Обновить статус</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="pc-card">
|
||||
<h3>Покупатель</h3>
|
||||
<p><strong>{{ $order->customer_name }}</strong></p>
|
||||
<p>{{ $order->email }}</p>
|
||||
<p>Оплата: {{ $order->payment_method_label }}</p>
|
||||
@if ($order->phone)
|
||||
<p>{{ $order->phone }}</p>
|
||||
@endif
|
||||
@if ($order->address)
|
||||
<p>{{ $order->address }}</p>
|
||||
@endif
|
||||
@if ($order->comment)
|
||||
<p>Комментарий: {{ $order->comment }}</p>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-card">
|
||||
<h3>Состав заказа</h3>
|
||||
<div class="pc-account-orders">
|
||||
@foreach ($order->items as $item)
|
||||
<div class="pc-account-order">
|
||||
<span>{{ $item->name }} × {{ $item->quantity }}</span>
|
||||
<strong>{{ number_format($item->subtotal, 0, '.', ' ') }} {{ config('shop.currency_symbol', '₽') }}</strong>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="pc-cart-summary-row">
|
||||
<span>Итого</span>
|
||||
<strong>{{ number_format($order->total, 0, '.', ' ') }} {{ config('shop.currency_symbol', '₽') }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
219
resources/views/admin/products/_form.blade.php
Normal file
219
resources/views/admin/products/_form.blade.php
Normal file
@@ -0,0 +1,219 @@
|
||||
@php
|
||||
$currentProduct = $product ?? null;
|
||||
$selectedCategoryId = (int) old('category_id', $currentProduct->category_id ?? 0);
|
||||
$specValues = old('specs', $currentProduct->specs ?? []);
|
||||
$selectedGalleryPathsForRemoval = collect(old('remove_gallery_paths', []))
|
||||
->filter(fn ($path) => is_string($path) && trim($path) !== '')
|
||||
->map(fn (string $path) => trim($path))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
$galleryItems = collect((array) ($currentProduct->gallery_paths ?? []))
|
||||
->filter(fn ($path) => is_string($path) && trim($path) !== '')
|
||||
->map(fn (string $path) => trim($path))
|
||||
->unique()
|
||||
->filter(fn (string $path) => $path !== ($currentProduct->image_path ?? null))
|
||||
->map(function (string $path) {
|
||||
$url = $path;
|
||||
|
||||
if (!\Illuminate\Support\Str::startsWith($path, ['http://', 'https://', '/'])) {
|
||||
$url = \Illuminate\Support\Str::startsWith($path, 'uploads/')
|
||||
? asset($path)
|
||||
: '/storage/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'url' => $url,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
@endphp
|
||||
|
||||
<label>
|
||||
Категория
|
||||
<select id="product-category-select" name="category_id" required>
|
||||
<option value="">Выберите категорию</option>
|
||||
@foreach ($categories as $categoryOption)
|
||||
<option value="{{ $categoryOption->id }}" @selected((int) old('category_id', $currentProduct->category_id ?? 0) === $categoryOption->id)>
|
||||
{{ $categoryOption->name }}
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Название
|
||||
<input type="text" name="name" value="{{ old('name', $currentProduct->name ?? '') }}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Slug
|
||||
<input type="text" name="slug" value="{{ old('slug', $currentProduct->slug ?? '') }}" placeholder="Можно оставить пустым">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Артикул
|
||||
<input type="text" name="sku" value="{{ old('sku', $currentProduct->sku ?? '') }}">
|
||||
</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">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Дополнительные изображения (до 12 файлов)
|
||||
<input
|
||||
class="pc-file-input"
|
||||
type="file"
|
||||
name="gallery_images[]"
|
||||
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="pc-grid pc-grid-2">
|
||||
<label>
|
||||
Цена
|
||||
<input type="number" step="0.01" min="0" name="price" value="{{ old('price', $currentProduct->price ?? '') }}" required>
|
||||
</label>
|
||||
<label>
|
||||
Старая цена
|
||||
<input type="number" step="0.01" min="0" name="old_price" value="{{ old('old_price', $currentProduct->old_price ?? '') }}">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Остаток
|
||||
<input type="number" min="0" name="stock" value="{{ old('stock', $currentProduct->stock ?? 0) }}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Короткое описание
|
||||
<input type="text" name="short_description" value="{{ old('short_description', $currentProduct->short_description ?? '') }}">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Описание
|
||||
<textarea name="description">{{ old('description', $currentProduct->description ?? '') }}</textarea>
|
||||
</label>
|
||||
|
||||
@if (!empty($currentProduct?->image_url))
|
||||
<div class="pc-admin-product-image-current">
|
||||
<img class="pc-admin-product-image-preview" src="{{ $currentProduct->image_url }}" alt="{{ $currentProduct->name }}" loading="lazy">
|
||||
<span class="pc-muted">Текущее изображение. Загрузите новое, чтобы заменить.</span>
|
||||
<label class="pc-checkbox">
|
||||
<input type="checkbox" name="remove_image" value="1" @checked(old('remove_image'))>
|
||||
<span>Удалить текущее изображение</span>
|
||||
</label>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($galleryItems !== [])
|
||||
<div class="pc-admin-product-gallery-current">
|
||||
<span class="pc-muted">Отметьте изображения, которые нужно удалить. При новой загрузке текущая галерея будет заменена.</span>
|
||||
<div class="pc-admin-product-gallery-grid">
|
||||
@foreach ($galleryItems as $galleryItem)
|
||||
<label class="pc-admin-product-gallery-item">
|
||||
<img class="pc-admin-product-gallery-preview" src="{{ $galleryItem['url'] }}" alt="Дополнительное изображение товара" loading="lazy">
|
||||
<span class="pc-checkbox">
|
||||
<input type="checkbox" name="remove_gallery_paths[]" value="{{ $galleryItem['path'] }}" @checked(in_array($galleryItem['path'], $selectedGalleryPathsForRemoval, true))>
|
||||
<span>Удалить</span>
|
||||
</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="pc-spec-fields-wrap">
|
||||
<h3 class="pc-spec-fields-title">Характеристики категории</h3>
|
||||
<div id="pc-spec-fields" class="pc-grid pc-grid-2"></div>
|
||||
</div>
|
||||
|
||||
<label class="pc-checkbox">
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $currentProduct->is_active ?? true))>
|
||||
<span>Товар активен</span>
|
||||
</label>
|
||||
|
||||
<button class="pc-btn primary" type="submit">{{ $submitLabel ?? 'Сохранить' }}</button>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const categorySelect = document.getElementById('product-category-select');
|
||||
const specsContainer = document.getElementById('pc-spec-fields');
|
||||
if (!categorySelect || !specsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const specsByCategory = @json($categorySpecsById ?? []);
|
||||
const specValues = @json($specValues);
|
||||
|
||||
const renderSpecFields = () => {
|
||||
const selectedCategoryId = categorySelect.value;
|
||||
const fields = specsByCategory[selectedCategoryId] || [];
|
||||
specsContainer.innerHTML = '';
|
||||
|
||||
if (fields.length === 0) {
|
||||
const muted = document.createElement('p');
|
||||
muted.className = 'pc-muted';
|
||||
muted.textContent = 'Для выбранной категории нет дополнительных полей.';
|
||||
specsContainer.appendChild(muted);
|
||||
return;
|
||||
}
|
||||
|
||||
fields.forEach((field) => {
|
||||
const label = document.createElement('label');
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.textContent = field.label;
|
||||
|
||||
label.appendChild(title);
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = `specs[${field.key}]`;
|
||||
input.value = typeof specValues[field.key] === 'string' ? specValues[field.key] : '';
|
||||
|
||||
const options = Array.isArray(field.options)
|
||||
? [...new Set(field.options.map((value) => String(value).trim()).filter(Boolean))]
|
||||
: [];
|
||||
|
||||
if (options.length > 0) {
|
||||
const listId = `pc-spec-options-${field.key}`;
|
||||
input.setAttribute('list', listId);
|
||||
input.setAttribute('autocomplete', 'off');
|
||||
|
||||
const datalist = document.createElement('datalist');
|
||||
datalist.id = listId;
|
||||
|
||||
options.forEach((optionValue) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = optionValue;
|
||||
datalist.appendChild(option);
|
||||
});
|
||||
|
||||
const hint = document.createElement('small');
|
||||
hint.className = 'pc-field-hint';
|
||||
hint.textContent = 'Выберите из списка или введите свое значение';
|
||||
|
||||
label.appendChild(input);
|
||||
label.appendChild(datalist);
|
||||
label.appendChild(hint);
|
||||
} else {
|
||||
label.appendChild(input);
|
||||
}
|
||||
|
||||
specsContainer.appendChild(label);
|
||||
});
|
||||
};
|
||||
|
||||
categorySelect.addEventListener('change', renderSpecFields);
|
||||
if (categorySelect.value === '' && '{{ $selectedCategoryId }}' !== '0') {
|
||||
categorySelect.value = '{{ $selectedCategoryId }}';
|
||||
}
|
||||
renderSpecFields();
|
||||
})();
|
||||
</script>
|
||||
22
resources/views/admin/products/create.blade.php
Normal file
22
resources/views/admin/products/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.products.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.products.store') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@include('admin.products._form', ['submitLabel' => 'Создать'])
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
23
resources/views/admin/products/edit.blade.php
Normal file
23
resources/views/admin/products/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.products.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.products.update', $product) }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
@method('put')
|
||||
@include('admin.products._form', ['product' => $product, 'submitLabel' => 'Сохранить'])
|
||||
</form>
|
||||
</section>
|
||||
@endsection
|
||||
58
resources/views/admin/products/index.blade.php
Normal file
58
resources/views/admin/products/index.blade.php
Normal file
@@ -0,0 +1,58 @@
|
||||
@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>
|
||||
<div class="pc-product-actions">
|
||||
<a class="pc-btn primary" href="{{ route('admin.products.create') }}">Добавить товар</a>
|
||||
<a class="pc-btn ghost" href="{{ route('admin.products.export') }}">Экспорт CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pc-card">
|
||||
<h3>Импорт из CSV</h3>
|
||||
<p class="pc-muted">Загрузите CSV файл (UTF-8). Поддерживаются колонки category_slug/category_name, name, price, stock и дополнительные характеристики.</p>
|
||||
<form class="pc-product-actions" method="post" action="{{ route('admin.products.import') }}" enctype="multipart/form-data">
|
||||
@csrf
|
||||
<input class="pc-file-input" type="file" name="csv_file" accept=".csv,text/csv" required>
|
||||
<button class="pc-btn ghost" type="submit">Импорт CSV</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="pc-card">
|
||||
@if ($products->isEmpty())
|
||||
<p>Товары пока не созданы.</p>
|
||||
@else
|
||||
<div class="pc-account-orders">
|
||||
@foreach ($products as $product)
|
||||
<div class="pc-account-order">
|
||||
<span>{{ $product->name }} ({{ $product->category?->name ?? 'Без категории' }})</span>
|
||||
<div class="pc-product-actions">
|
||||
<a class="pc-btn ghost" href="{{ route('admin.products.edit', $product) }}">Редактировать</a>
|
||||
<form method="post" action="{{ route('admin.products.destroy', $product) }}">
|
||||
@csrf
|
||||
@method('delete')
|
||||
<button class="pc-btn ghost" type="submit">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="pc-pagination">
|
||||
{{ $products->links() }}
|
||||
</div>
|
||||
</section>
|
||||
@endsection
|
||||
Reference in New Issue
Block a user