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:
2026-09-01 10:50:40 +00:00
parent cae2904c76
commit 0716b70bfa
5 changed files with 119 additions and 2 deletions
+29
View File
@@ -45,6 +45,35 @@ class Faq extends Page
return Hints::AREAS; 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 public function getGrouped(): array
{ {
$q = mb_strtolower(trim($this->search)); $q = mb_strtolower(trim($this->search));
+7
View File
@@ -59,6 +59,7 @@ class Settings extends Page
'labor_rate' => $settings['labor_rate'] ?? 400, 'labor_rate' => $settings['labor_rate'] ?? 400,
'services' => isset($settings['services']) ? implode(', ', (array) $settings['services']) : '', 'services' => isset($settings['services']) ? implode(', ', (array) $settings['services']) : '',
'cars' => isset($settings['cars']) ? implode(', ', (array) $settings['cars']) : '', 'cars' => isset($settings['cars']) ? implode(', ', (array) $settings['cars']) : '',
'faq_video_url' => $settings['faq_video_url'] ?? null,
'notify_wo_ready' => $notify['wo_ready'] ?? true, 'notify_wo_ready' => $notify['wo_ready'] ?? true,
'notify_payment' => $notify['payment'] ?? true, 'notify_payment' => $notify['payment'] ?? true,
'notify_appointment' => $notify['appointment'] ?? true, 'notify_appointment' => $notify['appointment'] ?? true,
@@ -148,6 +149,11 @@ class Settings extends Page
Forms\Components\Textarea::make('cars') Forms\Components\Textarea::make('cars')
->label(__('Mărci auto suportate (separate prin virgulă)')) ->label(__('Mărci auto suportate (separate prin virgulă)'))
->rows(2), ->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')) Schemas\Components\Section::make(__('Logo & favicon'))
->columns(2) ->columns(2)
@@ -268,6 +274,7 @@ class Settings extends Page
'labor_rate' => (float) ($data['labor_rate'] ?? 400), 'labor_rate' => (float) ($data['labor_rate'] ?? 400),
'services' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['services'] ?? ''))))), 'services' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['services'] ?? ''))))),
'cars' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['cars'] ?? ''))))), 'cars' => array_values(array_filter(array_map('trim', explode(',', (string) ($data['cars'] ?? ''))))),
'faq_video_url' => trim((string) ($data['faq_video_url'] ?? '')) ?: null,
'notify' => [ 'notify' => [
'wo_ready' => (bool) ($data['notify_wo_ready'] ?? true), 'wo_ready' => (bool) ($data['notify_wo_ready'] ?? true),
'payment' => (bool) ($data['notify_payment'] ?? true), 'payment' => (bool) ($data['notify_payment'] ?? true),
+6
View File
@@ -141,6 +141,7 @@
"Adaugă rând în șablon": "Add row to template", "Adaugă rând în șablon": "Add row to template",
"Adaugă sarcină": "Add task", "Adaugă sarcină": "Add task",
"Adaugă serviciu": "Add service", "Adaugă serviciu": "Add service",
"Adaugă un link YouTube/Vimeo în": "Add a YouTube/Vimeo link in",
"Adaugă utilizator": "Add user", "Adaugă utilizator": "Add user",
"Adaugă vehicul": "Add vehicle", "Adaugă vehicul": "Add vehicle",
"Address": "Address", "Address": "Address",
@@ -154,6 +155,7 @@
"Aduce înapoi hint-urile ascunse": "Bring back dismissed hints", "Aduce înapoi hint-urile ascunse": "Bring back dismissed hints",
"Advance": "Advance", "Advance": "Advance",
"Aer condiționat": "Air conditioning", "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", "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 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?", "Ai deja cont?": "Already have an account?",
@@ -1073,6 +1075,7 @@
"Link": "Link", "Link": "Link",
"Link client (QR)": "Client link (QR)", "Link client (QR)": "Client link (QR)",
"Link tracking": "Tracking link", "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 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,", "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.", "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": "Payment settings",
"Setări plăți salvate": "Setări plăți salvate", "Setări plăți salvate": "Setări plăți salvate",
"Setări salvate": "Settings saved", "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.", "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", "Sever": "Severe",
"Sex": "Gender", "Sex": "Gender",
@@ -2127,6 +2131,7 @@
"Type": "Type", "Type": "Type",
"UAH — Hryvnia": "UAH — Hryvnia", "UAH — Hryvnia": "UAH — Hryvnia",
"UM": "Unit", "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.", "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", "USD — US Dollar": "USD — US Dollar",
"Ulei": "Oil", "Ulei": "Oil",
@@ -2221,6 +2226,7 @@
"Vezi toți clienții": "View all clients", "Vezi toți clienții": "View all clients",
"Vezi utilizatori": "View users", "Vezi utilizatori": "View users",
"Viber": "Viber", "Viber": "Viber",
"Video tutorial": "Video tutorial",
"View": "View", "View": "View",
"Viitoare": "Viitoare", "Viitoare": "Viitoare",
"Vin": "VIN", "Vin": "VIN",
+6
View File
@@ -141,6 +141,7 @@
"Adaugă rând în șablon": "Добавить строку в шаблон", "Adaugă rând în șablon": "Добавить строку в шаблон",
"Adaugă sarcină": "Добавить задачу", "Adaugă sarcină": "Добавить задачу",
"Adaugă serviciu": "Добавить услугу", "Adaugă serviciu": "Добавить услугу",
"Adaugă un link YouTube/Vimeo în": "Добавь ссылку YouTube/Vimeo в",
"Adaugă utilizator": "Добавить пользователя", "Adaugă utilizator": "Добавить пользователя",
"Adaugă vehicul": "Добавить транспорт", "Adaugă vehicul": "Добавить транспорт",
"Address": "Адрес", "Address": "Адрес",
@@ -154,6 +155,7 @@
"Aduce înapoi hint-urile ascunse": "Вернуть скрытые подсказки", "Aduce înapoi hint-urile ascunse": "Вернуть скрытые подсказки",
"Advance": "Аванс", "Advance": "Аванс",
"Aer condiționat": "Кондиционер", "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": "Показать детали маржи в расчётах", "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 cerut resetarea parolei pentru contul tău de magazin. Apasă linkul de mai jos ca să setezi o parolă nouă:": "Вы запросили сброс пароля для вашего аккаунта магазина. Нажмите ссылку ниже, чтобы задать новый пароль:",
"Ai deja cont?": "Уже есть аккаунт?", "Ai deja cont?": "Уже есть аккаунт?",
@@ -1073,6 +1075,7 @@
"Link": "Ссылка", "Link": "Ссылка",
"Link client (QR)": "Ссылка клиенту (QR)", "Link client (QR)": "Ссылка клиенту (QR)",
"Link tracking": "Ссылка отслеживания", "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 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,", "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.", "Lipsește bot token în setări.": "В настройках отсутствует bot token.",
@@ -1885,6 +1888,7 @@
"Setări plăți": "Настройки платежей", "Setări plăți": "Настройки платежей",
"Setări plăți salvate": "Setări plăți salvate", "Setări plăți salvate": "Setări plăți salvate",
"Setări 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.", "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": "Тяжёлое", "Sever": "Тяжёлое",
"Sex": "Пол", "Sex": "Пол",
@@ -2127,6 +2131,7 @@
"Type": "Тип", "Type": "Тип",
"UAH — Hryvnia": "UAH — Гривна", "UAH — Hryvnia": "UAH — Гривна",
"UM": "Ед.", "UM": "Ед.",
"URL nerecunoscut — deschide-l direct:": "URL не распознан — открой напрямую:",
"URL secret pentru POST direct cu lead-uri din site-ul tău.": "Секретный URL для POST-запросов с заявками с вашего сайта.", "URL secret pentru POST direct cu lead-uri din site-ul tău.": "Секретный URL для POST-запросов с заявками с вашего сайта.",
"USD — US Dollar": "USD — Доллар США", "USD — US Dollar": "USD — Доллар США",
"Ulei": "Масло", "Ulei": "Масло",
@@ -2221,6 +2226,7 @@
"Vezi toți clienții": "Просмотр всех клиентов", "Vezi toți clienții": "Просмотр всех клиентов",
"Vezi utilizatori": "Просмотр пользователей", "Vezi utilizatori": "Просмотр пользователей",
"Viber": "Viber", "Viber": "Viber",
"Video tutorial": "Видео-урок",
"View": "Просмотр", "View": "Просмотр",
"Viitoare": "Viitoare", "Viitoare": "Viitoare",
"Vin": "VIN", "Vin": "VIN",
@@ -5,6 +5,8 @@
$grouped = $this->getGrouped(); $grouped = $this->getGrouped();
$user = auth()->user(); $user = auth()->user();
$totalHints = collect($grouped)->sum(fn ($g) => count($g)); $totalHints = collect($grouped)->sum(fn ($g) => count($g));
$videoEmbed = $this->getVideoEmbedUrl();
$videoRaw = $this->getVideoRawUrl();
// Distinct accent color per area for card headers // Distinct accent color per area for card headers
$areaColors = [ $areaColors = [
'service' => '#0ea5e9', 'service' => '#0ea5e9',
@@ -15,7 +17,42 @@
@endphp @endphp
<style> <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 ── */ /* ── HERO STRIP ── */
.faq-hero { .faq-hero {
@@ -83,7 +120,7 @@
.dark .faq-btn-reset:hover { background: #4b5563; color: #f3f4f6; } .dark .faq-btn-reset:hover { background: #4b5563; color: #f3f4f6; }
/* ── GRID OF AREA CARDS ── */ /* ── 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; } } @media (max-width: 900px) { .faq-grid { grid-template-columns: 1fr; } }
.faq-card { .faq-card {
@@ -211,6 +248,38 @@
</div> </div>
</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 --}} {{-- CONTROLS --}}
<div class="faq-controls"> <div class="faq-controls">
<input type="text" class="faq-search" placeholder="{{ __('Caută în ghid...') }}" wire:model.live.debounce.300ms="search"> <input type="text" class="faq-search" placeholder="{{ __('Caută în ghid...') }}" wire:model.live.debounce.300ms="search">