feat(faq): video tutorial + full-width layout + wider grid
- New tenant setting: settings.faq_video_url. TextInput on Settings page → Liste configurabile (accepts YouTube watch URL, youtu.be short URL, YouTube shorts/embed URL, or Vimeo URL). - Faq page has getVideoEmbedUrl() that converts recognized URLs to their /embed/ variant. Unknown URLs fall back to a "?" card with a direct link. Admins see a "configure in Settings" nudge when no URL is set. - Video renders as a 16:9 iframe capped at 480px height, above the hero. - .faq-shell is now width:100% (was max-width:1200px) so FAQ uses all available horizontal space. - .faq-grid switched from strict 2 columns to repeat(auto-fit, minmax(420px, 1fr)) so wide screens get 3 columns and narrow screens stack. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,35 @@ class Faq extends Page
|
||||
return Hints::AREAS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a YouTube / Vimeo watch URL to the embed URL that goes in
|
||||
* an <iframe src="">. Returns null if the URL is empty or the host
|
||||
* isn't recognized (in which case we fall back to a plain link).
|
||||
*/
|
||||
public function getVideoEmbedUrl(): ?string
|
||||
{
|
||||
$tenant = app(\App\Tenancy\TenantManager::class)->current();
|
||||
$raw = trim((string) ($tenant?->settings['faq_video_url'] ?? ''));
|
||||
if ($raw === '') return null;
|
||||
|
||||
// YouTube: watch?v=ID | youtu.be/ID | shorts/ID
|
||||
if (preg_match('#(?:youtube\.com/(?:watch\?v=|shorts/|embed/)|youtu\.be/)([A-Za-z0-9_-]{6,})#', $raw, $m)) {
|
||||
return 'https://www.youtube.com/embed/' . $m[1];
|
||||
}
|
||||
// Vimeo: vimeo.com/ID
|
||||
if (preg_match('#vimeo\.com/(\d+)#', $raw, $m)) {
|
||||
return 'https://player.vimeo.com/video/' . $m[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function getVideoRawUrl(): ?string
|
||||
{
|
||||
$tenant = app(\App\Tenancy\TenantManager::class)->current();
|
||||
$raw = trim((string) ($tenant?->settings['faq_video_url'] ?? ''));
|
||||
return $raw !== '' ? $raw : null;
|
||||
}
|
||||
|
||||
public function getGrouped(): array
|
||||
{
|
||||
$q = mb_strtolower(trim($this->search));
|
||||
|
||||
@@ -59,6 +59,7 @@ class Settings extends Page
|
||||
'labor_rate' => $settings['labor_rate'] ?? 400,
|
||||
'services' => isset($settings['services']) ? implode(', ', (array) $settings['services']) : '',
|
||||
'cars' => isset($settings['cars']) ? implode(', ', (array) $settings['cars']) : '',
|
||||
'faq_video_url' => $settings['faq_video_url'] ?? null,
|
||||
'notify_wo_ready' => $notify['wo_ready'] ?? true,
|
||||
'notify_payment' => $notify['payment'] ?? true,
|
||||
'notify_appointment' => $notify['appointment'] ?? true,
|
||||
@@ -148,6 +149,11 @@ class Settings extends Page
|
||||
Forms\Components\Textarea::make('cars')
|
||||
->label(__('Mărci auto suportate (separate prin virgulă)'))
|
||||
->rows(2),
|
||||
Forms\Components\TextInput::make('faq_video_url')
|
||||
->label(__('Link video tutorial (YouTube / Vimeo)'))
|
||||
->url()
|
||||
->placeholder('https://www.youtube.com/watch?v=... sau https://vimeo.com/...')
|
||||
->helperText(__('Afișat în pagina FAQ ca player embedded. Lasă gol pentru a-l ascunde.')),
|
||||
]),
|
||||
Schemas\Components\Section::make(__('Logo & favicon'))
|
||||
->columns(2)
|
||||
@@ -268,6 +274,7 @@ class Settings extends Page
|
||||
'labor_rate' => (float) ($data['labor_rate'] ?? 400),
|
||||
'services' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['services'] ?? ''))))),
|
||||
'cars' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['cars'] ?? ''))))),
|
||||
'faq_video_url' => trim((string) ($data['faq_video_url'] ?? '')) ?: null,
|
||||
'notify' => [
|
||||
'wo_ready' => (bool) ($data['notify_wo_ready'] ?? true),
|
||||
'payment' => (bool) ($data['notify_payment'] ?? true),
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"Adaugă rând în șablon": "Add row to template",
|
||||
"Adaugă sarcină": "Add task",
|
||||
"Adaugă serviciu": "Add service",
|
||||
"Adaugă un link YouTube/Vimeo în": "Add a YouTube/Vimeo link in",
|
||||
"Adaugă utilizator": "Add user",
|
||||
"Adaugă vehicul": "Add vehicle",
|
||||
"Address": "Address",
|
||||
@@ -154,6 +155,7 @@
|
||||
"Aduce înapoi hint-urile ascunse": "Bring back dismissed hints",
|
||||
"Advance": "Advance",
|
||||
"Aer condiționat": "Air conditioning",
|
||||
"Afișat în pagina FAQ ca player embedded. Lasă gol pentru a-l ascunde.": "Shown on the FAQ page as an embedded player. Leave empty to hide.",
|
||||
"Afișează detalii marjă la procesele calculate": "Show margin details in calculations",
|
||||
"Ai cerut resetarea parolei pentru contul tău de magazin. Apasă linkul de mai jos ca să setezi o parolă nouă:": "You requested a password reset for your shop account. Click the link below to set a new password:",
|
||||
"Ai deja cont?": "Already have an account?",
|
||||
@@ -1073,6 +1075,7 @@
|
||||
"Link": "Link",
|
||||
"Link client (QR)": "Client link (QR)",
|
||||
"Link tracking": "Tracking link",
|
||||
"Link video tutorial (YouTube / Vimeo)": "Video tutorial link (YouTube / Vimeo)",
|
||||
"Linkul expiră în 60 de minute. Dacă nu ai cerut tu acest reset, ignoră emailul — contul tău e în siguranță.": "Linkul expiră în 60 de minute. Dacă nu ai cerut tu acest reset, ignoră emailul — contul tău e în siguranță.",
|
||||
"Linkul expiră în 7 zile. Dacă nu te aștepți la această invitație, ignoră acest mesaj.\n\nMulțumim,": "Linkul expiră în 7 zile. Dacă nu te aștepți la această invitație, ignoră acest mesaj.\n\nMulțumim,",
|
||||
"Lipsește bot token în setări.": "Bot token missing in settings.",
|
||||
@@ -1885,6 +1888,7 @@
|
||||
"Setări plăți": "Payment settings",
|
||||
"Setări plăți salvate": "Setări plăți salvate",
|
||||
"Setări salvate": "Settings saved",
|
||||
"Setări → Liste configurabile": "Settings → Configurable lists",
|
||||
"Setările au fost salvate. Hai să adăugăm primul client.": "Setările au fost salvate. Hai să adăugăm primul client.",
|
||||
"Sever": "Severe",
|
||||
"Sex": "Gender",
|
||||
@@ -2127,6 +2131,7 @@
|
||||
"Type": "Type",
|
||||
"UAH — Hryvnia": "UAH — Hryvnia",
|
||||
"UM": "Unit",
|
||||
"URL nerecunoscut — deschide-l direct:": "Unrecognized URL — open it directly:",
|
||||
"URL secret pentru POST direct cu lead-uri din site-ul tău.": "Secret URL for direct POST with leads from your site.",
|
||||
"USD — US Dollar": "USD — US Dollar",
|
||||
"Ulei": "Oil",
|
||||
@@ -2221,6 +2226,7 @@
|
||||
"Vezi toți clienții": "View all clients",
|
||||
"Vezi utilizatori": "View users",
|
||||
"Viber": "Viber",
|
||||
"Video tutorial": "Video tutorial",
|
||||
"View": "View",
|
||||
"Viitoare": "Viitoare",
|
||||
"Vin": "VIN",
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"Adaugă rând în șablon": "Добавить строку в шаблон",
|
||||
"Adaugă sarcină": "Добавить задачу",
|
||||
"Adaugă serviciu": "Добавить услугу",
|
||||
"Adaugă un link YouTube/Vimeo în": "Добавь ссылку YouTube/Vimeo в",
|
||||
"Adaugă utilizator": "Добавить пользователя",
|
||||
"Adaugă vehicul": "Добавить транспорт",
|
||||
"Address": "Адрес",
|
||||
@@ -154,6 +155,7 @@
|
||||
"Aduce înapoi hint-urile ascunse": "Вернуть скрытые подсказки",
|
||||
"Advance": "Аванс",
|
||||
"Aer condiționat": "Кондиционер",
|
||||
"Afișat în pagina FAQ ca player embedded. Lasă gol pentru a-l ascunde.": "Показывается на странице FAQ как встроенный плеер. Оставь пустым, чтобы скрыть.",
|
||||
"Afișează detalii marjă la procesele calculate": "Показать детали маржи в расчётах",
|
||||
"Ai cerut resetarea parolei pentru contul tău de magazin. Apasă linkul de mai jos ca să setezi o parolă nouă:": "Вы запросили сброс пароля для вашего аккаунта магазина. Нажмите ссылку ниже, чтобы задать новый пароль:",
|
||||
"Ai deja cont?": "Уже есть аккаунт?",
|
||||
@@ -1073,6 +1075,7 @@
|
||||
"Link": "Ссылка",
|
||||
"Link client (QR)": "Ссылка клиенту (QR)",
|
||||
"Link tracking": "Ссылка отслеживания",
|
||||
"Link video tutorial (YouTube / Vimeo)": "Ссылка на видео-урок (YouTube / Vimeo)",
|
||||
"Linkul expiră în 60 de minute. Dacă nu ai cerut tu acest reset, ignoră emailul — contul tău e în siguranță.": "Linkul expiră în 60 de minute. Dacă nu ai cerut tu acest reset, ignoră emailul — contul tău e în siguranță.",
|
||||
"Linkul expiră în 7 zile. Dacă nu te aștepți la această invitație, ignoră acest mesaj.\n\nMulțumim,": "Linkul expiră în 7 zile. Dacă nu te aștepți la această invitație, ignoră acest mesaj.\n\nMulțumim,",
|
||||
"Lipsește bot token în setări.": "В настройках отсутствует bot token.",
|
||||
@@ -1885,6 +1888,7 @@
|
||||
"Setări plăți": "Настройки платежей",
|
||||
"Setări plăți salvate": "Setări plăți salvate",
|
||||
"Setări salvate": "Настройки сохранены",
|
||||
"Setări → Liste configurabile": "Настройки → Списки конфигурации",
|
||||
"Setările au fost salvate. Hai să adăugăm primul client.": "Setările au fost salvate. Hai să adăugăm primul client.",
|
||||
"Sever": "Тяжёлое",
|
||||
"Sex": "Пол",
|
||||
@@ -2127,6 +2131,7 @@
|
||||
"Type": "Тип",
|
||||
"UAH — Hryvnia": "UAH — Гривна",
|
||||
"UM": "Ед.",
|
||||
"URL nerecunoscut — deschide-l direct:": "URL не распознан — открой напрямую:",
|
||||
"URL secret pentru POST direct cu lead-uri din site-ul tău.": "Секретный URL для POST-запросов с заявками с вашего сайта.",
|
||||
"USD — US Dollar": "USD — Доллар США",
|
||||
"Ulei": "Масло",
|
||||
@@ -2221,6 +2226,7 @@
|
||||
"Vezi toți clienții": "Просмотр всех клиентов",
|
||||
"Vezi utilizatori": "Просмотр пользователей",
|
||||
"Viber": "Viber",
|
||||
"Video tutorial": "Видео-урок",
|
||||
"View": "Просмотр",
|
||||
"Viitoare": "Viitoare",
|
||||
"Vin": "VIN",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
$grouped = $this->getGrouped();
|
||||
$user = auth()->user();
|
||||
$totalHints = collect($grouped)->sum(fn ($g) => count($g));
|
||||
$videoEmbed = $this->getVideoEmbedUrl();
|
||||
$videoRaw = $this->getVideoRawUrl();
|
||||
// Distinct accent color per area for card headers
|
||||
$areaColors = [
|
||||
'service' => '#0ea5e9',
|
||||
@@ -15,7 +17,42 @@
|
||||
@endphp
|
||||
|
||||
<style>
|
||||
.faq-shell { max-width: 1200px; margin: 0 auto; }
|
||||
.faq-shell { width: 100%; }
|
||||
|
||||
/* ── VIDEO ── */
|
||||
.faq-video-card {
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 480px;
|
||||
}
|
||||
.faq-video-card iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
.faq-video-fallback {
|
||||
background: linear-gradient(135deg, #374151 0%, #111827 100%);
|
||||
border-radius: 12px;
|
||||
padding: 40px 28px;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.faq-video-fallback-icon { font-size: 40px; }
|
||||
.faq-video-fallback-txt { flex: 1; min-width: 200px; }
|
||||
.faq-video-fallback-title { font-size: 16px; font-weight: 600; margin: 0 0 4px; }
|
||||
.faq-video-fallback-sub { font-size: 13px; opacity: .8; margin: 0; }
|
||||
.faq-video-fallback a { color: #60a5fa; text-decoration: none; font-weight: 500; }
|
||||
.faq-video-fallback a:hover { text-decoration: underline; }
|
||||
|
||||
/* ── HERO STRIP ── */
|
||||
.faq-hero {
|
||||
@@ -83,7 +120,7 @@
|
||||
.dark .faq-btn-reset:hover { background: #4b5563; color: #f3f4f6; }
|
||||
|
||||
/* ── GRID OF AREA CARDS ── */
|
||||
.faq-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
|
||||
.faq-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); gap: 20px; }
|
||||
@media (max-width: 900px) { .faq-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
.faq-card {
|
||||
@@ -211,6 +248,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- VIDEO TUTORIAL (dacă e configurat) --}}
|
||||
@if ($videoEmbed)
|
||||
<div class="faq-video-card">
|
||||
<iframe src="{{ $videoEmbed }}"
|
||||
title="{{ __('Video tutorial') }}"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen></iframe>
|
||||
</div>
|
||||
@elseif ($videoRaw)
|
||||
<div class="faq-video-fallback">
|
||||
<div class="faq-video-fallback-icon">🎥</div>
|
||||
<div class="faq-video-fallback-txt">
|
||||
<h3 class="faq-video-fallback-title">{{ __('Video tutorial') }}</h3>
|
||||
<p class="faq-video-fallback-sub">
|
||||
{{ __('URL nerecunoscut — deschide-l direct:') }}
|
||||
<a href="{{ $videoRaw }}" target="_blank">{{ $videoRaw }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($user?->isAdmin())
|
||||
<div class="faq-video-fallback">
|
||||
<div class="faq-video-fallback-icon">🎥</div>
|
||||
<div class="faq-video-fallback-txt">
|
||||
<h3 class="faq-video-fallback-title">{{ __('Video tutorial') }}</h3>
|
||||
<p class="faq-video-fallback-sub">
|
||||
{{ __('Adaugă un link YouTube/Vimeo în') }}
|
||||
<a href="{{ url('/app/settings') }}">{{ __('Setări → Liste configurabile') }} →</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- CONTROLS --}}
|
||||
<div class="faq-controls">
|
||||
<input type="text" class="faq-search" placeholder="{{ __('Caută în ghid...') }}" wire:model.live.debounce.300ms="search">
|
||||
|
||||
Reference in New Issue
Block a user