Auto commit: 2026-04-06T07:22:53.709Z
This commit is contained in:
parent
ffd5ebcc11
commit
ca311e99b4
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
48
app/Filament/Widgets/JourneyStoryboard.php
Normal file
48
app/Filament/Widgets/JourneyStoryboard.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
@ -4,6 +4,10 @@
|
||||
@section('meta_description', 'Demo TAXILANZ en Laravel: el taxi funciona como punto de entrada para activar 2–3 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 2–3 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 sí 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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user