Auto commit: 2026-04-06T07:22:53.709Z

This commit is contained in:
Flatlogic Bot 2026-04-06 07:22:53 +00:00
parent ffd5ebcc11
commit ca311e99b4
13 changed files with 1664 additions and 515 deletions

View File

@ -3,117 +3,102 @@
namespace App\Filament\Resources;
use App\Filament\Resources\BookingResource\Pages;
use App\Filament\Resources\BookingResource\RelationManagers;
use App\Models\Booking;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class BookingResource extends Resource
{
protected static ?string $model = Booking::class;
protected static ?string $navigationIcon = 'heroicon-o-ticket';
protected static ?string $navigationLabel = 'Bookings';
protected static ?string $navigationGroup = 'Demo TAXILANZ';
protected static ?string $navigationLabel = 'Reservas';
protected static ?string $navigationGroup = 'Funnel TAXILANZ';
protected static ?string $modelLabel = 'Reserva';
protected static ?string $pluralModelLabel = 'Reservas';
protected static ?int $navigationSort = 3;
public static function getNavigationBadge(): ?string
{
return (string) static::getModel()::count();
}
public static function getNavigationBadgeColor(): ?string
{
return 'success';
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('uuid')
->label('UUID')
->required()
->maxLength(36),
Forms\Components\Select::make('ride_id')
->relationship('ride', 'id')
->default(null),
Forms\Components\Select::make('offer_id')
->relationship('offer', 'title')
->required(),
Forms\Components\TextInput::make('ride_recommendation_id')
->numeric()
->default(null),
Forms\Components\TextInput::make('customer_name')
->maxLength(255)
->default(null),
Forms\Components\TextInput::make('customer_email')
->email()
->maxLength(255)
->default(null),
Forms\Components\TextInput::make('customer_phone')
->tel()
->maxLength(255)
->default(null),
Forms\Components\TextInput::make('party_size')
->required()
->numeric()
->default(1),
Forms\Components\DateTimePicker::make('booking_for'),
Forms\Components\TextInput::make('status')
->required(),
Forms\Components\TextInput::make('amount')
->numeric()
->default(null),
Forms\Components\TextInput::make('commission_amount')
->numeric()
->default(null),
Forms\Components\Textarea::make('notes')
->columnSpanFull(),
Forms\Components\TextInput::make('uuid')->label('UUID')->required()->maxLength(36),
Forms\Components\Select::make('ride_id')->label('Trayecto')->relationship('ride', 'id')->default(null),
Forms\Components\Select::make('offer_id')->label('Oferta')->relationship('offer', 'title')->required(),
Forms\Components\TextInput::make('ride_recommendation_id')->label('Recomendación')->numeric()->default(null),
Forms\Components\TextInput::make('customer_name')->label('Cliente')->maxLength(255)->default(null),
Forms\Components\TextInput::make('customer_email')->email()->maxLength(255)->default(null),
Forms\Components\TextInput::make('customer_phone')->tel()->maxLength(255)->default(null),
Forms\Components\TextInput::make('party_size')->label('Personas')->required()->numeric()->default(1),
Forms\Components\DateTimePicker::make('booking_for')->label('Fecha / hora'),
Forms\Components\TextInput::make('status')->label('Estado')->required(),
Forms\Components\TextInput::make('amount')->label('Importe')->numeric()->default(null),
Forms\Components\TextInput::make('commission_amount')->label('Comisión')->numeric()->default(null),
Forms\Components\Textarea::make('notes')->label('Nota')->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('uuid')
->label('UUID')
->searchable(),
Tables\Columns\TextColumn::make('ride.id')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('offer.title')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('ride_recommendation_id')
->numeric()
->sortable(),
->label('Oferta')
->searchable()
->weight('semibold'),
Tables\Columns\TextColumn::make('customer_name')
->searchable(),
Tables\Columns\TextColumn::make('customer_email')
->searchable(),
Tables\Columns\TextColumn::make('customer_phone')
->searchable(),
Tables\Columns\TextColumn::make('party_size')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('booking_for')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('status'),
->label('Cliente')
->searchable()
->description(fn (Booking $record): string => $record->ride?->pickup_label ? 'Ride: '.$record->ride->pickup_label : 'Reserva directa'),
Tables\Columns\TextColumn::make('status')
->label('Estado')
->badge()
->color(fn (?string $state): string => match ($state) {
'confirmed', 'completed' => 'success',
'pending' => 'warning',
'cancelled' => 'danger',
default => 'gray',
}),
Tables\Columns\TextColumn::make('amount')
->numeric()
->label('Importe')
->money('EUR')
->sortable(),
Tables\Columns\TextColumn::make('commission_amount')
->numeric()
->label('Comisión')
->money('EUR')
->sortable(),
Tables\Columns\TextColumn::make('ride_recommendation_id')
->label('Top')
->formatStateUsing(fn ($state): string => $state ? 'Sí' : 'Directa')
->badge()
->color(fn ($state): string => $state ? 'primary' : 'gray'),
Tables\Columns\TextColumn::make('booking_for')
->label('Fecha')
->dateTime('d/m/Y H:i')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->label('Creada')
->since()
->sortable(),
Tables\Columns\TextColumn::make('uuid')
->label('UUID')
->copyable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
@ -127,9 +112,7 @@ class BookingResource extends Resource
public static function getRelations(): array
{
return [
//
];
return [];
}
public static function getPages(): array

View File

@ -3,77 +3,92 @@
namespace App\Filament\Resources;
use App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource\RelationManagers;
use App\Models\Event;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class EventResource extends Resource
{
protected static ?string $model = Event::class;
protected static ?string $navigationIcon = 'heroicon-o-bolt';
protected static ?string $navigationLabel = 'Events';
protected static ?string $navigationGroup = 'Demo TAXILANZ';
protected static ?string $navigationLabel = 'Eventos';
protected static ?string $navigationGroup = 'Funnel TAXILANZ';
protected static ?string $modelLabel = 'Evento';
protected static ?string $pluralModelLabel = 'Eventos';
protected static ?int $navigationSort = 4;
public static function getNavigationBadge(): ?string
{
return (string) static::getModel()::count();
}
public static function getNavigationBadgeColor(): ?string
{
return 'gray';
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('event_type')
->required(),
Forms\Components\Select::make('ride_id')
->relationship('ride', 'id')
->default(null),
Forms\Components\Select::make('offer_id')
->relationship('offer', 'title')
->default(null),
Forms\Components\Select::make('booking_id')
->relationship('booking', 'id')
->default(null),
Forms\Components\TextInput::make('ride_recommendation_id')
->numeric()
->default(null),
Forms\Components\TextInput::make('session_id')
->maxLength(255)
->default(null),
Forms\Components\Textarea::make('meta')
->columnSpanFull(),
Forms\Components\TextInput::make('event_type')->label('Tipo')->required(),
Forms\Components\Select::make('ride_id')->label('Trayecto')->relationship('ride', 'id')->default(null),
Forms\Components\Select::make('offer_id')->label('Oferta')->relationship('offer', 'title')->default(null),
Forms\Components\Select::make('booking_id')->label('Reserva')->relationship('booking', 'id')->default(null),
Forms\Components\TextInput::make('ride_recommendation_id')->label('Recomendación')->numeric()->default(null),
Forms\Components\TextInput::make('session_id')->label('Sesión')->maxLength(255)->default(null),
Forms\Components\Textarea::make('meta')->label('Meta')->columnSpanFull(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('event_type'),
Tables\Columns\TextColumn::make('ride.id')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('event_type')
->label('Evento')
->badge()
->formatStateUsing(fn (?string $state): string => str($state ?: 'unknown')->replace('_', ' ')->title())
->color(fn (?string $state): string => match ($state) {
'request_created' => 'info',
'recommendation_viewed' => 'warning',
'recommendation_clicked' => 'primary',
'booking_started' => 'gray',
'booking_completed' => 'success',
default => 'gray',
}),
Tables\Columns\TextColumn::make('ride_id')
->label('Ride')
->badge()
->color('info'),
Tables\Columns\TextColumn::make('offer.title')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('booking.id')
->numeric()
->sortable(),
->label('Oferta')
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('booking_id')
->label('Booking')
->badge()
->color('success')
->toggleable(),
Tables\Columns\TextColumn::make('ride_recommendation_id')
->numeric()
->sortable(),
->label('Top')
->badge()
->color('primary')
->toggleable(),
Tables\Columns\TextColumn::make('session_id')
->searchable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->label('Sesión')
->limit(18)
->searchable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
Tables\Columns\TextColumn::make('created_at')
->label('Creado')
->dateTime('d/m/Y H:i')
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
@ -87,9 +102,7 @@ class EventResource extends Resource
public static function getRelations(): array
{
return [
//
];
return [];
}
public static function getPages(): array

View File

@ -3,124 +3,107 @@
namespace App\Filament\Resources;
use App\Filament\Resources\OfferResource\Pages;
use App\Filament\Resources\OfferResource\RelationManagers;
use App\Models\Offer;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class OfferResource extends Resource
{
protected static ?string $model = Offer::class;
protected static ?string $navigationIcon = 'heroicon-o-sparkles';
protected static ?string $navigationLabel = 'Offers';
protected static ?string $navigationGroup = 'Demo TAXILANZ';
protected static ?string $navigationLabel = 'Ofertas';
protected static ?string $navigationGroup = 'Funnel TAXILANZ';
protected static ?string $modelLabel = 'Oferta';
protected static ?string $pluralModelLabel = 'Ofertas';
protected static ?int $navigationSort = 1;
public static function getNavigationBadge(): ?string
{
return (string) static::getModel()::count();
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('uuid')
->label('UUID')
->required()
->maxLength(36),
Forms\Components\TextInput::make('title')
->required()
->maxLength(255),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255),
Forms\Components\TextInput::make('category')
->required(),
Forms\Components\TextInput::make('excerpt')
->maxLength(255)
->default(null),
Forms\Components\Textarea::make('description')
->columnSpanFull(),
Forms\Components\TextInput::make('location_label')
->maxLength(255)
->default(null),
Forms\Components\TextInput::make('lat')
->numeric()
->default(null),
Forms\Components\TextInput::make('lng')
->numeric()
->default(null),
Forms\Components\TextInput::make('price_from')
->numeric()
->default(null),
Forms\Components\TextInput::make('duration_minutes')
->numeric()
->default(null),
Forms\Components\FileUpload::make('image_url')
->image(),
Forms\Components\TextInput::make('status')
->required(),
Forms\Components\Toggle::make('is_featured')
->required(),
Forms\Components\TextInput::make('priority_score')
->required()
->numeric()
->default(0),
Forms\Components\Toggle::make('available_now')
->required(),
Forms\Components\TextInput::make('uuid')->label('UUID')->required()->maxLength(36),
Forms\Components\TextInput::make('title')->label('Título')->required()->maxLength(255),
Forms\Components\TextInput::make('slug')->required()->maxLength(255),
Forms\Components\TextInput::make('category')->label('Categoría')->required(),
Forms\Components\TextInput::make('excerpt')->label('Resumen')->maxLength(255)->default(null),
Forms\Components\Textarea::make('description')->label('Descripción')->columnSpanFull(),
Forms\Components\TextInput::make('location_label')->label('Ubicación')->maxLength(255)->default(null),
Forms\Components\TextInput::make('lat')->numeric()->default(null),
Forms\Components\TextInput::make('lng')->numeric()->default(null),
Forms\Components\TextInput::make('price_from')->label('Precio desde')->numeric()->default(null),
Forms\Components\TextInput::make('duration_minutes')->label('Duración')->numeric()->default(null),
Forms\Components\FileUpload::make('image_url')->label('Imagen')->image(),
Forms\Components\TextInput::make('status')->label('Estado')->required(),
Forms\Components\Toggle::make('is_featured')->label('Destacada')->required(),
Forms\Components\TextInput::make('priority_score')->label('Prioridad')->required()->numeric()->default(0),
Forms\Components\Toggle::make('available_now')->label('Disponible ahora')->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('priority_score', 'desc')
->columns([
Tables\Columns\TextColumn::make('uuid')
->label('UUID')
->searchable(),
Tables\Columns\TextColumn::make('title')
->searchable(),
Tables\Columns\TextColumn::make('slug')
->searchable(),
Tables\Columns\TextColumn::make('category'),
Tables\Columns\TextColumn::make('excerpt')
->searchable(),
->label('Oferta')
->searchable()
->weight('semibold')
->description(fn (Offer $record): string => $record->excerpt ?: ($record->location_label ?: 'Sin resumen')),
Tables\Columns\TextColumn::make('category')
->label('Categoría')
->badge()
->formatStateUsing(fn (?string $state): string => ucfirst($state ?: 'general'))
->color('warning'),
Tables\Columns\TextColumn::make('location_label')
->searchable(),
Tables\Columns\TextColumn::make('lat')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('lng')
->numeric()
->sortable(),
->label('Ubicación')
->badge()
->toggleable(),
Tables\Columns\TextColumn::make('price_from')
->numeric()
->label('Desde')
->money('EUR')
->sortable(),
Tables\Columns\TextColumn::make('duration_minutes')
->numeric()
->label('Duración')
->suffix(' min')
->sortable(),
Tables\Columns\ImageColumn::make('image_url'),
Tables\Columns\TextColumn::make('status'),
Tables\Columns\TextColumn::make('status')
->label('Estado')
->badge()
->color(fn (?string $state): string => match ($state) {
'active' => 'success',
'draft' => 'warning',
'archived' => 'gray',
default => 'primary',
}),
Tables\Columns\IconColumn::make('available_now')
->label('Ahora')
->boolean(),
Tables\Columns\IconColumn::make('is_featured')
->label('Destacada')
->boolean(),
Tables\Columns\TextColumn::make('priority_score')
->numeric()
->label('Prioridad')
->badge()
->sortable(),
Tables\Columns\IconColumn::make('available_now')
->boolean(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
->label('Creada')
->since()
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
@ -134,9 +117,7 @@ class OfferResource extends Resource
public static function getRelations(): array
{
return [
//
];
return [];
}
public static function getPages(): array

View File

@ -3,114 +3,107 @@
namespace App\Filament\Resources;
use App\Filament\Resources\RideResource\Pages;
use App\Filament\Resources\RideResource\RelationManagers;
use App\Models\Ride;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class RideResource extends Resource
{
protected static ?string $model = Ride::class;
protected static ?string $navigationIcon = 'heroicon-o-map';
protected static ?string $navigationLabel = 'Rides';
protected static ?string $navigationGroup = 'Demo TAXILANZ';
protected static ?string $navigationLabel = 'Trayectos';
protected static ?string $navigationGroup = 'Funnel TAXILANZ';
protected static ?string $modelLabel = 'Trayecto';
protected static ?string $pluralModelLabel = 'Trayectos';
protected static ?int $navigationSort = 2;
public static function getNavigationBadge(): ?string
{
return (string) static::getModel()::count();
}
public static function getNavigationBadgeColor(): ?string
{
return 'primary';
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('uuid')
->label('UUID')
->required()
->maxLength(36),
Forms\Components\TextInput::make('pickup_label')
->required()
->maxLength(255),
Forms\Components\TextInput::make('pickup_lat')
->numeric()
->default(null),
Forms\Components\TextInput::make('pickup_lng')
->numeric()
->default(null),
Forms\Components\TextInput::make('destination_label')
->required()
->maxLength(255),
Forms\Components\TextInput::make('destination_lat')
->numeric()
->default(null),
Forms\Components\TextInput::make('destination_lng')
->numeric()
->default(null),
Forms\Components\DateTimePicker::make('scheduled_for'),
Forms\Components\TextInput::make('status')
->required(),
Forms\Components\TextInput::make('eta_minutes')
->numeric()
->default(null),
Forms\Components\TextInput::make('source_channel')
->required(),
Forms\Components\TextInput::make('context_zone')
->maxLength(255)
->default(null),
Forms\Components\TextInput::make('locale')
->maxLength(10)
->default(null),
Forms\Components\TextInput::make('uuid')->label('UUID')->required()->maxLength(36),
Forms\Components\TextInput::make('pickup_label')->label('Recogida')->required()->maxLength(255),
Forms\Components\TextInput::make('pickup_lat')->numeric()->default(null),
Forms\Components\TextInput::make('pickup_lng')->numeric()->default(null),
Forms\Components\TextInput::make('destination_label')->label('Destino')->required()->maxLength(255),
Forms\Components\TextInput::make('destination_lat')->numeric()->default(null),
Forms\Components\TextInput::make('destination_lng')->numeric()->default(null),
Forms\Components\DateTimePicker::make('scheduled_for')->label('Programado para'),
Forms\Components\TextInput::make('status')->label('Estado')->required(),
Forms\Components\TextInput::make('eta_minutes')->label('ETA (min)')->numeric()->default(null),
Forms\Components\TextInput::make('source_channel')->label('Canal')->required(),
Forms\Components\TextInput::make('context_zone')->label('Zona')->maxLength(255)->default(null),
Forms\Components\TextInput::make('locale')->maxLength(10)->default(null),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('uuid')
->label('UUID')
->searchable(),
Tables\Columns\TextColumn::make('pickup_label')
->searchable(),
Tables\Columns\TextColumn::make('pickup_lat')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('pickup_lng')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('destination_label')
->searchable(),
Tables\Columns\TextColumn::make('destination_lat')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('destination_lng')
->numeric()
->label('Trayecto')
->searchable()
->description(fn (Ride $record): string => '→ '.$record->destination_label)
->weight('semibold'),
Tables\Columns\TextColumn::make('source_channel')
->label('Canal')
->badge()
->formatStateUsing(fn (?string $state): string => ucfirst($state ?: 'directo'))
->color(fn (?string $state): string => match ($state) {
'hotel' => 'warning',
'app' => 'success',
'web' => 'info',
'reception' => 'gray',
default => 'primary',
}),
Tables\Columns\TextColumn::make('context_zone')
->label('Zona')
->badge()
->searchable()
->toggleable(),
Tables\Columns\TextColumn::make('status')
->label('Estado')
->badge()
->color(fn (?string $state): string => match ($state) {
'confirmed' => 'success',
'pending' => 'warning',
'cancelled' => 'danger',
default => 'gray',
}),
Tables\Columns\TextColumn::make('eta_minutes')
->label('ETA')
->suffix(' min')
->sortable(),
Tables\Columns\TextColumn::make('scheduled_for')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('status'),
Tables\Columns\TextColumn::make('eta_minutes')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('source_channel'),
Tables\Columns\TextColumn::make('context_zone')
->searchable(),
Tables\Columns\TextColumn::make('locale')
->searchable(),
->label('Programado')
->dateTime('d/m/Y H:i')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('uuid')
->label('UUID')
->searchable()
->copyable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
->label('Creado')
->since()
->sortable(),
])
->actions([
Tables\Actions\EditAction::make(),
@ -124,9 +117,7 @@ class RideResource extends Resource
public static function getRelations(): array
{
return [
//
];
return [];
}
public static function getPages(): array

View File

@ -9,7 +9,13 @@ use Filament\Widgets\ChartWidget;
class FunnelStageChart extends ChartWidget
{
protected static ?string $heading = 'Embudo TAXILANZ';
protected static ?string $heading = 'Funnel ejecutivo';
protected int | string | array $columnSpan = 'full';
protected static ?int $sort = 2;
protected static ?string $maxHeight = '260px';
protected function getData(): array
{
@ -20,13 +26,40 @@ class FunnelStageChart extends ChartWidget
return [
'datasets' => [[
'label' => 'Eventos del funnel',
'label' => 'Funnel',
'data' => [$rides, $views, $clicks, $bookings],
'backgroundColor' => ['#0f766e', '#14b8a6', '#f59e0b', '#16a34a'],
'borderRadius' => 10,
'backgroundColor' => ['#0f172a', '#f59e0b', '#0f766e', '#16a34a'],
'borderRadius' => 12,
'borderSkipped' => false,
]],
'labels' => ['Taxi requests', 'Recommendation views', 'Clicks', 'Bookings'],
'labels' => ['Taxi', 'Views', 'Clicks', 'Bookings'],
];
}
protected function getOptions(): array
{
return [
'plugins' => [
'legend' => [
'display' => false,
],
],
'scales' => [
'x' => [
'grid' => [
'display' => false,
],
],
'y' => [
'beginAtZero' => true,
'grid' => [
'color' => 'rgba(148, 163, 184, 0.16)',
],
'ticks' => [
'precision' => 0,
],
],
],
];
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Booking;
use App\Models\Event;
use App\Models\Ride;
use Filament\Widgets\Widget;
class JourneyStoryboard extends Widget
{
protected static string $view = 'filament.widgets.journey-storyboard';
protected int | string | array $columnSpan = 'full';
protected static ?int $sort = 1;
protected function getViewData(): array
{
$rides = Ride::count();
$views = Event::where('event_type', 'recommendation_viewed')->count();
$clicks = Event::where('event_type', 'recommendation_clicked')->count();
$bookingsCount = Booking::count();
$latestBooking = Booking::with(['offer', 'ride', 'recommendation'])->latest()->first();
$commission = (float) Booking::sum('commission_amount');
$gmv = (float) Booking::sum('amount');
$rideToBooking = $rides > 0 ? round(($bookingsCount / $rides) * 100, 1) : 0;
$clickThrough = $views > 0 ? round(($clicks / $views) * 100, 1) : 0;
$topZone = Ride::query()
->selectRaw("COALESCE(context_zone, 'General') as zone, COUNT(*) as aggregate")
->groupBy('zone')
->orderByDesc('aggregate')
->value('zone') ?: 'General';
return [
'rides' => $rides,
'views' => $views,
'clicks' => $clicks,
'bookingsCount' => $bookingsCount,
'latestBooking' => $latestBooking,
'commission' => $commission,
'gmv' => $gmv,
'rideToBooking' => $rideToBooking,
'clickThrough' => $clickThrough,
'topZone' => $topZone,
];
}
}

View File

@ -2,10 +2,8 @@
namespace App\Providers\Filament;
use App\Filament\Widgets\FunnelOverview;
use App\Filament\Widgets\FunnelStageChart;
use App\Filament\Widgets\SourceChannelChart;
use App\Filament\Widgets\JourneyStoryboard;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
@ -14,7 +12,6 @@ use Filament\Pages;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\Widgets;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
@ -31,6 +28,7 @@ class AdminPanelProvider extends PanelProvider
->id('admin')
->path('admin')
->login()
->brandName('TAXILANZ Admin')
->colors([
'primary' => Color::Teal,
])
@ -41,10 +39,8 @@ class AdminPanelProvider extends PanelProvider
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
Widgets\AccountWidget::class,
FunnelOverview::class,
JourneyStoryboard::class,
FunnelStageChart::class,
SourceChannelChart::class,
])
->middleware([
EncryptCookies::class,

View File

@ -5,73 +5,128 @@
@section('content')
<section class="split">
<article class="card success form-card">
<span class="eyebrow">6 · Reserva confirmada</span>
<h1 style="font-size:clamp(2.15rem,4vw,3.7rem);max-width:14ch;">Reserva cerrada.</h1>
<p>
{{ $booking->customer_name }} ya quedó registrado para <strong>{{ $booking->offer->title }}</strong>.
Aquí se ve el valor del MVP: un trayecto real terminó en una intención comercial medible y atribuible.
</p>
<article class="card form-card">
<span class="eyebrow">Reserva confirmada</span>
<h1 style="font-size:clamp(2.15rem,4vw,3.7rem);max-width:12ch;">Reserva cerrada.</h1>
<p class="compact-lead">{{ $booking->customer_name }} ya tiene {{ $booking->offer->title }}.</p>
<div class="story-rail" aria-label="Progreso del funnel">
<span class="story-chip is-done">1 · Taxi</span>
<span class="story-chip is-done">2 · Contexto</span>
<span class="story-chip is-done">3 · Oferta</span>
<span class="story-chip is-active">4 · Reserva</span>
</div>
<div class="route-card">
<small>Resultado visible</small>
<small>Resultado</small>
<strong>{{ $booking->offer->title }}</strong>
<span>{{ $booking->amount ? '€'.number_format((float) $booking->amount, 2) : 'Importe pendiente' }} · {{ ucfirst($booking->status) }} · {{ $booking->ride?->context_zone ?: ($booking->offer->location_label ?: 'General') }}</span>
</div>
<div class="screen-kpis">
<div class="screen-kpi">
<div class="metric-strip metric-strip--3">
<div class="metric-cell">
<strong>{{ $booking->amount ? '€'.number_format((float) $booking->amount, 0) : '—' }}</strong>
<span>importe</span>
</div>
<div class="screen-kpi">
<div class="metric-cell">
<strong>{{ $booking->commission_amount ? '€'.number_format((float) $booking->commission_amount, 0) : '—' }}</strong>
<span>comisión</span>
</div>
<div class="screen-kpi">
<strong>{{ $booking->recommendation?->id ? 'Top '.$booking->recommendation->position : 'Directa' }}</strong>
<div class="metric-cell">
<strong>{{ $booking->recommendation?->position ? 'Top '.$booking->recommendation->position : 'Directa' }}</strong>
<span>atribución</span>
</div>
</div>
<div class="list">
<div class="list-item">Booking UUID: {{ $booking->uuid }}</div>
<div class="list-item">Estado: {{ ucfirst($booking->status) }}</div>
<div class="list-item">Importe: {{ $booking->amount ? '€'.number_format((float) $booking->amount, 2) : 'Pendiente' }}</div>
<div class="list-item">Comisión estimada: {{ $booking->commission_amount ? '€'.number_format((float) $booking->commission_amount, 2) : 'Pendiente' }}</div>
</div>
<div class="notice">
<strong>Qué demuestra este cierre:</strong>
el taxi no fue solo transporte. Funcionó como disparador de una reserva adicional con valor económico visible.
</div>
</article>
<aside class="card form-card">
<span class="eyebrow">Lectura de negocio</span>
<div class="phone-screen" style="padding:18px;">
<div class="phone-topbar">
<span>Conversión atribuida</span>
<span class="phone-dot-group" aria-hidden="true">
<span class="phone-dot"></span>
<span class="phone-dot"></span>
<span class="phone-dot is-live"></span>
</span>
</div>
<div class="phone-list">
<div class="phone-list-item"><strong>Ride vinculado</strong><small>{{ $booking->ride?->uuid ?? 'Reserva directa' }}</small></div>
<div class="phone-list-item"><strong>Canal de origen</strong><small>{{ $booking->ride?->source_channel ? ucfirst($booking->ride->source_channel) : 'Directo' }}</small></div>
<div class="phone-list-item"><strong>Zona</strong><small>{{ $booking->ride?->context_zone ?: ($booking->offer->location_label ?: 'General') }}</small></div>
<div class="phone-list-item"><strong>Recomendación vinculada</strong><small>{{ $booking->recommendation?->id ? 'Top '.$booking->recommendation->position : 'No' }}</small></div>
<div class="phone-list-item"><strong>Email cliente</strong><small>{{ $booking->customer_email ?: 'No informado' }}</small></div>
</div>
</div>
<div class="cta-row" style="margin-top:18px;">
<div class="cta-row" style="margin-top:10px;">
<a class="btn" href="{{ route('home') }}">Iniciar otro trayecto</a>
@if($booking->ride)
<a class="btn btn-secondary" href="{{ route('rides.confirmed', $booking->ride) }}">Volver al taxi confirmado</a>
@endif
<button type="button" class="icon-button" data-modal-open="booking-business" data-tooltip="Detalle negocio">Ver negocio</button>
</div>
<details class="disclosure" style="margin-top:8px;">
<summary>
<span> Ver detalle de atribución</span>
<span>Abrir</span>
</summary>
<div class="disclosure-body">
<ul class="accordion-list">
<li>Booking UUID: <strong>{{ $booking->uuid }}</strong>.</li>
<li>Estado: <strong>{{ ucfirst($booking->status) }}</strong>.</li>
<li>Ride vinculado: <strong>{{ $booking->ride?->uuid ?? 'Reserva directa' }}</strong>.</li>
<li>Origen: <strong>{{ $booking->ride?->source_channel ? ucfirst($booking->ride->source_channel) : 'Directo' }}</strong>.</li>
</ul>
</div>
</details>
</article>
<aside class="card form-card">
<span class="eyebrow">Business snapshot</span>
<div class="decision-primary">
<h2>Valor visible</h2>
<div class="screen-kpis">
<div class="screen-kpi">
<strong>{{ $booking->offer->location_label ?: 'Zona activa' }}</strong>
<span>zona</span>
</div>
<div class="screen-kpi">
<strong>{{ $booking->ride?->source_channel ? ucfirst($booking->ride->source_channel) : 'Directo' }}</strong>
<span>canal</span>
</div>
<div class="screen-kpi">
<strong>{{ $booking->customer_email ?: 'No informado' }}</strong>
<span>contacto</span>
</div>
</div>
</div>
<details class="disclosure disclosure--soft" style="margin-top:16px;">
<summary>
<span> Ver datos operativos</span>
<span>Abrir</span>
</summary>
<div class="disclosure-body">
<ul class="accordion-list">
<li>{{ $booking->offer->title }} queda como conversión cerrada.</li>
<li>La comisión demo ya es visible en dashboard.</li>
<li>El usuario ve un cierre limpio; negocio ve trazabilidad cuando la pide.</li>
</ul>
</div>
</details>
</aside>
</section>
<dialog class="app-modal" id="booking-business" aria-labelledby="booking-business-title">
<div class="app-modal-card">
<div class="app-modal-head">
<div>
<p class="eyebrow">Detalle negocio</p>
<h2 id="booking-business-title">Conversión atribuible</h2>
</div>
<button type="button" class="app-modal-close" data-modal-close aria-label="Cerrar modal">Cerrar</button>
</div>
<div class="metric-strip metric-strip--3">
<div class="metric-cell">
<strong>{{ $booking->amount ? '€'.number_format((float) $booking->amount, 2) : 'Pendiente' }}</strong>
<span>GMV</span>
</div>
<div class="metric-cell">
<strong>{{ $booking->commission_amount ? '€'.number_format((float) $booking->commission_amount, 2) : 'Pendiente' }}</strong>
<span>comisión</span>
</div>
<div class="metric-cell">
<strong>{{ $booking->recommendation?->position ? 'Top '.$booking->recommendation->position : 'Directa' }}</strong>
<span>origen</span>
</div>
</div>
<ul class="accordion-list">
<li>Oferta: <strong>{{ $booking->offer->title }}</strong>.</li>
<li>Zona: <strong>{{ $booking->ride?->context_zone ?: ($booking->offer->location_label ?: 'General') }}</strong>.</li>
<li>Canal: <strong>{{ $booking->ride?->source_channel ? ucfirst($booking->ride->source_channel) : 'Directo' }}</strong>.</li>
<li>La UI principal sigue limpia; la explicación queda bajo demanda.</li>
</ul>
</div>
</dialog>
@endsection

View File

@ -0,0 +1,83 @@
<x-filament-widgets::widget>
<x-filament::section>
<div class="rounded-3xl border border-primary-100 bg-gradient-to-br from-white via-primary-50/40 to-amber-50/60 p-6 shadow-sm">
<div class="flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div class="space-y-3">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-primary-600">Executive snapshot</p>
<div>
<h2 class="text-3xl font-semibold tracking-tight text-gray-950">Booking atribuible, sin ruido.</h2>
<p class="mt-2 max-w-2xl text-sm text-gray-600">Un KPI principal arriba. El funnel debajo. Lo demás, bajo demanda.</p>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-stretch">
<div class="min-w-[220px] rounded-2xl border border-white/80 bg-white/90 p-5 shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.22em] text-gray-500">KPI principal</p>
<p class="mt-2 text-4xl font-semibold tracking-tight text-gray-950">{{ number_format($commission, 0) }}</p>
<p class="mt-2 text-sm text-gray-600">Comisión atribuible</p>
</div>
<a href="{{ route('home') }}" class="inline-flex items-center justify-center rounded-2xl bg-primary-600 px-5 py-4 text-sm font-semibold text-white shadow-sm transition hover:bg-primary-500">
Ver front demo
</a>
</div>
</div>
<div class="mt-6 grid gap-3 md:grid-cols-4">
<article class="rounded-2xl border border-white/80 bg-white/85 p-4 shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-gray-500">Taxi requests</p>
<p class="mt-2 text-2xl font-semibold tracking-tight text-gray-950">{{ number_format($rides) }}</p>
</article>
<article class="rounded-2xl border border-white/80 bg-white/85 p-4 shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-gray-500">CTR view click</p>
<p class="mt-2 text-2xl font-semibold tracking-tight text-gray-950">{{ $clickThrough }}%</p>
</article>
<article class="rounded-2xl border border-white/80 bg-white/85 p-4 shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-gray-500">Bookings</p>
<p class="mt-2 text-2xl font-semibold tracking-tight text-gray-950">{{ number_format($bookingsCount) }}</p>
</article>
<article class="rounded-2xl border border-white/80 bg-white/85 p-4 shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-gray-500">GMV demo</p>
<p class="mt-2 text-2xl font-semibold tracking-tight text-gray-950">{{ number_format($gmv, 0) }}</p>
</article>
</div>
<div class="mt-6 grid gap-3 xl:grid-cols-4">
<article class="rounded-2xl border border-gray-200 bg-gray-950 p-4 text-white shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-white/60">1 · Taxi</p>
<p class="mt-2 text-2xl font-semibold tracking-tight">{{ number_format($rides) }}</p>
<p class="mt-2 text-sm text-white/70">entradas al funnel</p>
</article>
<article class="rounded-2xl border border-amber-200 bg-amber-50/80 p-4 shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-amber-800">2 · Contexto</p>
<p class="mt-2 text-2xl font-semibold tracking-tight text-gray-950">{{ number_format($views) }}</p>
<p class="mt-2 text-sm text-gray-600">views en espera útil</p>
</article>
<article class="rounded-2xl border border-teal-200 bg-teal-50/80 p-4 shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-teal-800">3 · Oferta</p>
<p class="mt-2 text-2xl font-semibold tracking-tight text-gray-950">{{ number_format($clicks) }}</p>
<p class="mt-2 text-sm text-gray-600">clics a detalle</p>
</article>
<article class="rounded-2xl border border-emerald-200 bg-emerald-50/80 p-4 shadow-sm">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-emerald-800">4 · Booking</p>
<p class="mt-2 text-2xl font-semibold tracking-tight text-gray-950">{{ $rideToBooking }}%</p>
<p class="mt-2 text-sm text-gray-600">ride booking</p>
</article>
</div>
<details class="mt-6 rounded-2xl border border-gray-200 bg-white/80 p-4">
<summary class="cursor-pointer list-none text-sm font-semibold text-gray-900"> Ver contexto ejecutivo</summary>
<div class="mt-4 grid gap-3 md:grid-cols-3">
<div class="rounded-2xl bg-gray-50 p-4">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-gray-500">Zona con más señal</p>
<p class="mt-2 text-lg font-semibold text-gray-950">{{ $topZone }}</p>
</div>
<div class="rounded-2xl bg-gray-50 p-4 md:col-span-2">
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-gray-500">Última reserva</p>
<p class="mt-2 text-lg font-semibold text-gray-950">{{ $latestBooking?->offer?->title ?: 'Todavía no hay bookings' }}</p>
<p class="mt-2 text-sm text-gray-600">La explicación profunda queda oculta por defecto para mantener el dashboard limpio.</p>
</div>
</div>
</details>
</div>
</x-filament::section>
</x-filament-widgets::widget>

View File

@ -4,6 +4,10 @@
@section('meta_description', 'Demo TAXILANZ en Laravel: el taxi funciona como punto de entrada para activar 23 propuestas relevantes y convertirlas en reservas medibles.')
@section('content')
@php
$primaryOffer = $featuredOffers->first();
$secondaryOffer = $featuredOffers->skip(1)->first();
@endphp
<section class="hero">
<article class="card hero-copy">
<div>
@ -34,32 +38,37 @@
</span>
</div>
<span class="screen-badge">Hoy en Lanzarote</span>
<h2 class="screen-title">Pedir taxi</h2>
<p class="screen-copy">La app entra por movilidad y enseguida abre una siguiente decisión útil, simple y medible.</p>
<span class="screen-badge">Storyboard activo</span>
<h2 class="screen-title">4 pantallas, 1 historia</h2>
<p class="screen-copy">La demo ahora se entiende como producto móvil real: pedir taxi, esperar, decidir una propuesta y cerrar la reserva.</p>
<div class="route-card">
<small>Trayecto sugerido</small>
<small>Punto de entrada</small>
<strong>Aeropuerto César Manrique Puerto del Carmen</strong>
<span>Contexto suficiente para activar sugerencias relevantes sin parecer marketplace.</span>
<span>Primero movilidad. Después contexto. Y solo al final, conversión atribuible.</span>
</div>
<div class="screen-kpis">
<div class="screen-kpi"><strong>{{ $metrics['rides'] }}</strong><span>rides</span></div>
<div class="screen-kpi"><strong>{{ $metrics['clicks'] }}</strong><span>clics</span></div>
<div class="screen-kpi"><strong>{{ $metrics['bookings'] }}</strong><span>bookings</span></div>
<div class="screen-kpi"><strong>01</strong><span>taxi</span></div>
<div class="screen-kpi"><strong>02</strong><span>contexto</span></div>
<div class="screen-kpi"><strong>04</strong><span>booking</span></div>
</div>
<div class="phone-list">
@foreach ($featuredOffers->take(2) as $offer)
<div class="phone-list-item">
<div class="phone-list-row">
<strong>{{ $offer->title }}</strong>
<span>{{ $loop->first ? 'Top now' : 'Next' }}</span>
</div>
<small>{{ $offer->location_label ?: 'Zona activa' }}</small>
<div class="phone-list-item">
<div class="phone-list-row">
<strong>Pedir taxi</strong>
<span>start</span>
</div>
@endforeach
<small>Necesidad inmediata</small>
</div>
<div class="phone-list-item">
<div class="phone-list-row">
<strong>{{ $primaryOffer?->title ?: 'Oferta contextual' }}</strong>
<span>next</span>
</div>
<small>{{ $primaryOffer?->location_label ?: 'Zona activa' }}</small>
</div>
</div>
<a class="btn" href="#request">Activar demo</a>
@ -67,6 +76,96 @@
</aside>
</section>
<section class="section">
<div class="section-head">
<div>
<span class="eyebrow">4-screen mobile storyboard</span>
<h2>La demo se lee en 20 segundos</h2>
</div>
<p>Este bloque baja la abstracción: enseña exactamente qué ve el turista y por qué eso termina en ingreso atribuible.</p>
</div>
<div class="storyboard-grid">
<article class="card storyboard-card">
<span class="eyebrow">Pantalla 1</span>
<div class="storyboard-phone">
<span class="storyboard-step">Taxi first</span>
<h3 style="margin:0;">Pedir taxi</h3>
<p class="storyboard-caption">Acción principal clarísima, pocos campos y cero fricción para entrar en el funnel.</p>
<div class="storyboard-mini-list">
<div class="storyboard-mini-item">
<strong>Aeropuerto hotel</strong>
<span>Origen y destino bastan para empezar.</span>
</div>
<div class="storyboard-mini-item">
<strong>Canal hotel / app</strong>
<span>También deja señal de origen para ventas.</span>
</div>
</div>
</div>
</article>
<article class="card storyboard-card">
<span class="eyebrow">Pantalla 2</span>
<div class="storyboard-phone">
<span class="storyboard-step is-warm">Momento exacto</span>
<h3 style="margin:0;">Taxi confirmado</h3>
<p class="storyboard-caption">Con ETA visible aparece la ventana de atención correcta: no catálogo, solo 23 decisiones útiles.</p>
<div class="storyboard-mini-list">
<div class="storyboard-mini-item">
<strong>{{ $metrics['views'] }} vistas</strong>
<span>La pantalla ya activa interés medible.</span>
</div>
<div class="storyboard-mini-item">
<strong>{{ $metrics['clicks'] }} clics</strong>
<span>Intención real mientras el taxi llega.</span>
</div>
</div>
</div>
</article>
<article class="card storyboard-card">
<span class="eyebrow">Pantalla 3</span>
<div class="storyboard-phone">
<span class="storyboard-step">Oferta contextual</span>
<h3 style="margin:0;">Propuesta relevante</h3>
<p class="storyboard-caption">La mejor oferta se presenta como continuidad natural del trayecto, no como un escaparate infinito.</p>
<div class="storyboard-mini-list">
<div class="storyboard-mini-item">
<strong>{{ $primaryOffer?->title ?: 'Oferta destacada' }}</strong>
<span>{{ $primaryOffer?->location_label ?: 'Zona activa' }} · {{ $primaryOffer?->price_from ? 'Desde €'.number_format((float) $primaryOffer->price_from, 0) : 'Ticket consultable' }}</span>
</div>
@if($secondaryOffer)
<div class="storyboard-mini-item">
<strong>{{ $secondaryOffer->title }}</strong>
<span>{{ $secondaryOffer->duration_minutes ? $secondaryOffer->duration_minutes.' min' : 'Plan rápido' }} · alternativa secundaria</span>
</div>
@endif
</div>
</div>
</article>
<article class="card storyboard-card">
<span class="eyebrow">Pantalla 4</span>
<div class="storyboard-phone">
<span class="storyboard-step is-success">Conversión</span>
<h3 style="margin:0;">Reserva y atribución</h3>
<p class="storyboard-caption">La historia termina con dinero visible: booking, GMV demo y comisión estimada en el panel.</p>
<div class="storyboard-mini-list">
<div class="storyboard-mini-item">
<strong>{{ $metrics['bookings'] }} reservas</strong>
<span>{{ $metrics['ride_to_booking_rate'] }}% ride booking</span>
</div>
<div class="storyboard-mini-item">
<strong>{{ number_format($metrics['commission'], 0) }}</strong>
<span>Comisión estimada ya presentable en admin.</span>
</div>
</div>
</div>
</article>
</div>
</section>
<section class="section">
<div class="proof-grid">
<article class="card proof-card">
@ -148,11 +247,23 @@
<aside id="how" class="card">
<span class="eyebrow">Qué demuestra esta demo</span>
<h2>Flujo MVP real</h2>
<div class="list">
<div class="list-item"><strong>1.</strong> Se guarda el ride con contexto, ETA y canal. <p>Eso ya deja una señal útil para operación y atribución.</p></div>
<div class="list-item"><strong>2.</strong> Un motor simple puntúa ofertas publicadas. <p>No busca mostrarlo todo; solo lo que mejor encaja ahora.</p></div>
<div class="list-item"><strong>3.</strong> La pantalla de taxi confirmado genera vistas y clics medibles. <p>Ahí empieza el funnel de impacto.</p></div>
<div class="list-item"><strong>4.</strong> La reserva cierra el circuito con conversión trazable. <p>Es la historia mínima que necesita un demo killer.</p></div>
<div class="storyboard-progress">
<div class="storyboard-progress-step is-done">
<strong>1 · Taxi</strong>
<span>Entrada simple que captura contexto y canal.</span>
</div>
<div class="storyboard-progress-step is-active">
<strong>2 · Espera útil</strong>
<span>La ETA abre un micro-momento de atención.</span>
</div>
<div class="storyboard-progress-step">
<strong>3 · Oferta</strong>
<span>Solo la propuesta con más probabilidad de cierre.</span>
</div>
<div class="storyboard-progress-step">
<strong>4 · Booking</strong>
<span>Conversión rastreable hasta comisión.</span>
</div>
</div>
</aside>
</section>

View File

@ -282,6 +282,122 @@
min-height: 100%;
}
.storyboard-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.storyboard-card {
display: grid;
gap: 14px;
align-content: start;
}
.storyboard-phone {
display: grid;
gap: 12px;
min-height: 100%;
padding: 16px;
border-radius: 24px;
border: 1px solid rgba(15, 23, 42, 0.08);
background:
radial-gradient(circle at top right, rgba(245, 158, 11, 0.18), transparent 26%),
linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 249, 245, 0.95));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.storyboard-step {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
padding: 7px 11px;
border-radius: 999px;
background: rgba(15, 118, 110, 0.10);
color: var(--accent-strong);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.storyboard-step.is-warm {
background: rgba(245, 158, 11, 0.14);
color: #9a6700;
}
.storyboard-step.is-success {
background: rgba(34, 197, 94, 0.12);
color: #166534;
}
.storyboard-caption {
margin: 0;
color: var(--muted);
font-size: 14px;
line-height: 1.65;
}
.storyboard-mini-list {
display: grid;
gap: 10px;
}
.storyboard-mini-item {
display: grid;
gap: 4px;
padding: 12px 13px;
border-radius: 16px;
border: 1px solid rgba(15, 23, 42, 0.07);
background: rgba(255, 255, 255, 0.86);
}
.storyboard-mini-item strong {
font-size: 14px;
}
.storyboard-mini-item span {
color: var(--muted);
font-size: 12px;
}
.storyboard-progress {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 22px;
}
.storyboard-progress-step {
display: grid;
gap: 4px;
padding: 13px 14px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.72);
}
.storyboard-progress-step strong {
font-size: 13px;
}
.storyboard-progress-step span {
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.storyboard-progress-step.is-done {
border-color: rgba(15, 118, 110, 0.16);
background: rgba(15, 118, 110, 0.08);
}
.storyboard-progress-step.is-active {
border-color: rgba(245, 158, 11, 0.22);
background: rgba(245, 158, 11, 0.10);
}
.phone-shell {
position: relative;
@ -726,6 +842,290 @@
color: var(--accent-strong);
}
.hero-copy--compact {
gap: 18px;
}
.compact-lead {
max-width: 34ch;
color: var(--text);
font-size: 1rem;
line-height: 1.55;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.meta-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.78);
color: var(--text);
font-size: 13px;
font-weight: 700;
}
.story-rail {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.story-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.06);
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
}
.story-chip.is-done {
background: rgba(15, 118, 110, 0.10);
color: var(--accent-strong);
}
.story-chip.is-active {
background: rgba(245, 158, 11, 0.14);
color: #9a6700;
}
.decision-card,
.decision-primary {
display: grid;
gap: 14px;
}
.decision-primary {
padding: 18px;
border-radius: 24px;
border: 1px solid rgba(15, 23, 42, 0.07);
background: rgba(255, 255, 255, 0.9);
}
.decision-primary--accent {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(240, 253, 250, 0.92));
border-color: rgba(15, 118, 110, 0.14);
}
.decision-title {
margin: 0;
font-size: clamp(1.8rem, 4vw, 2.7rem);
line-height: 0.98;
letter-spacing: -0.05em;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.inline-actions .btn {
min-width: 190px;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 48px;
padding: 12px 15px;
border: 1px solid rgba(15, 23, 42, 0.10);
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
color: var(--text);
cursor: pointer;
font: inherit;
font-weight: 700;
position: relative;
}
.icon-button:hover {
background: #ffffff;
transform: translateY(-1px);
}
.icon-button[data-tooltip]:hover::after,
.icon-button[data-tooltip]:focus-visible::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%);
padding: 8px 10px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.94);
color: #fff;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.18);
}
.disclosure {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
background: rgba(255, 255, 255, 0.76);
overflow: hidden;
}
.disclosure--soft {
background: rgba(15, 23, 42, 0.03);
}
.disclosure summary {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
cursor: pointer;
color: var(--text);
font-size: 14px;
font-weight: 800;
}
.disclosure summary::-webkit-details-marker {
display: none;
}
.disclosure[open] summary {
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.disclosure-body {
display: grid;
gap: 10px;
padding: 14px 16px 16px;
}
.accordion-list {
margin: 0;
padding-left: 18px;
color: var(--muted);
display: grid;
gap: 8px;
}
.accordion-list strong {
color: var(--text);
}
.phone-screen--focused {
gap: 14px;
}
.recommendation-focus {
display: grid;
gap: 12px;
padding: 16px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(15, 118, 110, 0.14);
box-shadow: 0 18px 40px rgba(15, 118, 110, 0.10);
}
.recommendation-focus .btn {
width: 100%;
}
.metric-strip {
display: grid;
gap: 12px;
}
.metric-strip--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-strip--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-cell {
display: grid;
gap: 4px;
padding: 14px;
border-radius: 18px;
background: rgba(15, 23, 42, 0.04);
border: 1px solid rgba(15, 23, 42, 0.06);
}
.metric-cell strong {
font-size: 1.2rem;
letter-spacing: -0.04em;
}
.metric-cell span {
color: var(--muted);
font-size: 12px;
}
.section-head--compact {
margin-bottom: 14px;
}
.cards--compact {
gap: 16px;
}
.offer-card--compact {
gap: 14px;
}
.app-modal {
width: min(560px, calc(100% - 24px));
border: 0;
border-radius: 26px;
padding: 0;
background: var(--surface-strong);
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.24);
}
.app-modal::backdrop {
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(4px);
}
.app-modal-card {
display: grid;
gap: 16px;
padding: 24px;
}
.app-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.app-modal-close {
border: 0;
border-radius: 14px;
background: rgba(15, 23, 42, 0.08);
color: var(--text);
padding: 10px 12px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.footer-note {
margin-top: 24px;
padding-top: 20px;
@ -742,21 +1142,309 @@
.hero,
.split,
.cards,
.proof-grid {
.proof-grid,
.storyboard-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.topbar-inner,
.footer-note {
.hero-copy--compact {
gap: 18px;
}
.compact-lead {
max-width: 34ch;
color: var(--text);
font-size: 1rem;
line-height: 1.55;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.meta-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.78);
color: var(--text);
font-size: 13px;
font-weight: 700;
}
.story-rail {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.story-chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 12px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.06);
color: var(--muted);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.02em;
}
.story-chip.is-done {
background: rgba(15, 118, 110, 0.10);
color: var(--accent-strong);
}
.story-chip.is-active {
background: rgba(245, 158, 11, 0.14);
color: #9a6700;
}
.decision-card,
.decision-primary {
display: grid;
gap: 14px;
}
.decision-primary {
padding: 18px;
border-radius: 24px;
border: 1px solid rgba(15, 23, 42, 0.07);
background: rgba(255, 255, 255, 0.9);
}
.decision-primary--accent {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(240, 253, 250, 0.92));
border-color: rgba(15, 118, 110, 0.14);
}
.decision-title {
margin: 0;
font-size: clamp(1.8rem, 4vw, 2.7rem);
line-height: 0.98;
letter-spacing: -0.05em;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.inline-actions .btn {
min-width: 190px;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 48px;
padding: 12px 15px;
border: 1px solid rgba(15, 23, 42, 0.10);
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
color: var(--text);
cursor: pointer;
font: inherit;
font-weight: 700;
position: relative;
}
.icon-button:hover {
background: #ffffff;
transform: translateY(-1px);
}
.icon-button[data-tooltip]:hover::after,
.icon-button[data-tooltip]:focus-visible::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 8px);
transform: translateX(-50%);
padding: 8px 10px;
border-radius: 12px;
background: rgba(15, 23, 42, 0.94);
color: #fff;
white-space: nowrap;
font-size: 12px;
font-weight: 700;
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.18);
}
.disclosure {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 20px;
background: rgba(255, 255, 255, 0.76);
overflow: hidden;
}
.disclosure--soft {
background: rgba(15, 23, 42, 0.03);
}
.disclosure summary {
list-style: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
cursor: pointer;
color: var(--text);
font-size: 14px;
font-weight: 800;
}
.disclosure summary::-webkit-details-marker {
display: none;
}
.disclosure[open] summary {
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.disclosure-body {
display: grid;
gap: 10px;
padding: 14px 16px 16px;
}
.accordion-list {
margin: 0;
padding-left: 18px;
color: var(--muted);
display: grid;
gap: 8px;
}
.accordion-list strong {
color: var(--text);
}
.phone-screen--focused {
gap: 14px;
}
.recommendation-focus {
display: grid;
gap: 12px;
padding: 16px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.98);
border: 1px solid rgba(15, 118, 110, 0.14);
box-shadow: 0 18px 40px rgba(15, 118, 110, 0.10);
}
.recommendation-focus .btn {
width: 100%;
}
.metric-strip {
display: grid;
gap: 12px;
}
.metric-strip--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-strip--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.metric-cell {
display: grid;
gap: 4px;
padding: 14px;
border-radius: 18px;
background: rgba(15, 23, 42, 0.04);
border: 1px solid rgba(15, 23, 42, 0.06);
}
.metric-cell strong {
font-size: 1.2rem;
letter-spacing: -0.04em;
}
.metric-cell span {
color: var(--muted);
font-size: 12px;
}
.section-head--compact {
margin-bottom: 14px;
}
.cards--compact {
gap: 16px;
}
.offer-card--compact {
gap: 14px;
}
.app-modal {
width: min(560px, calc(100% - 24px));
border: 0;
border-radius: 26px;
padding: 0;
background: var(--surface-strong);
box-shadow: 0 30px 80px rgba(15, 23, 42, 0.24);
}
.app-modal::backdrop {
background: rgba(15, 23, 42, 0.42);
backdrop-filter: blur(4px);
}
.app-modal-card {
display: grid;
gap: 16px;
padding: 24px;
}
.app-modal-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.app-modal-close {
border: 0;
border-radius: 14px;
background: rgba(15, 23, 42, 0.08);
color: var(--text);
padding: 10px 12px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.footer-note {
flex-direction: column;
align-items: flex-start;
}
.stats,
.grid-2,
.screen-kpis {
.screen-kpis,
.storyboard-progress,
.metric-strip--2,
.metric-strip--3 {
grid-template-columns: 1fr;
}
@ -768,11 +1456,22 @@
.phone-topbar,
.phone-list-row,
.phone-action-top,
.section-head {
.section-head,
.app-modal-head {
align-items: flex-start;
flex-direction: column;
}
.inline-actions,
.inline-actions--stack-mobile {
flex-direction: column;
}
.inline-actions .btn,
.icon-button {
width: 100%;
}
main { padding-top: 28px; }
.card { padding: 22px; }
.nav-links { gap: 12px; }
@ -809,5 +1508,43 @@
</footer>
</div>
</main>
<script>
document.addEventListener('click', function (event) {
const openButton = event.target.closest('[data-modal-open]');
if (openButton) {
const targetId = openButton.getAttribute('data-modal-open');
const dialog = document.getElementById(targetId);
if (dialog && typeof dialog.showModal === 'function') {
dialog.showModal();
}
return;
}
const closeButton = event.target.closest('[data-modal-close]');
if (closeButton) {
const dialog = closeButton.closest('dialog');
if (dialog) {
dialog.close();
}
}
});
document.addEventListener('click', function (event) {
const dialog = event.target;
if (!(dialog instanceof HTMLDialogElement) || !dialog.classList.contains('app-modal')) {
return;
}
const rect = dialog.getBoundingClientRect();
const inside = rect.top <= event.clientY && event.clientY <= rect.bottom
&& rect.left <= event.clientX && event.clientX <= rect.right;
if (!inside) {
dialog.close();
}
});
</script>
</body>
</html>

View File

@ -14,7 +14,14 @@
@endphp
<section class="split">
<article class="card offer-card">
<div class="phone-screen" style="padding:18px;">
<div class="story-rail" aria-label="Progreso del funnel">
<span class="story-chip is-done">1 · Taxi</span>
<span class="story-chip is-done">2 · Contexto</span>
<span class="story-chip is-active">3 · Oferta</span>
<span class="story-chip">4 · Reserva</span>
</div>
<div class="phone-screen phone-screen--focused" style="padding:18px;">
<div class="phone-topbar">
<span>Propuesta contextual</span>
<span class="phone-dot-group" aria-hidden="true">
@ -26,59 +33,54 @@
<div class="offer-visual" aria-hidden="true"></div>
<div>
<span class="screen-badge">4 · Propuesta contextual</span>
<h1 style="font-size:clamp(2.15rem,4vw,3.6rem);max-width:14ch;margin-top:14px;">{{ $offer->title }}</h1>
<p>
{{ $offer->description ?: $offer->excerpt }}
Esta pantalla debe sentirse como una continuación natural del trayecto: útil, cercana y rápida de confirmar.
</p>
</div>
<div class="offer-meta">
<span class="pill">{{ $categoryLabels[$offer->category] ?? ucfirst($offer->category) }}</span>
@if($offer->location_label)<span class="pill">{{ $offer->location_label }}</span>@endif
@if($offer->price_from)<span class="pill">Desde {{ number_format((float) $offer->price_from, 0) }}</span>@endif
@if($offer->duration_minutes)<span class="pill">{{ $offer->duration_minutes }} min</span>@endif
@if($offer->available_now)<span class="pill">Disponible hoy</span>@endif
</div>
@if($recommendation)
<div class="notice">
<strong>Por qué aparece ahora:</strong>
esta propuesta viene del trayecto hacia <strong>{{ $ride?->destination_label ?? ($offer->location_label ?: 'tu zona') }}</strong>
y quedó posicionada como recomendación #{{ $recommendation->position }} por su cercanía y facilidad de cierre.
<div class="decision-primary">
<span class="screen-badge">Detalle oferta</span>
<h1 style="font-size:clamp(2.15rem,4vw,3.6rem);max-width:13ch;margin-top:14px;">{{ $offer->title }}</h1>
<div class="offer-meta">
<span class="pill">{{ $categoryLabels[$offer->category] ?? ucfirst($offer->category) }}</span>
@if($offer->location_label)<span class="pill">{{ $offer->location_label }}</span>@endif
@if($offer->price_from)<span class="pill">Desde {{ number_format((float) $offer->price_from, 0) }}</span>@endif
@if($offer->duration_minutes)<span class="pill">{{ $offer->duration_minutes }} min</span>@endif
@if($offer->available_now)<span class="pill">Disponible hoy</span>@endif
</div>
<div class="inline-actions">
<a class="btn" href="#reserve">Reservar ahora</a>
@if($recommendation)
<button type="button" class="icon-button" data-modal-open="offer-signal" data-tooltip="Señal completa">Ver señal</button>
@endif
</div>
</div>
@if($offer->excerpt)
<p class="screen-copy">{{ $offer->excerpt }}</p>
@endif
<div class="screen-kpis">
<div class="screen-kpi">
<strong>{{ $offer->location_label ?: 'Zona activa' }}</strong>
<span>cerca del destino</span>
</div>
<div class="screen-kpi">
<strong>{{ $offer->duration_minutes ? $offer->duration_minutes.' min' : 'Flexible' }}</strong>
<span>decisión simple</span>
</div>
<div class="screen-kpi">
<strong>{{ $offer->price_from ? '€'.number_format((float) $offer->price_from, 0) : 'Consultar' }}</strong>
<span>ticket trazable</span>
</div>
</div>
@if($recommendation || $ride)
<details class="disclosure disclosure--soft">
<summary>
<span> ¿Por qué aparece ahora?</span>
<span>Abrir</span>
</summary>
<div class="disclosure-body">
<ul class="accordion-list">
@if($ride)
<li>Llega después de un trayecto hacia <strong>{{ $ride->destination_label }}</strong>.</li>
@endif
@if($offer->location_label)
<li>Está alineada con la zona <strong>{{ $offer->location_label }}</strong>.</li>
@endif
<li>{{ $recommendation?->reason ?: 'Se prioriza por proximidad, timing y facilidad de decisión.' }}</li>
</ul>
</div>
</details>
@endif
</div>
</article>
<aside class="card form-card">
<span class="eyebrow">5 · Reserva simple</span>
<h2>Confirma en menos de un minuto</h2>
<p>
Sin registro largo y sin checkout complejo. Justo lo necesario para que la demo enseñe conversión real,
no solo intención.
</p>
<div class="form-note">
Este bloque funciona como checkout móvil mínimo: datos esenciales, intención clara y atribución al trayecto.
</div>
<aside class="card form-card" id="reserve">
<span class="eyebrow">Reserva</span>
<h2>Confirma y sigue.</h2>
<p class="compact-lead">Solo pedimos lo necesario para cerrar la decisión.</p>
@if ($errors->any())
<div class="errors">
@ -90,14 +92,16 @@
</div>
@endif
<form method="post" action="{{ route('bookings.store', $offer) }}" style="margin-top:18px;">
<form method="post" action="{{ route('bookings.store', $offer) }}" style="margin-top:10px;">
@csrf
<input type="hidden" name="ride_id" value="{{ $ride?->id }}">
<input type="hidden" name="ride_recommendation_id" value="{{ $recommendation?->id }}">
<label>
<span class="field-label">Nombre</span>
<input type="text" name="customer_name" value="{{ old('customer_name', 'Alex Morgan') }}" required>
</label>
<div class="grid-2">
<label>
<span class="field-label">Email</span>
@ -108,27 +112,78 @@
<input type="text" name="customer_phone" value="{{ old('customer_phone', '+34 600 123 456') }}">
</label>
</div>
<div class="grid-2">
<label>
<span class="field-label">Personas</span>
<input type="number" name="party_size" value="{{ old('party_size', 2) }}" min="1" max="12" required>
<select name="party_size">
@for ($i = 1; $i <= 8; $i++)
<option value="{{ $i }}" @selected((int) old('party_size', 2) === $i)>{{ $i }}</option>
@endfor
</select>
</label>
<label>
<span class="field-label">Fecha / hora</span>
<input type="datetime-local" name="booking_for" value="{{ old('booking_for') }}">
</label>
</div>
<label>
<span class="field-label">Nota</span>
<textarea name="notes">{{ old('notes', 'Mesa tranquila si está disponible.') }}</textarea>
</label>
<button class="btn" type="submit">Confirmar reserva</button>
</form>
<div class="notice">
<strong>Lectura demo:</strong>
al confirmar, el panel puede atribuir esta reserva al trayecto y a la recomendación que la activó.
</div>
<details class="disclosure" style="margin-top:8px;">
<summary>
<span> Ver detalle de atribución</span>
<span>Abrir</span>
</summary>
<div class="disclosure-body">
<ul class="accordion-list">
<li>La reserva se vincula al trayecto si existe <code>ride_id</code>.</li>
<li>Si vienes desde una recomendación, también queda guardada su posición.</li>
<li>Eso permite enseñar conversión y comisión en admin sin ruido en la UI principal.</li>
</ul>
</div>
</details>
</aside>
</section>
@if($recommendation)
<dialog class="app-modal" id="offer-signal" aria-labelledby="offer-signal-title">
<div class="app-modal-card">
<div class="app-modal-head">
<div>
<p class="eyebrow">Señal completa</p>
<h2 id="offer-signal-title">Lectura contextual</h2>
</div>
<button type="button" class="app-modal-close" data-modal-close aria-label="Cerrar modal">Cerrar</button>
</div>
<div class="metric-strip metric-strip--3">
<div class="metric-cell">
<strong>Top {{ $recommendation->position }}</strong>
<span>posición</span>
</div>
<div class="metric-cell">
<strong>{{ $ride?->eta_minutes ?? '—' }}</strong>
<span>ETA del taxi</span>
</div>
<div class="metric-cell">
<strong>{{ $ride?->context_zone ?: ($offer->location_label ?: 'General') }}</strong>
<span>zona activa</span>
</div>
</div>
<ul class="accordion-list">
<li>{{ $recommendation->reason ?: 'La propuesta aparece por contexto, proximidad y capacidad de cierre.' }}</li>
@if($ride)
<li>Trayecto vinculado: <strong>{{ $ride->pickup_label }}</strong> <strong>{{ $ride->destination_label }}</strong>.</li>
@endif
<li>Oferta con ticket visible y reserva rápida.</li>
</ul>
</div>
</dialog>
@endif
@endsection

View File

@ -1,44 +1,107 @@
@extends('layouts.app')
@section('title', 'Taxi confirmado | TAXILANZ Demo')
@section('meta_description', 'Pantalla de taxi confirmado con recomendaciones contextuales y tracking de visualización.')
@section('meta_description', 'Pantalla de taxi confirmado con recomendaciones contextuales y decisiones rápidas en TAXILANZ Demo.')
@section('content')
@php
$primaryRecommendation = $recommendations->first();
$secondaryRecommendations = $recommendations->skip(1);
@endphp
<section class="hero">
<article class="card hero-copy">
<article class="card hero-copy hero-copy--compact">
<div>
<span class="eyebrow">2 · Taxi confirmado</span>
<span class="eyebrow">Taxi confirmado</span>
<h1>Tu taxi llega en {{ $ride->eta_minutes ?? 6 }} min.</h1>
<p>
Perfecto. Mientras llega, TAXILANZ no te enseña un catálogo infinito: te propone solo opciones que
encajan con este trayecto, cerca de tu destino y fáciles de decidir ahora mismo.
</p>
<p class="compact-lead">Decide una sola cosa más, si te interesa. El resto queda fuera.</p>
</div>
<div class="stats">
<div class="stat">
<strong>{{ $ride->eta_minutes ?? 6 }} min</strong>
<span>ventana de atención antes de subir</span>
</div>
<div class="stat">
<strong>{{ $recommendations->count() }}</strong>
<span>opciones priorizadas, no ruido</span>
</div>
<div class="stat">
<strong>1 toque</strong>
<span>para pasar del trayecto a la reserva</span>
</div>
<div class="meta-row">
<span class="meta-pill">{{ $ride->pickup_label }}</span>
<span class="meta-pill">{{ $ride->destination_label }}</span>
<span class="meta-pill">{{ ucfirst($ride->source_channel) }}</span>
<span class="meta-pill">{{ $ride->context_zone ?: 'Zona activa' }}</span>
</div>
<div class="list" style="margin-top:0;">
<div class="list-item"><strong>Recogida:</strong> {{ $ride->pickup_label }}</div>
<div class="list-item"><strong>Destino:</strong> {{ $ride->destination_label }}</div>
<div class="list-item"><strong>Canal:</strong> {{ ucfirst($ride->source_channel) }} · <strong>Zona:</strong> {{ $ride->context_zone ?: 'General' }}</div>
<div class="story-rail" aria-label="Progreso del funnel">
<span class="story-chip is-done">1 · Taxi</span>
<span class="story-chip is-active">2 · Confirmado</span>
<span class="story-chip">3 · Oferta</span>
<span class="story-chip">4 · Reserva</span>
</div>
@if ($primaryRecommendation)
<div class="decision-card">
<div class="decision-primary decision-primary--accent">
<span class="screen-badge">Siguiente mejor opción</span>
<h2 class="decision-title">{{ $primaryRecommendation->offer->title }}</h2>
<div class="offer-meta">
@if($primaryRecommendation->offer->location_label)<span class="pill">{{ $primaryRecommendation->offer->location_label }}</span>@endif
@if($primaryRecommendation->offer->price_from)<span class="pill">{{ number_format((float) $primaryRecommendation->offer->price_from, 0) }}</span>@endif
@if($primaryRecommendation->offer->duration_minutes)<span class="pill">{{ $primaryRecommendation->offer->duration_minutes }} min</span>@endif
<span class="pill">Disponible ahora</span>
</div>
<div class="inline-actions">
<a class="btn" href="{{ route('offers.show', $primaryRecommendation->offer->slug) }}?ride={{ $ride->id }}&recommendation={{ $primaryRecommendation->id }}">Ver y reservar</a>
<button type="button" class="icon-button" data-modal-open="signal-primary" data-tooltip="Señal completa">Ver señal</button>
</div>
</div>
<details class="disclosure">
<summary>
<span> ¿Por qué esta opción?</span>
<span>Abrir</span>
</summary>
<div class="disclosure-body">
<ul class="accordion-list">
<li>Encaja con tu destino: <strong>{{ $ride->destination_label }}</strong>.</li>
@if($primaryRecommendation->offer->location_label)
<li>Está cerca de <strong>{{ $primaryRecommendation->offer->location_label }}</strong> y se puede decidir rápido.</li>
@endif
@if($primaryRecommendation->reason)
<li>{{ $primaryRecommendation->reason }}</li>
@else
<li>La priorizamos por cercanía, disponibilidad y facilidad de cierre.</li>
@endif
</ul>
</div>
</details>
</div>
<dialog class="app-modal" id="signal-primary" aria-labelledby="signal-primary-title">
<div class="app-modal-card">
<div class="app-modal-head">
<div>
<p class="eyebrow">Señal completa</p>
<h2 id="signal-primary-title">Por qué {{ $primaryRecommendation->offer->title }} sube arriba</h2>
</div>
<button type="button" class="app-modal-close" data-modal-close aria-label="Cerrar modal">Cerrar</button>
</div>
<div class="metric-strip metric-strip--2">
<div class="metric-cell">
<strong>Top {{ $primaryRecommendation->position }}</strong>
<span>posición actual</span>
</div>
<div class="metric-cell">
<strong>{{ $ride->eta_minutes ?? 6 }} min</strong>
<span>momento de atención</span>
</div>
</div>
<ul class="accordion-list">
<li>Trayecto activo: <strong>{{ $ride->pickup_label }}</strong> <strong>{{ $ride->destination_label }}</strong>.</li>
<li>Zona relevante: <strong>{{ $ride->context_zone ?: 'General' }}</strong>.</li>
<li>Canal de origen: <strong>{{ ucfirst($ride->source_channel) }}</strong>.</li>
<li>{{ $primaryRecommendation->reason ?: 'La propuesta destaca por contexto, proximidad y facilidad de reserva.' }}</li>
</ul>
</div>
</dialog>
@else
<div class="notice">Todavía no hay una propuesta activa para este trayecto.</div>
@endif
</article>
<aside class="phone-shell" aria-label="Vista móvil del taxi confirmado">
<div class="phone-screen">
<div class="phone-screen phone-screen--focused">
<div class="phone-topbar">
<span>Taxi confirmado</span>
<span class="phone-dot-group" aria-hidden="true">
@ -54,78 +117,78 @@
<span>ETA {{ $ride->eta_minutes ?? 6 }} min · {{ ucfirst($ride->source_channel) }} · {{ $ride->context_zone ?: 'Zona activa' }}</span>
</div>
<span class="screen-badge">Encaja con tu ruta</span>
<div class="phone-list">
@forelse ($recommendations->take(3) as $recommendation)
<a class="phone-action {{ $recommendation->position === 1 ? 'phone-action--accent' : '' }}" href="{{ route('offers.show', $recommendation->offer->slug) }}?ride={{ $ride->id }}&recommendation={{ $recommendation->id }}">
<div class="phone-action-top">
<strong class="phone-action-title">{{ $recommendation->offer->title }}</strong>
<span>Top {{ $recommendation->position }}</span>
</div>
<small>{{ $recommendation->position === 1 ? 'Parada rápida' : 'Plan al llegar' }}</small>
<div class="phone-action-meta">
@if($recommendation->offer->location_label)<span>{{ $recommendation->offer->location_label }}</span>@endif
@if($recommendation->offer->duration_minutes)<span>{{ $recommendation->offer->duration_minutes }} min</span>@endif
@if($recommendation->offer->price_from)<span>{{ number_format((float) $recommendation->offer->price_from, 0) }}</span>@endif
</div>
</a>
@empty
<div class="phone-empty">No hay recomendaciones activas todavía, pero el ride ya quedó listo para atribución.</div>
@endforelse
</div>
@if ($primaryRecommendation)
<article class="recommendation-focus">
<div class="phone-action-top">
<strong class="phone-action-title">{{ $primaryRecommendation->offer->title }}</strong>
<span>Top {{ $primaryRecommendation->position }}</span>
</div>
<small>Disponible ahora</small>
<div class="phone-action-meta">
@if($primaryRecommendation->offer->location_label)<span>{{ $primaryRecommendation->offer->location_label }}</span>@endif
@if($primaryRecommendation->offer->price_from)<span>{{ number_format((float) $primaryRecommendation->offer->price_from, 0) }}</span>@endif
@if($primaryRecommendation->offer->duration_minutes)<span>{{ $primaryRecommendation->offer->duration_minutes }} min</span>@endif
</div>
<a class="btn" href="{{ route('offers.show', $primaryRecommendation->offer->slug) }}?ride={{ $ride->id }}&recommendation={{ $primaryRecommendation->id }}">Ver y reservar</a>
</article>
@endif
@if ($secondaryRecommendations->isNotEmpty())
<div class="phone-list">
@foreach ($secondaryRecommendations as $recommendation)
<a class="phone-action" href="{{ route('offers.show', $recommendation->offer->slug) }}?ride={{ $ride->id }}&recommendation={{ $recommendation->id }}">
<div class="phone-action-top">
<strong class="phone-action-title">{{ $recommendation->offer->title }}</strong>
<span>Top {{ $recommendation->position }}</span>
</div>
<div class="phone-action-meta">
@if($recommendation->offer->location_label)<span>{{ $recommendation->offer->location_label }}</span>@endif
@if($recommendation->offer->price_from)<span>{{ number_format((float) $recommendation->offer->price_from, 0) }}</span>@endif
@if($recommendation->offer->duration_minutes)<span>{{ $recommendation->offer->duration_minutes }} min</span>@endif
</div>
</a>
@endforeach
</div>
@endif
</div>
</aside>
</section>
<section class="section">
<div class="section-head">
<div>
<span class="eyebrow">3 · Recomendaciones</span>
<h2>Encajan con tu ruta ahora</h2>
@if ($secondaryRecommendations->isNotEmpty())
<section class="section">
<div class="section-head section-head--compact">
<div>
<span class="eyebrow">Más opciones</span>
<h2>Solo si quieres comparar</h2>
</div>
</div>
<p>Primero relevancia. Después variedad. Siempre con contexto.</p>
</div>
@if ($recommendations->isEmpty())
<div class="notice">
No hay recomendaciones activas para este trayecto todavía. La solicitud quedó registrada y lista para atribución.
</div>
@else
<div class="cards">
@foreach ($recommendations as $recommendation)
<article class="card offer-card">
<div class="offer-visual" aria-hidden="true"></div>
<div class="cards cards--compact">
@foreach ($secondaryRecommendations as $recommendation)
<article class="card offer-card offer-card--compact">
<div>
<div class="offer-meta">
<span class="pill">Top {{ $recommendation->position }}</span>
<span class="pill">{{ $recommendation->position === 1 ? 'Parada rápida' : 'Plan al llegar' }}</span>
@if($recommendation->offer->available_now)
<span class="pill">Disponible hoy</span>
@endif
@if($recommendation->offer->location_label)
<span class="pill">{{ $recommendation->offer->location_label }}</span>
@endif
</div>
<h3 style="margin-top:14px;">{{ $recommendation->offer->title }}</h3>
<p>{{ $recommendation->offer->excerpt }}</p>
<div class="list" style="margin-top:14px;">
@if($recommendation->offer->duration_minutes)
<div class="list-item"><strong>Duración:</strong> {{ $recommendation->offer->duration_minutes }} min · pensada para decidir sin fricción</div>
@endif
@if($recommendation->offer->price_from)
<div class="list-item"><strong>Desde:</strong> {{ number_format((float) $recommendation->offer->price_from, 0) }} · ticket claro para mostrar impacto comercial</div>
@endif
</div>
<div class="notice" style="margin-top:14px;">
<strong>Por qué encaja:</strong> {{ $recommendation->reason }}
@if($recommendation->offer->location_label)<span class="pill">{{ $recommendation->offer->location_label }}</span>@endif
@if($recommendation->offer->price_from)<span class="pill">{{ number_format((float) $recommendation->offer->price_from, 0) }}</span>@endif
</div>
<h3>{{ $recommendation->offer->title }}</h3>
</div>
<a class="btn" href="{{ route('offers.show', $recommendation->offer->slug) }}?ride={{ $ride->id }}&recommendation={{ $recommendation->id }}">Ver detalle y reservar</a>
<div class="inline-actions inline-actions--stack-mobile">
<a class="btn btn-secondary" href="{{ route('offers.show', $recommendation->offer->slug) }}?ride={{ $ride->id }}&recommendation={{ $recommendation->id }}">Ver detalle</a>
</div>
<details class="disclosure disclosure--soft">
<summary>
<span> Ver motivo</span>
<span>Abrir</span>
</summary>
<div class="disclosure-body">
<p>{{ $recommendation->reason ?: 'Opción contextual cercana al destino y fácil de reservar.' }}</p>
</div>
</details>
</article>
@endforeach
</div>
@endif
</section>
</section>
@endif
@endsection