Compare commits
15 Commits
pricing-lo
...
v0.1.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08afafe6bd | ||
|
|
685a84e159 | ||
|
|
4c0017cb91 | ||
|
|
347a71154b | ||
|
|
61adc133aa | ||
|
|
e7d19488fd | ||
|
|
fdcf253ca7 | ||
|
|
cf373a85f9 | ||
|
|
ea22387c9d | ||
|
|
040b2f627e | ||
|
|
5ca7232029 | ||
|
|
da0db012a0 | ||
|
|
5a6967dc01 | ||
|
|
257c09d178 | ||
|
|
f14006dc28 |
@@ -1 +0,0 @@
|
||||
../../.agents/skills/antfu
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/nuxt
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/pinia
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/pnpm
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/slidev
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/tsdown
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/turborepo
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/unocss
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vite
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vitepress
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vitest
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-best-practices
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-router-best-practices
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-testing-best-practices
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vueuse-functions
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/web-design-guidelines
|
||||
50
.github/workflows/lint.yml
vendored
@@ -1,50 +0,0 @@
|
||||
name: linter
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
- master
|
||||
- workos
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
- master
|
||||
- workos
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Testing
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.4'
|
||||
|
||||
- name: Add Flux Credentials Loaded From ENV
|
||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
npm install
|
||||
|
||||
- name: Run Pint
|
||||
run: composer lint
|
||||
|
||||
# - name: Commit Changes
|
||||
# uses: stefanzweifel/git-auto-commit-action@v7
|
||||
# with:
|
||||
# commit_message: fix code style
|
||||
# commit_options: '--no-verify'
|
||||
# file_pattern: |
|
||||
# **/*
|
||||
# !.github/workflows/*
|
||||
60
.github/workflows/tests.yml
vendored
@@ -1,60 +0,0 @@
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
- master
|
||||
- workos
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- main
|
||||
- master
|
||||
- workos
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
environment: Testing
|
||||
strategy:
|
||||
matrix:
|
||||
php-version: ['8.3', '8.4', '8.5']
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php-version }}
|
||||
tools: composer:v2
|
||||
coverage: xdebug
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Add Flux Credentials Loaded From ENV
|
||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
- name: Copy Environment File
|
||||
run: cp .env.example .env
|
||||
|
||||
- name: Generate Application Key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Build Assets
|
||||
run: npm run build
|
||||
|
||||
- name: Run Tests
|
||||
run: ./vendor/bin/pest
|
||||
1
.gitignore
vendored
@@ -24,4 +24,5 @@ yarn-error.log
|
||||
/.zed
|
||||
/.tmp/
|
||||
/.worktrees/
|
||||
/.deploy-last-commit
|
||||
/ONSPD_Online_Latest_Centroids_*.csv
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/antfu
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/nuxt
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/pinia
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/pnpm
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/slidev
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/tsdown
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/turborepo
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/unocss
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vite
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vitepress
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vitest
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-best-practices
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-router-best-practices
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vue-testing-best-practices
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/vueuse-functions
|
||||
@@ -1 +0,0 @@
|
||||
../../.agents/skills/web-design-guidelines
|
||||
@@ -1,138 +0,0 @@
|
||||
# SuperDesign Context Files for Fuel Price Application
|
||||
|
||||
This directory contains comprehensive design system documentation for the Fuel Price (Laravel Starter Kit) application.
|
||||
|
||||
## Generated Files
|
||||
|
||||
### 1. `init/components.md` (274 lines)
|
||||
**Shared UI Primitives & Components**
|
||||
|
||||
Lists all reusable Blade components (7 total) with full source code:
|
||||
- x-action-message: Temporary status messages
|
||||
- x-app-logo: Branding component
|
||||
- x-app-logo-icon: SVG logo icon
|
||||
- x-auth-header: Centered auth page header
|
||||
- x-auth-session-status: Session status display
|
||||
- x-desktop-user-menu: User profile dropdown
|
||||
- x-placeholder-pattern: Loading state pattern
|
||||
|
||||
Also documents Flux v2 components used throughout the application.
|
||||
|
||||
### 2. `init/layouts.md` (347 lines)
|
||||
**Complete Layout Hierarchy**
|
||||
|
||||
Full content of 7 layout files:
|
||||
- layouts/app.blade.php: Main app wrapper
|
||||
- layouts/app/sidebar.blade.php: Authenticated layout with header/sidebar
|
||||
- layouts/auth.blade.php: Auth wrapper
|
||||
- layouts/auth/simple.blade.php: Centered auth layout
|
||||
- layouts/auth/card.blade.php: Card-based auth layout
|
||||
- layouts/auth/split.blade.php: Split-screen auth layout
|
||||
- partials/head.blade.php: Shared head section
|
||||
|
||||
Includes usage patterns and color scheme documentation.
|
||||
|
||||
### 3. `init/routes.md` (120 lines)
|
||||
**Route Configuration & Summary**
|
||||
|
||||
Complete files:
|
||||
- routes/web.php: Public and authenticated routes
|
||||
- routes/settings.php: Settings page routes
|
||||
|
||||
Plus detailed route summary table covering:
|
||||
- 19 total routes (web + Fortify)
|
||||
- Route grouping by protection level
|
||||
- Livewire component routes mapping
|
||||
|
||||
### 4. `init/theme.md` (298 lines)
|
||||
**Design Tokens & Configuration**
|
||||
|
||||
Complete content:
|
||||
- resources/css/app.css: Tailwind imports and custom theme
|
||||
- Color palette: Zinc neutral scale + semantic colors
|
||||
- Typography: Instrument Sans font configuration
|
||||
- Spacing system: 4px base unit scale
|
||||
- Dark mode implementation
|
||||
- Vite build configuration
|
||||
- Flux theme setup
|
||||
|
||||
### 5. `init/pages.md` (291 lines)
|
||||
**Page Dependency Trees**
|
||||
|
||||
Full-page component documentation with dependency trees:
|
||||
1. StationSearch Livewire component (public)
|
||||
2. Dashboard
|
||||
3. Welcome/Home page
|
||||
4. Settings pages (Profile, Security, Appearance)
|
||||
5. Authentication pages (Login, Register, Password Reset, Email Verification, 2FA)
|
||||
|
||||
Includes data flow documentation and page transition patterns.
|
||||
|
||||
### 6. `init/extractable-components.md` (386 lines)
|
||||
**Reusable Component Analysis**
|
||||
|
||||
16 components identified by category:
|
||||
- 3 Form components (Search, Select, Loading Button)
|
||||
- 6 Data Display components (Card, Stats, Legend)
|
||||
- 2 Navigation components (Sidebar Nav, User Menu)
|
||||
- 3 Layout components (Auth layouts)
|
||||
- 2 Feedback components (Messages)
|
||||
- 1 Map/Visualization component
|
||||
- 2 Typography components
|
||||
|
||||
Includes reusability scoring table and extraction recommendations.
|
||||
|
||||
### 7. `init/design-system.md` (494 lines)
|
||||
**Complete Design System**
|
||||
|
||||
Comprehensive design documentation:
|
||||
- Brand identity & philosophy
|
||||
- Color system: Zinc palette + semantic colors
|
||||
- Typography: Typeface, hierarchy, weights
|
||||
- Spacing & layout: Grid system, breakpoints
|
||||
- Borders & radius: Border weight, color, radius scales
|
||||
- Component patterns: Forms, Buttons, Cards, Navigation
|
||||
- Data visualization: Map colors, marker styles
|
||||
- Animations & transitions: Page transitions, component effects
|
||||
- Accessibility: Contrast, interactive elements, typography
|
||||
- Dark mode: Colors, images, implementation
|
||||
- Responsive behavior: Breakpoints, layout adjustments
|
||||
|
||||
## Project Stack
|
||||
|
||||
- **Framework:** Laravel 11
|
||||
- **UI Library:** Livewire 3 (classic components)
|
||||
- **Styling:** Tailwind CSS v4
|
||||
- **UI Components:** Flux UI v2
|
||||
- **JavaScript:** Alpine.js
|
||||
- **Font:** Instrument Sans (400, 500, 600 weights)
|
||||
- **Map Library:** Leaflet
|
||||
- **Map Tiles:** OpenStreetMap
|
||||
|
||||
## Key Features
|
||||
|
||||
- Dark-mode-first design with light mode support
|
||||
- Responsive mobile-first architecture
|
||||
- Comprehensive authentication UI (Fortify-based)
|
||||
- Interactive fuel station search with Leaflet maps
|
||||
- Settings/profile management
|
||||
- Accessibility-focused component design
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary
|
||||
- Zinc neutral scale (50-950)
|
||||
|
||||
### Semantic
|
||||
- Success: Green-500 (#22c55e)
|
||||
- Warning: Amber-500 (#f59e0b)
|
||||
- Error: Red-500 (#ef4444)
|
||||
- Info: Slate-500 (#64748b)
|
||||
|
||||
## File Organization
|
||||
|
||||
All context files are in `init/` subdirectory for SuperDesign import.
|
||||
|
||||
Total: ~2,210 lines of comprehensive design documentation
|
||||
|
||||
Generated: April 6, 2026
|
||||
@@ -1,274 +0,0 @@
|
||||
# Shared UI Components
|
||||
|
||||
## Blade Components (Reusable)
|
||||
|
||||
### 1. `x-action-message`
|
||||
**Path:** `resources/views/components/action-message.blade.php`
|
||||
|
||||
Displays a temporary status message that auto-hides after 2 seconds using Alpine.js.
|
||||
|
||||
**Props:**
|
||||
- `on`: Event name to listen for (Livewire event)
|
||||
|
||||
**Features:**
|
||||
- Transition animation with fade-out
|
||||
- Auto-hide timeout (2000ms)
|
||||
- Default text: "Saved."
|
||||
|
||||
```blade
|
||||
@props([
|
||||
'on',
|
||||
])
|
||||
|
||||
<div
|
||||
x-data="{ shown: false, timeout: null }"
|
||||
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
|
||||
x-show.transition.out.opacity.duration.1500ms="shown"
|
||||
x-transition:leave.opacity.duration.1500ms
|
||||
style="display: none"
|
||||
{{ $attributes->merge(['class' => 'text-sm']) }}
|
||||
>
|
||||
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `x-app-logo`
|
||||
**Path:** `resources/views/components/app-logo.blade.php`
|
||||
|
||||
Main app branding component. Renders as `flux:brand` (header) or `flux:sidebar.brand` (sidebar variant).
|
||||
|
||||
**Props:**
|
||||
- `sidebar` (bool): If true, renders sidebar variant
|
||||
- Inherits Flux component attributes
|
||||
|
||||
**Features:**
|
||||
- Dynamic logo rendering based on context (header vs sidebar)
|
||||
- Uses `x-app-logo-icon` for icon
|
||||
- Passes through attributes to Flux components
|
||||
|
||||
```blade
|
||||
@props([
|
||||
'sidebar' => false,
|
||||
])
|
||||
|
||||
@if($sidebar)
|
||||
<flux:sidebar.brand name="Laravel Starter Kit" {{ $attributes }}>
|
||||
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
|
||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
||||
</x-slot>
|
||||
</flux:sidebar.brand>
|
||||
@else
|
||||
<flux:brand name="Laravel Starter Kit" {{ $attributes }}>
|
||||
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
|
||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
||||
</x-slot>
|
||||
</flux:brand>
|
||||
@endif
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. `x-app-logo-icon`
|
||||
**Path:** `resources/views/components/app-logo-icon.blade.php`
|
||||
|
||||
SVG icon for the application logo. Geometric design with fills.
|
||||
|
||||
**Features:**
|
||||
- Scalable SVG
|
||||
- Uses `currentColor` for dynamic styling
|
||||
- Can be sized with Tailwind classes
|
||||
|
||||
```blade
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
|
||||
/>
|
||||
</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `x-auth-header`
|
||||
**Path:** `resources/views/components/auth-header.blade.php`
|
||||
|
||||
Centered header for authentication pages with title and description.
|
||||
|
||||
**Props:**
|
||||
- `title`: Main heading text
|
||||
- `description`: Subheading/description text
|
||||
|
||||
**Features:**
|
||||
- Centered layout
|
||||
- Uses Flux typography components
|
||||
|
||||
```blade
|
||||
@props([
|
||||
'title',
|
||||
'description',
|
||||
])
|
||||
|
||||
<div class="flex w-full flex-col text-center">
|
||||
<flux:heading size="xl">{{ $title }}</flux:heading>
|
||||
<flux:subheading>{{ $description }}</flux:subheading>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. `x-auth-session-status`
|
||||
**Path:** `resources/views/components/auth-session-status.blade.php`
|
||||
|
||||
Displays session status messages (typically success messages after redirect).
|
||||
|
||||
**Props:**
|
||||
- `status`: Status message text (if present)
|
||||
- Passes through attributes
|
||||
|
||||
**Features:**
|
||||
- Conditional rendering (only shows if status exists)
|
||||
- Green success styling
|
||||
|
||||
```blade
|
||||
@props([
|
||||
'status',
|
||||
])
|
||||
|
||||
@if ($status)
|
||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
||||
{{ $status }}
|
||||
</div>
|
||||
@endif
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. `x-desktop-user-menu`
|
||||
**Path:** `resources/views/components/desktop-user-menu.blade.php`
|
||||
|
||||
User profile dropdown menu with settings and logout options. Uses Flux components.
|
||||
|
||||
**Features:**
|
||||
- Sidebar profile with dropdown
|
||||
- Shows user avatar, name, and email
|
||||
- Menu items: Settings (cog icon), Logout (arrow icon)
|
||||
- Logout form with CSRF protection
|
||||
- Data test attributes for testing
|
||||
|
||||
```blade
|
||||
<flux:dropdown position="bottom" align="start">
|
||||
<flux:sidebar.profile
|
||||
:name="auth()->user()->name"
|
||||
:initials="auth()->user()->initials()"
|
||||
icon:trailing="chevrons-up-down"
|
||||
data-test="sidebar-menu-button"
|
||||
/>
|
||||
|
||||
<flux:menu>
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
||||
<flux:avatar
|
||||
:name="auth()->user()->name"
|
||||
:initials="auth()->user()->initials()"
|
||||
/>
|
||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
||||
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
|
||||
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
|
||||
</div>
|
||||
</div>
|
||||
<flux:menu.separator />
|
||||
<flux:menu.radio.group>
|
||||
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
|
||||
{{ __('Settings') }}
|
||||
</flux:menu.item>
|
||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
||||
@csrf
|
||||
<flux:menu.item
|
||||
as="button"
|
||||
type="submit"
|
||||
icon="arrow-right-start-on-rectangle"
|
||||
class="w-full cursor-pointer"
|
||||
data-test="logout-button"
|
||||
>
|
||||
{{ __('Log out') }}
|
||||
</flux:menu.item>
|
||||
</form>
|
||||
</flux:menu.radio.group>
|
||||
</flux:menu>
|
||||
</flux:dropdown>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. `x-placeholder-pattern`
|
||||
**Path:** `resources/views/components/placeholder-pattern.blade.php`
|
||||
|
||||
SVG placeholder pattern for skeleton/loading states. Diagonal line pattern.
|
||||
|
||||
**Props:**
|
||||
- `id`: Unique pattern ID (auto-generated)
|
||||
|
||||
**Features:**
|
||||
- Reusable SVG pattern definition
|
||||
- Can be styled with Tailwind stroke classes
|
||||
- Used in dashboard placeholder cards
|
||||
|
||||
```blade
|
||||
@props([
|
||||
'id' => uniqid(),
|
||||
])
|
||||
|
||||
<svg {{ $attributes }} fill="none">
|
||||
<defs>
|
||||
<pattern id="pattern-{{ $id }}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
||||
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect stroke="none" fill="url(#pattern-{{ $id }})" width="100%" height="100%"></rect>
|
||||
</svg>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flux UI Components Used
|
||||
|
||||
Flux v2 components extensively used throughout the application:
|
||||
|
||||
- **Layout:** `flux:header`, `flux:sidebar`, `flux:main`, `flux:spacer`
|
||||
- **Navigation:** `flux:navbar`, `flux:navbar.item`, `flux:sidebar.nav`, `flux:sidebar.item`, `flux:sidebar.group`
|
||||
- **Forms:** `flux:input`, `flux:select`, `flux:checkbox`, `flux:button`
|
||||
- **Typography:** `flux:heading`, `flux:subheading`, `flux:text`
|
||||
- **Dropdowns/Menus:** `flux:dropdown`, `flux:menu`, `flux:menu.item`, `flux:menu.separator`
|
||||
- **UI Elements:** `flux:badge`, `flux:avatar`, `flux:brand`, `flux:profile`
|
||||
- **Other:** `flux:tooltip`, `flux:separator`, `flux:link`
|
||||
|
||||
---
|
||||
|
||||
## Flux Custom Icons
|
||||
|
||||
Located in `resources/views/flux/icon/`:
|
||||
- `layout-grid.blade.php`
|
||||
- `folder-git-2.blade.php`
|
||||
- `chevrons-up-down.blade.php`
|
||||
- `book-open-text.blade.php`
|
||||
|
||||
These extend Flux's default icon library.
|
||||
|
||||
---
|
||||
|
||||
## Alpine.js Data Objects
|
||||
|
||||
### `stationMap`
|
||||
**Path:** `resources/js/maps/station-map.js`
|
||||
|
||||
Leaflet map integration for displaying fuel stations:
|
||||
- Initializes map centered on UK
|
||||
- Plots colored circle markers for stations
|
||||
- Color coding: green (current), slate (recent), amber (stale), red (outdated)
|
||||
- Popup display with station details
|
||||
- Responsive bounds fitting
|
||||
- Watches for data changes via Alpine
|
||||
|
||||
@@ -1,494 +0,0 @@
|
||||
# Design System
|
||||
|
||||
Comprehensive documentation of the design system, visual language, and brand identity for the Fuel Price application.
|
||||
|
||||
---
|
||||
|
||||
## Brand Identity
|
||||
|
||||
### App Name
|
||||
"Fuel Price" (Laravel Starter Kit internally)
|
||||
|
||||
### Logo
|
||||
- **Style:** Geometric, modern
|
||||
- **Colors:** Adapts to light/dark mode
|
||||
- **Variants:** Header logo (wider), Sidebar logo (square icon)
|
||||
- **Usage:** All pages in navigation
|
||||
|
||||
### Design Philosophy
|
||||
- Clean, minimal aesthetic
|
||||
- Dark-first design (dark mode as primary)
|
||||
- Accessibility-focused
|
||||
- Data visualization emphasis (maps, charts)
|
||||
- Utility-oriented interface
|
||||
|
||||
---
|
||||
|
||||
## Color System
|
||||
|
||||
### Primary Palette - Zinc (Neutral)
|
||||
|
||||
Used as base colors for UI, text, and borders.
|
||||
|
||||
```
|
||||
Zinc-50: #fafafa (Lightest backgrounds)
|
||||
Zinc-100: #f5f5f5 (Light backgrounds)
|
||||
Zinc-200: #e5e5e5 (Light borders, dividers)
|
||||
Zinc-300: #d4d4d4 (Subtle borders)
|
||||
Zinc-400: #a3a3a3 (Placeholder text)
|
||||
Zinc-500: #737373 (Secondary text)
|
||||
Zinc-600: #525252 (Body text)
|
||||
Zinc-700: #404040 (Dark text)
|
||||
Zinc-800: #262626 (Page backgrounds dark)
|
||||
Zinc-900: #171717 (Header/sidebar backgrounds)
|
||||
Zinc-950: #0a0a0a (Darkest)
|
||||
```
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
#### Success
|
||||
- **Primary:** `#22c55e` (Green-500)
|
||||
- **Dark:** `#16a34a` (Green-600)
|
||||
- **Light:** `#86efac` (Green-300)
|
||||
- **Usage:** Success messages, positive indicators, current/fresh data
|
||||
|
||||
#### Warning
|
||||
- **Primary:** `#f59e0b` (Amber-500)
|
||||
- **Light:** `#fbbf24` (Amber-400)
|
||||
- **Usage:** Stale data, deprecation notices
|
||||
|
||||
#### Error/Danger
|
||||
- **Primary:** `#ef4444` (Red-500)
|
||||
- **Dark:** `#dc2626` (Red-600)
|
||||
- **Light:** `#fca5a5` (Red-300)
|
||||
- **Usage:** Outdated data, error messages, destructive actions
|
||||
|
||||
#### Info
|
||||
- **Primary:** `#64748b` (Slate-500)
|
||||
- **Usage:** Neutral information, recent data
|
||||
|
||||
### Accent Colors
|
||||
|
||||
**Light Mode:**
|
||||
- Accent: `--color-neutral-800` (dark gray)
|
||||
- Accent Content: `--color-neutral-800`
|
||||
- Accent Foreground: `--color-white`
|
||||
|
||||
**Dark Mode:**
|
||||
- Accent: `--color-white`
|
||||
- Accent Content: `--color-white`
|
||||
- Accent Foreground: `--color-neutral-800`
|
||||
|
||||
Used for:
|
||||
- Primary buttons
|
||||
- Active navigation items
|
||||
- Focus states
|
||||
- Primary CTAs
|
||||
|
||||
---
|
||||
|
||||
## Typography System
|
||||
|
||||
### Typeface
|
||||
|
||||
**Primary Font:** Instrument Sans
|
||||
- **Source:** Google Fonts (fonts.bunny.net)
|
||||
- **Weights Available:** 400 (Regular), 500 (Medium), 600 (Semibold)
|
||||
- **Category:** Sans-serif, Humanist
|
||||
- **Use Case:** All body text, headings, UI labels
|
||||
|
||||
**Fallback Stack:**
|
||||
```
|
||||
'Instrument Sans',
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
'Segoe UI Symbol',
|
||||
'Noto Color Emoji'
|
||||
```
|
||||
|
||||
### Font Sizes & Hierarchy
|
||||
|
||||
Via Flux components (sizes managed by Flux):
|
||||
|
||||
- **flux:heading size="xl":** Page titles (largest)
|
||||
- **flux:heading size="lg":** Section titles
|
||||
- **flux:heading:** Standard headings
|
||||
- **flux:subheading:** Secondary headings, descriptions
|
||||
- **flux:text:** Body text
|
||||
- **flux:label:** Form labels, captions
|
||||
- **flux:badge:** Small labels, tags
|
||||
|
||||
### Line Heights
|
||||
|
||||
- **Tight:** Labels, badges (`leading-tight`)
|
||||
- **Normal:** Body text, headings (default)
|
||||
- **Relaxed:** Long-form content (when needed)
|
||||
|
||||
### Weight Distribution
|
||||
|
||||
- **400 (Regular):** Body text, regular content
|
||||
- **500 (Medium):** Secondary headings, strong emphasis, labels
|
||||
- **600 (Semibold):** Primary headings, buttons, strong emphasis
|
||||
|
||||
---
|
||||
|
||||
## Spacing & Layout
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
Based on 4px base unit:
|
||||
|
||||
```
|
||||
0: 0px
|
||||
0.5: 2px
|
||||
1: 4px
|
||||
1.5: 6px
|
||||
2: 8px
|
||||
2.5: 10px
|
||||
3: 12px
|
||||
3.5: 14px
|
||||
4: 16px
|
||||
5: 20px
|
||||
6: 24px
|
||||
7: 28px
|
||||
8: 32px
|
||||
9: 36px
|
||||
10: 40px
|
||||
11: 44px
|
||||
12: 48px
|
||||
...
|
||||
```
|
||||
|
||||
### Common Spacing Patterns
|
||||
|
||||
- **Gap between elements:** `gap-3` (12px), `gap-4` (16px), `gap-6` (24px)
|
||||
- **Form field spacing:** `gap-2` (8px) for label + input
|
||||
- **Card padding:** `p-4` (16px) for compact, `p-6` (24px) for default, `p-10` (40px) for large
|
||||
- **Horizontal padding:** `px-4` (16px) standard
|
||||
- **Vertical padding:** `py-3` (12px) standard
|
||||
|
||||
### Grid System
|
||||
|
||||
- Flex-based layouts (no CSS Grid for most components)
|
||||
- Default gap: `gap-4` (16px)
|
||||
- Settings page: 2-column on desktop (220px sidebar + flex content)
|
||||
- Station results: 1-column list, single-level nesting
|
||||
|
||||
### Responsive Breakpoints
|
||||
|
||||
```
|
||||
Default (mobile): 0px
|
||||
sm: 640px (@media max-width: 639px = mobile)
|
||||
md: 768px (tablet)
|
||||
lg: 1024px (desktop)
|
||||
xl: 1280px (large desktop)
|
||||
2xl: 1536px (extra large)
|
||||
```
|
||||
|
||||
**Breakpoint Patterns:**
|
||||
- `max-lg:` - Mobile and tablet
|
||||
- `lg:` - Desktop and larger
|
||||
- `max-md:` - Mobile only
|
||||
- `md:` - Tablet and larger
|
||||
|
||||
---
|
||||
|
||||
## Border & Radius
|
||||
|
||||
### Border Weight
|
||||
|
||||
- **Default:** 1px (all borders)
|
||||
- **Emphasis:** None (single weight used)
|
||||
|
||||
### Border Radius
|
||||
|
||||
- **Small:** `rounded-md` (6-8px) - Icons, small buttons, badges
|
||||
- **Medium:** `rounded-lg` (8-10px) - Inputs, dropdowns
|
||||
- **Large:** `rounded-xl` (12-16px) - Cards, modals, large components
|
||||
- **None:** `rounded-none` - Rarely used
|
||||
|
||||
### Border Colors
|
||||
|
||||
**Light Mode:**
|
||||
- Default border: `border-zinc-200`
|
||||
- Focus ring: `ring-accent` (accent color)
|
||||
|
||||
**Dark Mode:**
|
||||
- Default border: `border-zinc-700`
|
||||
- Card/modal: `border-stone-800` or `border-neutral-800`
|
||||
- Focus ring: `ring-accent` with `ring-offset-2` and `ring-offset-accent-foreground`
|
||||
|
||||
### Focus States
|
||||
|
||||
All interactive elements have:
|
||||
- `ring-2 ring-accent` (focus ring)
|
||||
- `ring-offset-2` (space between element and ring)
|
||||
- `ring-offset-accent-foreground` (offset background color)
|
||||
|
||||
---
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Form Patterns
|
||||
|
||||
All form elements follow consistent structure:
|
||||
1. Label (top)
|
||||
2. Input/Select/Textarea
|
||||
3. Error message (bottom, conditional)
|
||||
4. Helper text (optional)
|
||||
|
||||
**Spacing:** `gap-2` between label and input
|
||||
|
||||
**States:**
|
||||
- Normal: default appearance
|
||||
- Focus: ring around element
|
||||
- Disabled: opacity/pointer-events disabled
|
||||
- Error: red text below
|
||||
|
||||
### Button Patterns
|
||||
|
||||
**Variants:**
|
||||
- `variant="primary"` - Primary action (accent color background)
|
||||
- `variant="secondary"` - Secondary action
|
||||
- `variant="danger"` - Destructive action (red)
|
||||
|
||||
**States:**
|
||||
- Normal: clickable
|
||||
- Hover: slight opacity increase
|
||||
- Disabled: `disabled` attribute via `wire:loading.attr="disabled"`
|
||||
- Loading: show spinner or text change
|
||||
|
||||
### Card Patterns
|
||||
|
||||
Standard card styling:
|
||||
- Border: `border border-zinc-200 dark:border-zinc-700`
|
||||
- Radius: `rounded-xl`
|
||||
- Background: white (light) / dark (dark mode)
|
||||
- Padding: `p-4` to `p-10` depending on content density
|
||||
- Shadow: `shadow-xs` for subtle lift (optional)
|
||||
|
||||
### Navigation Patterns
|
||||
|
||||
**Sidebar Navigation:**
|
||||
- `flux:sidebar.nav` wrapper
|
||||
- `flux:sidebar.group` for sections (with heading)
|
||||
- `flux:sidebar.item` for links
|
||||
- Active state: current attribute
|
||||
- Icon on left, text on right
|
||||
|
||||
**Navbar Navigation:**
|
||||
- `flux:navbar` wrapper
|
||||
- `flux:navbar.item` for links
|
||||
- Icons only or icon + text
|
||||
- Horizontal layout
|
||||
- Tooltip support
|
||||
|
||||
---
|
||||
|
||||
## Data Visualization
|
||||
|
||||
### Station Search Map Colors (Leaflet)
|
||||
|
||||
Classification-based color coding for fuel price data freshness:
|
||||
|
||||
- **Current (< 24h):** `#22c55e` (Green-500)
|
||||
- **Recent (24-48h):** `#64748b` (Slate-500)
|
||||
- **Stale (2-5 days):** `#f59e0b` (Amber-500)
|
||||
- **Outdated (5+ days):** `#ef4444` (Red-500)
|
||||
|
||||
### Map Markers
|
||||
|
||||
- **Shape:** Circle
|
||||
- **Radius:** 9px
|
||||
- **Stroke:** White, 2px weight
|
||||
- **Fill:** Classification color
|
||||
- **Opacity:** 85% (0.85)
|
||||
- **Click:** Shows popup with station details
|
||||
|
||||
### Data Table Patterns
|
||||
|
||||
(No explicit tables in current design, but pattern would be):
|
||||
- Striped rows (alternate bg)
|
||||
- Hover states for interactivity
|
||||
- Clear column alignment
|
||||
- Sortable headers
|
||||
|
||||
---
|
||||
|
||||
## Animations & Transitions
|
||||
|
||||
### Page Transitions
|
||||
|
||||
All navigation via `wire:navigate` (no full page reload):
|
||||
- Seamless SPA-like experience
|
||||
- Preserves scroll position
|
||||
- No loading screen between pages
|
||||
|
||||
### Component Transitions
|
||||
|
||||
**Alpine.js Transitions:**
|
||||
- Fade on message dismiss: `x-show.transition.out.opacity.duration.1500ms`
|
||||
- Duration: 1500ms (1.5 seconds)
|
||||
- Effect: Opacity fade
|
||||
|
||||
**Flux Built-in:**
|
||||
- Dropdown/menu opens: instant or quick fade
|
||||
- Sidebar collapse: smooth width transition
|
||||
- Modal appears: fade + scale (typical Flux defaults)
|
||||
|
||||
### Hover & Active States
|
||||
|
||||
- Links: `hover:underline` (default Flux)
|
||||
- Buttons: Opacity change on hover
|
||||
- Navigation items: Background highlight on hover/active
|
||||
- Interactive elements: Subtle color shift
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Color Contrast
|
||||
|
||||
- Text on backgrounds meets WCAG AA standards (4.5:1 for normal text)
|
||||
- Semantic colors (green/red) supplemented with icons/patterns
|
||||
- No color-only indicators
|
||||
|
||||
### Interactive Elements
|
||||
|
||||
- Keyboard navigable (all Flux components)
|
||||
- Focus indicators visible (`ring-2 ring-accent`)
|
||||
- Touch-friendly sizing (minimum 44x44px recommended)
|
||||
- ARIA labels where needed
|
||||
|
||||
### Typography
|
||||
|
||||
- Font sizes readable at standard distances
|
||||
- Line height adequate for readability
|
||||
- High contrast between text and background
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
### Implementation
|
||||
|
||||
- **Method:** `class="dark"` on HTML root
|
||||
- **Toggle:** Managed by `@fluxAppearance` directive
|
||||
- **Detection:** Optional prefers-color-scheme integration
|
||||
|
||||
### Dark Mode Colors
|
||||
|
||||
**Text:**
|
||||
- Primary: `text-zinc-100` (light gray)
|
||||
- Secondary: `text-zinc-400` (medium gray)
|
||||
- Disabled: `text-zinc-500` (darker gray)
|
||||
|
||||
**Backgrounds:**
|
||||
- Page: `bg-zinc-800`
|
||||
- Header/Sidebar: `bg-zinc-900`
|
||||
- Cards: `bg-stone-950` (darker variant)
|
||||
- Inputs: `bg-zinc-900`
|
||||
|
||||
**Borders:**
|
||||
- Primary: `border-zinc-700`
|
||||
- Secondary: `border-stone-800`
|
||||
- Subtle: `border-neutral-800`
|
||||
|
||||
**Accents:**
|
||||
- Primary accent inverts: white (instead of dark gray)
|
||||
|
||||
### Image/SVG Handling in Dark
|
||||
|
||||
- Logo icon color inverts via `fill-current` and `text-*` classes
|
||||
- Patterns use opacity: `stroke-neutral-100/20` (dark) vs `stroke-gray-900/20` (light)
|
||||
- Leaflet map: Default OSM colors (already dark-friendly)
|
||||
|
||||
---
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### Mobile-First Approach
|
||||
|
||||
Design starts at mobile (smallest), enhances at larger breakpoints.
|
||||
|
||||
### Key Breakpoints
|
||||
|
||||
**Mobile (0-639px):**
|
||||
- Single column layouts
|
||||
- Hamburger menu (sidebar collapses)
|
||||
- Stacked form fields
|
||||
- Full-width cards
|
||||
|
||||
**Tablet (640-1023px):**
|
||||
- Narrower multi-column (if applicable)
|
||||
- Touch-friendly spacing
|
||||
- Condensed headers
|
||||
|
||||
**Desktop (1024px+):**
|
||||
- Multi-column layouts
|
||||
- Horizontal navigation
|
||||
- Sidebar persistent
|
||||
- Full feature set visible
|
||||
|
||||
### Layout Adjustments
|
||||
|
||||
- Header: Hidden navbar items on mobile (`max-lg:hidden`)
|
||||
- Sidebar: Becomes mobile hamburger at lg breakpoint
|
||||
- Forms: Stack vertically on mobile, horizontal on desktop
|
||||
- Grids: 1 column mobile, 2-3 columns desktop
|
||||
|
||||
---
|
||||
|
||||
## Consistency Patterns
|
||||
|
||||
### Consistent Component Usage
|
||||
|
||||
- All buttons: `flux:button` (never raw `<button>`)
|
||||
- All inputs: `flux:input` (with validation/errors)
|
||||
- All selects: `flux:select`
|
||||
- All headings: `flux:heading` (sizes: xl, lg, etc.)
|
||||
|
||||
### Consistent Spacing
|
||||
|
||||
- Inter-element gaps: multiples of 4px
|
||||
- Card padding: consistent with gap sizes
|
||||
- Consistent use of flexbox for alignment
|
||||
|
||||
### Consistent States
|
||||
|
||||
- Forms show errors below input in red
|
||||
- Success messages fade out after 2 seconds
|
||||
- Disabled states use opacity + pointer-events
|
||||
- Loading states shown via spinner or text change
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### CSS Custom Properties
|
||||
|
||||
```css
|
||||
--font-sans: 'Instrument Sans', ...
|
||||
--color-accent: (theme-dependent)
|
||||
--color-accent-content: (theme-dependent)
|
||||
--color-accent-foreground: (theme-dependent)
|
||||
--color-zinc-{50-950}: (full palette)
|
||||
```
|
||||
|
||||
### Tailwind Integration
|
||||
|
||||
- Uses Tailwind CSS v4 with `@tailwindcss/vite`
|
||||
- `@theme` block defines custom colors and fonts
|
||||
- `@custom-variant` for dark mode
|
||||
- `@layer` for CSS overrides
|
||||
|
||||
### Flux Integration
|
||||
|
||||
- Heavy reliance on Flux v2 components
|
||||
- Flux provides theming, dark mode, layout components
|
||||
- Custom CSS only overrides specific Flux defaults
|
||||
- No Flux config file needed (uses defaults + CSS)
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
# Extractable UI Components
|
||||
|
||||
This document identifies reusable UI components that could be extracted, packaged, and reused across other projects.
|
||||
|
||||
---
|
||||
|
||||
## Form Components
|
||||
|
||||
### 1. Search Input with Debounce
|
||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 8-16)
|
||||
**Category:** Forms
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- `name`: Input field name
|
||||
- `wire:model`: Livewire property binding
|
||||
- `label`: Display label
|
||||
- `placeholder`: Placeholder text
|
||||
|
||||
**Features:**
|
||||
- Flux form styling
|
||||
- Wire model binding support
|
||||
- Error message display
|
||||
|
||||
**Extractable:** Yes - Generic search input wrapper
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-Option Select Dropdown
|
||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 20-28, 34-42, 44-52)
|
||||
**Category:** Forms
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- `wire:model.live` or `wire:model`: Binding
|
||||
- `name`: Field name
|
||||
- `label`: Label text
|
||||
- Options as slot
|
||||
|
||||
**Features:**
|
||||
- Reactive updates via `wire:model.live`
|
||||
- Error display
|
||||
- Multiple select options
|
||||
|
||||
**Variants:**
|
||||
- Fuel type selector (6 options)
|
||||
- Radius selector (5 options)
|
||||
- Sort selector (5 options)
|
||||
|
||||
**Extractable:** Yes - Reusable select component wrapper
|
||||
|
||||
---
|
||||
|
||||
### 3. Form Submission Button with Loading State
|
||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 54-59)
|
||||
**Category:** Forms
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- `type`: button type
|
||||
- `variant`: "primary", etc.
|
||||
- `wire:loading.attr`: Disabled state during loading
|
||||
- `wire:target`: Target action
|
||||
|
||||
**Features:**
|
||||
- Dynamic text based on loading state
|
||||
- Wire loading indicators
|
||||
- Primary variant styling
|
||||
|
||||
**Extractable:** Yes - Generic loading button component
|
||||
|
||||
---
|
||||
|
||||
## Data Display Components
|
||||
|
||||
### 4. Station Result Card
|
||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 94-128)
|
||||
**Category:** Data Display
|
||||
**Complexity:** Medium
|
||||
|
||||
**Props:**
|
||||
- `station`: Object with station data
|
||||
- `name`: Station name
|
||||
- `address`: Street address
|
||||
- `postcode`: Postcode
|
||||
- `distance_km`: Distance in kilometers
|
||||
- `price`: Fuel price in pence
|
||||
- `price_classification`: One of (current, recent, stale, outdated)
|
||||
- `price_classification_label`: Human label
|
||||
- `price_updated_at`: ISO date string
|
||||
- `is_supermarket`: Boolean
|
||||
|
||||
**Features:**
|
||||
- Responsive layout (flex items-center justify-between)
|
||||
- Color-coded price based on freshness
|
||||
- Supermarket badge display
|
||||
- Distance/address display
|
||||
- Time-ago formatting
|
||||
|
||||
**Extractable:** Yes - Could be standalone component for displaying station data
|
||||
|
||||
---
|
||||
|
||||
### 5. Results Count Summary
|
||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 72-76)
|
||||
**Category:** Data Display
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- `count`: Total stations found
|
||||
- `lowest_pence`: Lowest price in pence
|
||||
- `avg_pence`: Average price in pence
|
||||
|
||||
**Features:**
|
||||
- Formatted currency display
|
||||
- Singular/plural handling
|
||||
- Stats display on one line
|
||||
|
||||
**Extractable:** Yes - Generic stats summary component
|
||||
|
||||
---
|
||||
|
||||
### 6. Classification Legend/Color Code Guide
|
||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 84-90)
|
||||
**Category:** Data Display
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- Array of classification items with colors and labels
|
||||
|
||||
**Features:**
|
||||
- Inline color swatches
|
||||
- Legend item labels
|
||||
- Responsive flex layout
|
||||
|
||||
**Extractable:** Yes - Reusable legend component
|
||||
|
||||
---
|
||||
|
||||
## Navigation Components
|
||||
|
||||
### 7. Settings Sidebar Navigation
|
||||
**Source:** `resources/views/pages/settings/layout.blade.php` (lines 3-7)
|
||||
**Category:** Navigation
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- Navigation items array with:
|
||||
- `label`: Display text
|
||||
- `route`: Route name
|
||||
- `icon`: Optional icon name
|
||||
|
||||
**Features:**
|
||||
- flux:navlist component
|
||||
- wire:navigate support
|
||||
- Active state detection
|
||||
- Mobile/desktop responsive
|
||||
|
||||
**Extractable:** Yes - Generic sidebar nav for multi-section pages
|
||||
|
||||
---
|
||||
|
||||
### 8. User Profile Dropdown Menu
|
||||
**Source:** `resources/views/components/desktop-user-menu.blade.php`
|
||||
**Category:** Navigation
|
||||
**Complexity:** Medium
|
||||
|
||||
**Props:**
|
||||
- User object with:
|
||||
- `name`: User name
|
||||
- `email`: User email
|
||||
- `initials()`: Method to get initials
|
||||
|
||||
**Features:**
|
||||
- Flux dropdown + menu
|
||||
- Avatar display with initials
|
||||
- Settings link
|
||||
- Logout form with CSRF
|
||||
- Test attributes for QA
|
||||
|
||||
**Extractable:** Yes - Standalone user menu component for authenticated apps
|
||||
|
||||
---
|
||||
|
||||
## Layout Components
|
||||
|
||||
### 9. Centered Authentication Layout Container
|
||||
**Source:** `resources/views/layouts/auth/simple.blade.php`
|
||||
**Category:** Layouts
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- None (slot-based)
|
||||
|
||||
**Features:**
|
||||
- Centered flex layout
|
||||
- Max-width constraint (sm)
|
||||
- Dark gradient background
|
||||
- Logo link at top
|
||||
- Responsive padding
|
||||
|
||||
**Extractable:** Yes - Generic centered auth layout
|
||||
|
||||
---
|
||||
|
||||
### 10. Card-Based Form Container
|
||||
**Source:** `resources/views/layouts/auth/card.blade.php`
|
||||
**Category:** Layouts
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- None (slot-based)
|
||||
|
||||
**Features:**
|
||||
- White card on dark background
|
||||
- Rounded borders with shadow
|
||||
- Dark mode support
|
||||
- Centered with max-width
|
||||
- Padding inside card
|
||||
|
||||
**Extractable:** Yes - Modal/card wrapper component
|
||||
|
||||
---
|
||||
|
||||
### 11. Split-Screen Auth Layout
|
||||
**Source:** `resources/views/layouts/auth/split.blade.php`
|
||||
**Category:** Layouts
|
||||
**Complexity:** Medium
|
||||
|
||||
**Props:**
|
||||
- Quote display (generated from Inspiring::quotes)
|
||||
|
||||
**Features:**
|
||||
- Two-column grid (desktop only)
|
||||
- Dark sidebar with quote/branding
|
||||
- Form content on right
|
||||
- Mobile-friendly (single column)
|
||||
- Absolute background fill
|
||||
|
||||
**Extractable:** Yes - Premium auth layout component
|
||||
|
||||
---
|
||||
|
||||
## Status/Feedback Components
|
||||
|
||||
### 12. Temporary Action Message
|
||||
**Source:** `resources/views/components/action-message.blade.php`
|
||||
**Category:** Feedback
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- `on`: Livewire event to listen to
|
||||
- Slot for custom message (defaults to "Saved.")
|
||||
|
||||
**Features:**
|
||||
- Alpine.js event listener
|
||||
- Auto-hide after 2 seconds
|
||||
- Fade transition
|
||||
- Livewire integration
|
||||
|
||||
**Extractable:** Yes - Generic toast/message component
|
||||
|
||||
---
|
||||
|
||||
### 13. Session Status Message
|
||||
**Source:** `resources/views/components/auth-session-status.blade.php`
|
||||
**Category:** Feedback
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- `status`: Status message text
|
||||
|
||||
**Features:**
|
||||
- Conditional rendering
|
||||
- Green success styling
|
||||
- Used in auth forms
|
||||
|
||||
**Extractable:** Yes - Simple status display component
|
||||
|
||||
---
|
||||
|
||||
## Map/Visualization Components
|
||||
|
||||
### 14. Leaflet Map Integration
|
||||
**Source:** `resources/js/maps/station-map.js`
|
||||
**Category:** Map/Visualization
|
||||
**Complexity:** High
|
||||
|
||||
**Props:**
|
||||
- `results`: Array of station data with lat/lng/classifications
|
||||
|
||||
**Features:**
|
||||
- Leaflet map initialization
|
||||
- OpenStreetMap tiles
|
||||
- Custom circle markers with colors
|
||||
- Popup on marker click
|
||||
- Auto-fit bounds
|
||||
- Reactive to data changes
|
||||
|
||||
**Extractable:** Yes - Could be abstracted into reusable Leaflet component
|
||||
|
||||
**Note:** Currently tightly coupled to Alpine.js and station data structure. Would require prop mapping for reuse.
|
||||
|
||||
---
|
||||
|
||||
## Typography/Header Components
|
||||
|
||||
### 15. Auth Page Header
|
||||
**Source:** `resources/views/components/auth-header.blade.php`
|
||||
**Category:** Typography
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- `title`: Main heading
|
||||
- `description`: Subheading
|
||||
|
||||
**Features:**
|
||||
- Centered text
|
||||
- Flux typography (heading + subheading)
|
||||
- Used on all auth pages
|
||||
|
||||
**Extractable:** Yes - Generic header for centered pages
|
||||
|
||||
---
|
||||
|
||||
### 16. Settings Page Header
|
||||
**Source:** `resources/views/partials/settings-heading.blade.php`
|
||||
**Category:** Typography
|
||||
**Complexity:** Low
|
||||
|
||||
**Props:**
|
||||
- None (hardcoded for settings)
|
||||
|
||||
**Features:**
|
||||
- Page title "Settings"
|
||||
- Subheading text
|
||||
- Separator line
|
||||
|
||||
**Extractable:** Maybe - Specific to settings but pattern is reusable
|
||||
|
||||
---
|
||||
|
||||
## Reusability Summary Table
|
||||
|
||||
| Component | Type | Complexity | Reusable | Dependencies | Extraction Cost |
|
||||
|-----------|------|-----------|----------|---|---|
|
||||
| Search Input | Form | Low | High | Flux | Very Low |
|
||||
| Select Dropdown | Form | Low | High | Flux | Very Low |
|
||||
| Loading Button | Form | Low | High | Flux, Livewire | Very Low |
|
||||
| Station Card | Display | Medium | High | Flux | Low |
|
||||
| Stats Summary | Display | Low | High | None | Very Low |
|
||||
| Legend | Display | Low | High | Tailwind | Very Low |
|
||||
| Settings Nav | Nav | Low | High | Flux | Very Low |
|
||||
| User Menu | Nav | Medium | High | Flux, Auth | Low |
|
||||
| Simple Auth Layout | Layout | Low | High | Tailwind | Very Low |
|
||||
| Card Container | Layout | Low | High | Tailwind | Very Low |
|
||||
| Split Layout | Layout | Medium | High | Tailwind | Low |
|
||||
| Action Message | Feedback | Low | High | Alpine, Livewire | Very Low |
|
||||
| Status Message | Feedback | Low | High | None | Very Low |
|
||||
| Leaflet Map | Visualization | High | Medium | Leaflet, Alpine | Medium |
|
||||
| Auth Header | Typography | Low | High | Flux | Very Low |
|
||||
| Settings Header | Typography | Low | Medium | Flux | Very Low |
|
||||
|
||||
---
|
||||
|
||||
## Top Candidates for Extraction
|
||||
|
||||
1. **Search Input Component** - Generic, simple, widely useful
|
||||
2. **Station Card Component** - Good showcase of complex data display
|
||||
3. **User Menu Component** - Authentication pattern, widely needed
|
||||
4. **Loading Button Component** - Form UX pattern, commonly needed
|
||||
5. **Simple Auth Layout** - Authentication flows are common
|
||||
|
||||
---
|
||||
|
||||
## Framework Package Recommendations
|
||||
|
||||
If extracting these components into a package:
|
||||
|
||||
1. Make them framework-agnostic (or have adapters)
|
||||
2. Ensure Flux/Livewire are peer dependencies
|
||||
3. Document required Alpine.js versions
|
||||
4. Provide TypeScript declarations for props
|
||||
5. Include Storybook examples
|
||||
6. Consider CSS-in-JS vs Tailwind integration
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
# Layout Files
|
||||
|
||||
## App Layouts (For Authenticated Users)
|
||||
|
||||
### 1. `layouts/app.blade.php` (Main App Layout)
|
||||
**Path:** `resources/views/layouts/app.blade.php`
|
||||
|
||||
Wrapper layout that delegates to sidebar layout. Used by authenticated routes.
|
||||
|
||||
```blade
|
||||
<x-layouts::app.sidebar :title="$title ?? null">
|
||||
<flux:main>
|
||||
{{ $slot }}
|
||||
</flux:main>
|
||||
</x-layouts::app.sidebar>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `layouts/app/sidebar.blade.php` (Sidebar Layout with Header/Navigation)
|
||||
**Path:** `resources/views/layouts/app/sidebar.blade.php`
|
||||
|
||||
Core authenticated layout with sidebar navigation and header. Renders app header on desktop, mobile sidebar.
|
||||
|
||||
**Key Features:**
|
||||
- Dark mode support (HTML class="dark")
|
||||
- Responsive: Sidebar on desktop, hamburger on mobile
|
||||
- Desktop navbar with Dashboard link
|
||||
- Mobile header with sidebar toggle
|
||||
- Search, Repository, and Documentation links
|
||||
- User menu in header (desktop) and mobile sidebar
|
||||
- Uses Flux components extensively
|
||||
|
||||
**Structure:**
|
||||
- HTML/head with partials.head include
|
||||
- Body with min-h-screen, dark mode bg colors
|
||||
- flux:header (desktop) with:
|
||||
- Sidebar toggle (mobile only)
|
||||
- App logo
|
||||
- Desktop navbar with links
|
||||
- Spacer
|
||||
- Secondary navbar (Search, Repo, Docs icons)
|
||||
- Desktop user menu
|
||||
- flux:sidebar (mobile) with:
|
||||
- App logo
|
||||
- Sidebar collapse button
|
||||
- Navigation group (Platform section)
|
||||
- External links (Repo, Docs)
|
||||
- flux:main slot for page content
|
||||
- @fluxScripts directive at end
|
||||
|
||||
```blade
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
||||
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.toggle class="lg:hidden mr-2" icon="bars-2" inset="left" />
|
||||
|
||||
<x-app-logo href="{{ route('dashboard') }}" wire:navigate />
|
||||
|
||||
<flux:navbar class="-mb-px max-lg:hidden">
|
||||
<flux:navbar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
|
||||
{{ __('Dashboard') }}
|
||||
</flux:navbar.item>
|
||||
</flux:navbar>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<flux:navbar class="me-1.5 space-x-0.5 rtl:space-x-reverse py-0!">
|
||||
<flux:tooltip :content="__('Search')" position="bottom">
|
||||
<flux:navbar.item class="!h-10 [&>div>svg]:size-5" icon="magnifying-glass" href="#" :label="__('Search')" />
|
||||
</flux:tooltip>
|
||||
<flux:tooltip :content="__('Repository')" position="bottom">
|
||||
<flux:navbar.item
|
||||
class="h-10 max-lg:hidden [&>div>svg]:size-5"
|
||||
icon="folder-git-2"
|
||||
href="https://github.com/laravel/livewire-starter-kit"
|
||||
target="_blank"
|
||||
:label="__('Repository')"
|
||||
/>
|
||||
</flux:tooltip>
|
||||
<flux:tooltip :content="__('Documentation')" position="bottom">
|
||||
<flux:navbar.item
|
||||
class="h-10 max-lg:hidden [&>div>svg]:size-5"
|
||||
icon="book-open-text"
|
||||
href="https://laravel.com/docs/starter-kits#livewire"
|
||||
target="_blank"
|
||||
:label="__('Documentation')"
|
||||
/>
|
||||
</flux:tooltip>
|
||||
</flux:navbar>
|
||||
|
||||
<x-desktop-user-menu />
|
||||
</flux:header>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<flux:sidebar collapsible="mobile" sticky class="lg:hidden border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.header>
|
||||
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
|
||||
<flux:sidebar.collapse class="in-data-flux-sidebar-on-desktop:not-in-data-flux-sidebar-collapsed-desktop:-mr-2" />
|
||||
</flux:sidebar.header>
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.group :heading="__('Platform')">
|
||||
<flux:sidebar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
|
||||
{{ __('Dashboard') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.group>
|
||||
</flux:sidebar.nav>
|
||||
|
||||
<flux:spacer />
|
||||
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
|
||||
{{ __('Repository') }}
|
||||
</flux:sidebar.item>
|
||||
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
|
||||
{{ __('Documentation') }}
|
||||
</flux:sidebar.item>
|
||||
</flux:sidebar.nav>
|
||||
</flux:sidebar>
|
||||
|
||||
{{ $slot }}
|
||||
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Auth Layouts (For Unauthenticated Users)
|
||||
|
||||
### 3. `layouts/auth.blade.php` (Auth Wrapper)
|
||||
**Path:** `resources/views/layouts/auth.blade.php`
|
||||
|
||||
Simple wrapper that delegates to simple layout. Used for auth pages (login, register, password reset, etc.).
|
||||
|
||||
```blade
|
||||
<x-layouts::auth.simple :title="$title ?? null">
|
||||
{{ $slot }}
|
||||
</x-layouts::auth.simple>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `layouts/auth/simple.blade.php` (Simple Auth Layout - Centered)
|
||||
**Path:** `resources/views/layouts/auth/simple.blade.php`
|
||||
|
||||
Minimalist centered layout for authentication. Used for login, register, etc.
|
||||
|
||||
**Key Features:**
|
||||
- Dark mode with gradient background (dark:from-neutral-950 to-neutral-900)
|
||||
- Centered content with max-width constraint
|
||||
- App logo centered with link to home
|
||||
- Dark mode gradient background
|
||||
- Flex column centered layout
|
||||
|
||||
```blade
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
||||
<div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div class="flex w-full max-w-sm flex-col gap-2">
|
||||
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
||||
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
||||
</span>
|
||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
||||
</a>
|
||||
<div class="flex flex-col gap-6">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. `layouts/auth/card.blade.php` (Card-based Auth Layout)
|
||||
**Path:** `resources/views/layouts/auth/card.blade.php`
|
||||
|
||||
Card-based authentication layout with rounded borders and shadow. Alternative to simple layout.
|
||||
|
||||
**Key Features:**
|
||||
- Centered card with white background and dark border
|
||||
- Max-width constraint
|
||||
- Padding inside card (px-10 py-8)
|
||||
- Light neutral-100 background
|
||||
- Dark mode: stone-950 card bg with stone-800 border
|
||||
|
||||
```blade
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
||||
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
||||
<div class="flex w-full max-w-md flex-col gap-6">
|
||||
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
||||
</span>
|
||||
|
||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
||||
<div class="px-10 py-8">{{ $slot }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. `layouts/auth/split.blade.php` (Split-screen Auth Layout)
|
||||
**Path:** `resources/views/layouts/auth/split.blade.php`
|
||||
|
||||
Split-screen layout with marketing content on left (desktop only) and form on right.
|
||||
|
||||
**Key Features:**
|
||||
- Two-column layout on desktop (lg:grid-cols-2)
|
||||
- Left side: Dark background (neutral-900) with motivational quote (hidden on mobile)
|
||||
- Right side: Form content
|
||||
- Logo centered on mobile, top-left on desktop (left side)
|
||||
- Quote display with blockquote
|
||||
- Uses Flux heading component
|
||||
- Full viewport height (h-dvh)
|
||||
|
||||
```blade
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
||||
<head>
|
||||
@include('partials.head')
|
||||
</head>
|
||||
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
||||
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
|
||||
<div class="absolute inset-0 bg-neutral-900"></div>
|
||||
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
|
||||
</span>
|
||||
{{ config('app.name', 'Laravel') }}
|
||||
</a>
|
||||
|
||||
@php
|
||||
[$message, $author] = str(Illuminate\Foundation\Inspiring::quotes()->random())->explode('-');
|
||||
@endphp
|
||||
|
||||
<div class="relative z-20 mt-auto">
|
||||
<blockquote class="space-y-2">
|
||||
<flux:heading size="lg">“{{ trim($message) }}”</flux:heading>
|
||||
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:p-8">
|
||||
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
|
||||
<span class="flex h-9 w-9 items-center justify-center rounded-md">
|
||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
||||
</span>
|
||||
|
||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
||||
</a>
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@fluxScripts
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Head/Partial Layouts
|
||||
|
||||
### 7. `partials/head.blade.php`
|
||||
**Path:** `resources/views/partials/head.blade.php`
|
||||
|
||||
Shared head section included in all layouts.
|
||||
|
||||
**Content:**
|
||||
- Meta charset and viewport
|
||||
- Dynamic title generation
|
||||
- Favicon setup (ico, svg, apple-touch-icon)
|
||||
- Fonts: Instrument Sans from fonts.bunny.net (weights 400, 500, 600)
|
||||
- Vite asset loading (CSS and JS)
|
||||
- Flux appearance directive (dark mode toggle support)
|
||||
|
||||
```blade
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>
|
||||
{{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }}
|
||||
</title>
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
||||
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@fluxAppearance
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Pattern
|
||||
|
||||
| Route | Layout Chain | Use Case |
|
||||
|-------|-----------|----------|
|
||||
| `/` (home) | Custom (not using these layouts) | Landing page |
|
||||
| `/dashboard` | `layouts/app` → `layouts/app/sidebar` | Authenticated user dashboard |
|
||||
| `/login`, `/register` | `layouts/auth` → `layouts/auth/simple` | Simple auth forms |
|
||||
| `/stations` | StationSearch Livewire component (no layout wrapper) | Public search page |
|
||||
| `/settings/*` | `layouts/app` → `layouts/app/sidebar` | Settings pages |
|
||||
|
||||
---
|
||||
|
||||
## Color Scheme in Layouts
|
||||
|
||||
- **Light Mode:** white background, zinc borders
|
||||
- **Dark Mode:** zinc-800 body, zinc-900 header/sidebar, zinc-700 borders
|
||||
- **Auth Pages (Dark):** linear gradient from neutral-950 to neutral-900
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
# Pages & Full-Page Components
|
||||
|
||||
## Page Dependency Trees
|
||||
|
||||
Pages are organized by their view location and dependencies. Livewire components with full-page routes are documented here.
|
||||
|
||||
---
|
||||
|
||||
## 1. StationSearch (Public Livewire Component)
|
||||
**Route:** `/stations`
|
||||
**Component:** `App\Livewire\Public\StationSearch`
|
||||
**View:** `resources/views/livewire/public/station-search.blade.php`
|
||||
|
||||
### Dependency Tree
|
||||
|
||||
```
|
||||
StationSearch (Livewire Component)
|
||||
├── resources/views/livewire/public/station-search.blade.php
|
||||
│ ├── flux:heading (component)
|
||||
│ ├── flux:subheading (component)
|
||||
│ ├── form > flux:input (Fuel location input)
|
||||
│ ├── form > flux:select (Fuel type selector)
|
||||
│ │ └── Options: Petrol, E5, Diesel, Premium Diesel, B10, HVO
|
||||
│ ├── form > flux:select (Radius selector)
|
||||
│ │ └── Options: 1, 2, 5, 10, 20 miles
|
||||
│ ├── form > flux:select (Sort selector)
|
||||
│ │ └── Options: Price, Distance, Updated, Brand, Reliable
|
||||
│ ├── form > flux:button (Search button)
|
||||
│ ├── Error display (conditional)
|
||||
│ ├── Results meta (count, cheapest, average)
|
||||
│ ├── x-data="stationMap(...)" (Alpine.js map)
|
||||
│ │ └── resources/js/maps/station-map.js
|
||||
│ │ ├── Leaflet map initialization
|
||||
│ │ ├── OpenStreetMap tiles
|
||||
│ │ └── Circle markers (station data)
|
||||
│ ├── Legend (color codes for data age)
|
||||
│ └── Results list
|
||||
│ └── Station cards (name, address, price, distance, age classification)
|
||||
│ └── flux:badge (Supermarket tag, conditional)
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **PHP Class** (`App\Livewire\Public\StationSearch`):
|
||||
- Properties: `$search`, `$fuelType`, `$radius`, `$sort`
|
||||
- Properties: `$results[]`, `$meta[]`, `$apiError`
|
||||
- Methods:
|
||||
- `updatedFuelType()`: Refetch on fuel type change
|
||||
- `updatedRadius()`: Refetch on radius change
|
||||
- `updatedSort()`: Refetch on sort change
|
||||
- `findStations()`: HTTP request to `/api/stations`
|
||||
- `render()`: Returns view
|
||||
|
||||
2. **View Interactions**:
|
||||
- `wire:model` on search input (two-way binding)
|
||||
- `wire:model.live` on selectors (reactive updates)
|
||||
- `wire:submit="findStations"` on form submit
|
||||
- `wire:loading` for button state
|
||||
- `wire:target="findStations"` for loading indicator
|
||||
- `@entangle('results')` for Alpine.js map data
|
||||
|
||||
3. **JavaScript**:
|
||||
- `stationMap(results)` Alpine data object
|
||||
- Watches for `results` property changes
|
||||
- Renders/updates Leaflet markers dynamically
|
||||
|
||||
---
|
||||
|
||||
## 2. Dashboard
|
||||
**Route:** `/dashboard`
|
||||
**View:** `resources/views/dashboard.blade.php`
|
||||
**Middleware:** `auth`, `verified`
|
||||
|
||||
### Dependency Tree
|
||||
|
||||
```
|
||||
dashboard.blade.php
|
||||
├── Layout: x-layouts::app (authenticated layout)
|
||||
│ └── layouts/app.blade.php
|
||||
│ └── x-layouts::app.sidebar (sidebar layout)
|
||||
│ └── layouts/app/sidebar.blade.php
|
||||
│ ├── partials/head.blade.php
|
||||
│ ├── flux:header (navigation)
|
||||
│ │ ├── x-app-logo
|
||||
│ │ ├── flux:navbar with Dashboard link
|
||||
│ │ ├── Secondary icons (Search, Repo, Docs)
|
||||
│ │ └── x-desktop-user-menu
|
||||
│ └── flux:sidebar (mobile navigation)
|
||||
│
|
||||
├── flux:main (page content wrapper)
|
||||
│
|
||||
└── Content: Grid of placeholder cards
|
||||
└── x-placeholder-pattern (diagonal line SVG pattern)
|
||||
└── 3 aspect-video cards (top row)
|
||||
└── 1 flex-1 card (bottom, full height)
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
- Placeholder cards for future dashboard widgets
|
||||
- Responsive grid: 3 columns on desktop, stacked on mobile
|
||||
- Dark mode support with adjusted stroke colors
|
||||
|
||||
---
|
||||
|
||||
## 3. Welcome/Home Page
|
||||
**Route:** `/`
|
||||
**View:** `resources/views/welcome.blade.php`
|
||||
|
||||
### Note
|
||||
|
||||
The welcome view is large (>15KB) and contains:
|
||||
- Full HTML with embedded Tailwind CSS
|
||||
- Hero section with Fuel Price branding
|
||||
- CTA buttons (Search Stations, View Source)
|
||||
- Feature cards
|
||||
- Dark mode support
|
||||
- No layout wrapper (standalone)
|
||||
|
||||
(Full content available in actual file due to size)
|
||||
|
||||
---
|
||||
|
||||
## 4. Settings Pages
|
||||
**Route:** `/settings/*`
|
||||
**Layout:** `x-layouts::app` → `layouts/app/sidebar.blade.php`
|
||||
|
||||
### 4.1 Profile Settings
|
||||
**Route:** `/settings/profile`
|
||||
**Livewire Component:** `pages::settings.profile`
|
||||
|
||||
### Dependency Tree
|
||||
|
||||
```
|
||||
settings/profile (Livewire Component)
|
||||
├── Layout: x-layouts::app
|
||||
│ └── layouts/app/sidebar.blade.php
|
||||
│
|
||||
├── partials/settings-heading.blade.php
|
||||
│ ├── flux:heading ("Settings")
|
||||
│ ├── flux:subheading ("Manage your profile...")
|
||||
│ └── flux:separator
|
||||
│
|
||||
├── pages/settings/layout.blade.php (settings sidebar layout)
|
||||
│ ├── flux:navlist (navigation)
|
||||
│ │ ├── flux:navlist.item (Profile - current)
|
||||
│ │ ├── flux:navlist.item (Security)
|
||||
│ │ └── flux:navlist.item (Appearance)
|
||||
│ │
|
||||
│ └── Slot content (form fields)
|
||||
│ └── Profile edit form (Livewire form)
|
||||
│ ├── flux:input (Name)
|
||||
│ ├── flux:input (Email)
|
||||
│ ├── flux:button (Save)
|
||||
│ └── Action messages on update
|
||||
```
|
||||
|
||||
### 4.2 Security Settings
|
||||
**Route:** `/settings/security`
|
||||
**Livewire Component:** `pages::settings.security`
|
||||
|
||||
- Two-factor authentication setup/management
|
||||
- Recovery codes display
|
||||
- Password confirmation (optional middleware)
|
||||
|
||||
### 4.3 Appearance Settings
|
||||
**Route:** `/settings/appearance`
|
||||
**Livewire Component:** `pages::settings.appearance`
|
||||
|
||||
- Theme selection (light/dark mode)
|
||||
- Preference persistence
|
||||
|
||||
---
|
||||
|
||||
## 5. Authentication Pages
|
||||
**Layout:** `x-layouts::auth` → Various auth layouts
|
||||
|
||||
### 5.1 Login
|
||||
**Route:** `/login`
|
||||
**View:** `resources/views/pages/auth/login.blade.php`
|
||||
**Layout:** `layouts/auth/simple.blade.php`
|
||||
|
||||
### Dependency Tree
|
||||
|
||||
```
|
||||
login.blade.php
|
||||
├── Layout: x-layouts::auth
|
||||
│ └── layouts/auth/simple.blade.php
|
||||
│ ├── partials/head.blade.php
|
||||
│ └── Centered login form
|
||||
│
|
||||
├── x-auth-header (title + description)
|
||||
├── x-auth-session-status (success message display)
|
||||
├── form (POST to login.store)
|
||||
│ ├── csrf token
|
||||
│ ├── flux:input (Email)
|
||||
│ ├── flux:input (Password, viewable)
|
||||
│ │ └── flux:link to password.request (forgot password)
|
||||
│ ├── flux:checkbox (Remember me)
|
||||
│ └── flux:button (Log in, primary)
|
||||
│
|
||||
└── Sign up link (flux:link to register)
|
||||
```
|
||||
|
||||
### 5.2 Register
|
||||
**Route:** `/register`
|
||||
**View:** `resources/views/pages/auth/register.blade.php`
|
||||
**Layout:** `layouts/auth/simple.blade.php`
|
||||
|
||||
```
|
||||
register.blade.php
|
||||
├── Layout: layouts/auth/simple.blade.php
|
||||
├── x-auth-header
|
||||
├── x-auth-session-status
|
||||
├── form (POST to register.store)
|
||||
│ ├── flux:input (Name)
|
||||
│ ├── flux:input (Email)
|
||||
│ ├── flux:input (Password, viewable)
|
||||
│ ├── flux:input (Password Confirmation, viewable)
|
||||
│ └── flux:button (Create account, primary)
|
||||
└── Log in link
|
||||
```
|
||||
|
||||
### 5.3 Password Reset
|
||||
**Route:** `/forgot-password` and `/reset-password/{token}`
|
||||
**Views:** `pages/auth/forgot-password.blade.php`, `pages/auth/reset-password.blade.php`
|
||||
**Layout:** `layouts/auth/simple.blade.php`
|
||||
|
||||
### 5.4 Email Verification
|
||||
**Route:** `/verify-email`
|
||||
**View:** `pages/auth/verify-email.blade.php`
|
||||
**Layout:** `layouts/auth/simple.blade.php`
|
||||
|
||||
### 5.5 Two-Factor Challenge
|
||||
**Route:** `/two-factor-challenge`
|
||||
**View:** `pages/auth/two-factor-challenge.blade.php`
|
||||
**Layout:** `layouts/auth/simple.blade.php`
|
||||
|
||||
---
|
||||
|
||||
## Component Include Hierarchy
|
||||
|
||||
### Layout Chain (All Authenticated Pages)
|
||||
|
||||
```
|
||||
Page View
|
||||
└── x-layouts::app (wrapper)
|
||||
└── x-layouts::app.sidebar (main layout)
|
||||
├── partials/head (in <head>)
|
||||
│ ├── CSS imports (Vite)
|
||||
│ ├── @fluxAppearance
|
||||
│ └── Font preload
|
||||
│
|
||||
├── flux:header (desktop navigation)
|
||||
│ ├── x-app-logo
|
||||
│ ├── x-desktop-user-menu
|
||||
│ └── flux:navbar / flux:tooltip components
|
||||
│
|
||||
├── flux:sidebar (mobile navigation)
|
||||
│ ├── x-app-logo (:sidebar="true")
|
||||
│ └── Navigation items
|
||||
│
|
||||
└── flux:main (page content slot)
|
||||
└── Page-specific content
|
||||
```
|
||||
|
||||
### Layout Chain (Auth Pages)
|
||||
|
||||
```
|
||||
Auth Page View
|
||||
└── x-layouts::auth (wrapper)
|
||||
└── x-layouts::auth.simple (centered layout)
|
||||
├── partials/head (in <head>)
|
||||
└── Centered card with form
|
||||
├── x-app-logo-icon
|
||||
└── x-auth-header or form content
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page Transitions
|
||||
|
||||
All pages use `wire:navigate` for Livewire navigation (no full page reload).
|
||||
|
||||
Example:
|
||||
- `wire:navigate` on `<flux:brand>` links
|
||||
- `wire:navigate` on `<flux:link>` components
|
||||
- `wire:navigate` on navigation items
|
||||
|
||||
This enables seamless SPA-like experience.
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
# Routes
|
||||
|
||||
## Public Routes
|
||||
|
||||
### `routes/web.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Livewire\Public\StationSearch;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::view('/', 'homepage')->name('home');
|
||||
|
||||
Route::get('/stations', StationSearch::class)->name('stations.search');
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::view('dashboard', 'dashboard')->name('dashboard');
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Settings Routes
|
||||
|
||||
### `routes/settings.php`
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
Route::redirect('settings', 'settings/profile');
|
||||
|
||||
Route::livewire('settings/profile', 'pages::settings.profile')->name('profile.edit');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit');
|
||||
|
||||
Route::livewire('settings/security', 'pages::settings.security')
|
||||
->middleware(
|
||||
when(
|
||||
Features::canManageTwoFactorAuthentication()
|
||||
&& Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
|
||||
['password.confirm'],
|
||||
[],
|
||||
),
|
||||
)
|
||||
->name('security.edit');
|
||||
});
|
||||
```
|
||||
|
||||
**Note:** Auth routes (login, register, password reset, etc.) are provided by Laravel Fortify. See `config/fortify.php` for route definitions.
|
||||
|
||||
---
|
||||
|
||||
## Route Summary Table
|
||||
|
||||
| Method | Path | Name | Component/View | Middleware | Purpose |
|
||||
|--------|------|------|---|---|---|
|
||||
| GET | `/` | `home` | `welcome` view | public | Landing page |
|
||||
| GET | `/stations` | `stations.search` | `StationSearch` Livewire | public | Fuel station search page |
|
||||
| GET | `/dashboard` | `dashboard` | `dashboard` view | `auth`, `verified` | Authenticated user dashboard |
|
||||
| GET/POST | `/login` | `login` | Fortify auth | public | User login |
|
||||
| GET/POST | `/register` | `register` | Fortify auth | public | User registration |
|
||||
| POST | `/logout` | `logout` | Fortify action | `auth` | Logout action |
|
||||
| GET/POST | `/forgot-password` | `password.request` | Fortify auth | public | Password reset request |
|
||||
| GET/POST | `/reset-password/{token}` | `password.reset` | Fortify auth | public | Password reset form |
|
||||
| GET/POST | `/confirm-password` | `password.confirm` | Fortify auth | `auth` | Confirm password (before sensitive actions) |
|
||||
| GET/POST | `/verify-email` | `verification.notice` | Fortify auth | `auth` | Email verification |
|
||||
| GET | `/verify-email/{id}/{hash}` | `verification.verify` | Fortify action | `auth`, `signed` | Verify email action |
|
||||
| GET/POST | `/two-factor-challenge` | `two-factor.login` | Fortify auth | `guest` | Two-factor challenge |
|
||||
| GET | `/settings` | (redirect) | → `settings.profile` | `auth` | Redirect to profile |
|
||||
| GET | `/settings/profile` | `profile.edit` | Livewire `pages::settings.profile` | `auth` | Edit user profile |
|
||||
| GET | `/settings/appearance` | `appearance.edit` | Livewire `pages::settings.appearance` | `auth`, `verified` | Edit appearance preferences |
|
||||
| GET | `/settings/security` | `security.edit` | Livewire `pages::settings.security` | `auth`, `verified`, (optional) `password.confirm` | Edit security settings |
|
||||
|
||||
---
|
||||
|
||||
## Route Grouping
|
||||
|
||||
### Public Routes
|
||||
- Landing page (`/`)
|
||||
- Station search (`/stations`)
|
||||
- Fortify auth routes (login, register, password reset, etc.)
|
||||
|
||||
### Authenticated Routes
|
||||
- Dashboard (`/dashboard`)
|
||||
- Settings pages (`/settings/*`)
|
||||
|
||||
### Settings Routes (Special Handling)
|
||||
- Profile editing: `auth` only
|
||||
- Appearance: `auth`, `verified`
|
||||
- Security: `auth`, `verified`, optional password confirmation
|
||||
|
||||
---
|
||||
|
||||
## Livewire Component Routes
|
||||
|
||||
| Route | Livewire Component | View | Purpose |
|
||||
|-------|---|---|---|
|
||||
| `/stations` | `App\Livewire\Public\StationSearch` | `livewire.public.station-search` | Interactive fuel station search with map |
|
||||
| `/settings/profile` | `pages::settings.profile` | `pages.settings.profile` | User profile management |
|
||||
| `/settings/appearance` | `pages::settings.appearance` | `pages.settings.appearance` | Theme/appearance preferences |
|
||||
| `/settings/security` | `pages::settings.security` | `pages.settings.security` | Security and 2FA settings |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Fortify**: The application uses Laravel Fortify for authentication scaffolding. Auth routes are auto-registered by Fortify package.
|
||||
- **Livewire Routes**: Routes using `Route::livewire()` expect corresponding Livewire components in `app/Livewire/`.
|
||||
- **Middleware Chain**: Routes are protected with `auth` and/or `verified` middleware as appropriate.
|
||||
- **Settings Redirect**: Accessing `/settings` redirects to `/settings/profile` (first settings tab).
|
||||
|
||||
@@ -1,298 +0,0 @@
|
||||
# Design System & Theme
|
||||
|
||||
## Tailwind Configuration
|
||||
|
||||
### `tailwind.config.js`
|
||||
|
||||
File doesn't exist. The project uses Tailwind CSS v4 with `@tailwindcss/vite` plugin for compilation.
|
||||
|
||||
---
|
||||
|
||||
## CSS Configuration
|
||||
|
||||
### `resources/css/app.css`
|
||||
|
||||
```css
|
||||
@import 'leaflet/dist/leaflet.css';
|
||||
@import 'tailwindcss';
|
||||
@import '../../vendor/livewire/flux/dist/flux.css';
|
||||
|
||||
@source '../views';
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php';
|
||||
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
|
||||
--color-zinc-50: #fafafa;
|
||||
--color-zinc-100: #f5f5f5;
|
||||
--color-zinc-200: #e5e5e5;
|
||||
--color-zinc-300: #d4d4d4;
|
||||
--color-zinc-400: #a3a3a3;
|
||||
--color-zinc-500: #737373;
|
||||
--color-zinc-600: #525252;
|
||||
--color-zinc-700: #404040;
|
||||
--color-zinc-800: #262626;
|
||||
--color-zinc-900: #171717;
|
||||
--color-zinc-950: #0a0a0a;
|
||||
|
||||
--color-accent: var(--color-neutral-800);
|
||||
--color-accent-content: var(--color-neutral-800);
|
||||
--color-accent-foreground: var(--color-white);
|
||||
}
|
||||
|
||||
@layer theme {
|
||||
.dark {
|
||||
--color-accent: var(--color-white);
|
||||
--color-accent-content: var(--color-white);
|
||||
--color-accent-foreground: var(--color-neutral-800);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentColor);
|
||||
}
|
||||
}
|
||||
|
||||
[data-flux-field]:not(ui-radio, ui-checkbox) {
|
||||
@apply grid gap-2;
|
||||
}
|
||||
|
||||
[data-flux-label] {
|
||||
@apply !mb-0 !leading-tight;
|
||||
}
|
||||
|
||||
input:focus[data-flux-control],
|
||||
textarea:focus[data-flux-control],
|
||||
select:focus[data-flux-control] {
|
||||
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Colors
|
||||
- **Zinc Palette** (grays): 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
|
||||
- Used for backgrounds, borders, text
|
||||
|
||||
### Accent Colors
|
||||
- **Dark Mode:** White/neutral-800 depending on context
|
||||
- **Light Mode:** neutral-800
|
||||
- **Accent Foreground:** White (light mode), neutral-800 (dark mode)
|
||||
- **Accent Content:** neutral-800 (light), white (dark)
|
||||
|
||||
### Status Colors
|
||||
- **Success:** green-500, green-600
|
||||
- **Warning:** amber-500, amber-400
|
||||
- **Error:** red-500, red-400, red-600
|
||||
- **Info:** slate-500
|
||||
|
||||
### Semantic Colors (from Station Search)
|
||||
- **Current Price (fresh):** green-500 (#22c55e)
|
||||
- **Recent (24-48h):** slate-500 (#64748b)
|
||||
- **Stale (2-5 days):** amber-500 (#f59e0b)
|
||||
- **Outdated (5+ days):** red-500 (#ef4444)
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Family
|
||||
- **Primary:** 'Instrument Sans' (Weights: 400, 500, 600)
|
||||
- **Fallback:** ui-sans-serif, system-ui, sans-serif
|
||||
- **Emoji:** Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji
|
||||
|
||||
### Font Sizes & Weights (via Flux)
|
||||
- **Heading (XL):** Used for page titles
|
||||
- **Heading (LG):** Used for section titles
|
||||
- **Subheading:** Used for descriptions
|
||||
- **Text:** Standard body text
|
||||
- **Label:** Form labels
|
||||
- **Badge:** Small text labels
|
||||
|
||||
---
|
||||
|
||||
## Spacing System
|
||||
|
||||
Based on Tailwind default scale:
|
||||
- **Base Unit:** 4px (1 = 4px)
|
||||
- **Common Spacing:**
|
||||
- `gap-2`: 8px (0.5rem)
|
||||
- `gap-3`: 12px (0.75rem)
|
||||
- `gap-4`: 16px (1rem)
|
||||
- `gap-6`: 24px (1.5rem)
|
||||
- `px-4`: 16px horizontal padding
|
||||
- `py-3`: 12px vertical padding
|
||||
- `p-6`: 24px all sides padding
|
||||
- `p-10`: 40px all sides padding
|
||||
|
||||
---
|
||||
|
||||
## Border & Radius
|
||||
|
||||
### Border Colors
|
||||
- **Light:** `border-zinc-200`
|
||||
- **Dark:** `border-zinc-700` (primary), `border-stone-800` (cards), `border-neutral-800` (split layout)
|
||||
|
||||
### Border Radius
|
||||
- **Input/buttons:** Default Flux radius
|
||||
- **Cards:** `rounded-xl` (larger radius)
|
||||
- **Icons/small:** `rounded-md`
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
### Implementation
|
||||
- Uses `class="dark"` on `<html>` root
|
||||
- Custom variant: `@custom-variant dark (&:where(.dark, .dark *))`
|
||||
- Flux `@fluxAppearance` directive manages theme toggle
|
||||
|
||||
### Dark Mode Colors
|
||||
- **Body BG:** `dark:bg-zinc-800`
|
||||
- **Header/Sidebar BG:** `dark:bg-zinc-900`
|
||||
- **Borders:** `dark:border-zinc-700`
|
||||
- **Text:** `dark:text-zinc-100` (headings), `dark:text-zinc-400` (secondary)
|
||||
|
||||
### Accent in Dark Mode
|
||||
- `--color-accent: var(--color-white)`
|
||||
- `--color-accent-foreground: var(--color-neutral-800)`
|
||||
|
||||
---
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoints (Tailwind defaults)
|
||||
- **sm:** 640px
|
||||
- **md:** 768px
|
||||
- **lg:** 1024px
|
||||
- **xl:** 1280px
|
||||
- **2xl:** 1536px
|
||||
|
||||
### Usage Patterns
|
||||
- `max-lg:hidden` - Hide on mobile/tablet
|
||||
- `lg:hidden` - Hide on desktop
|
||||
- `max-md:flex-col` - Stack on small screens
|
||||
- `sm:flex-row` - Row layout on small+ screens
|
||||
- `md:w-[220px]` - Fixed width on medium+
|
||||
|
||||
---
|
||||
|
||||
## Component-Specific Theming
|
||||
|
||||
### Forms (Flux)
|
||||
- `[data-flux-field]`: grid gap-2 layout
|
||||
- `[data-flux-label]`: No margin-bottom, tight line-height
|
||||
- Focus states: ring-2 ring-accent with ring-offset
|
||||
|
||||
### Flux Appearance
|
||||
- Automatically applied via `@fluxAppearance` directive
|
||||
- Manages light/dark mode toggle
|
||||
- Integrates with browser's prefers-color-scheme
|
||||
|
||||
### Leaflet Map
|
||||
- Uses default OSM tiles
|
||||
- Custom marker colors: green (current), slate (recent), amber (stale), red (outdated)
|
||||
- Markers: 9px radius, white stroke, 2px weight, 85% fill opacity
|
||||
|
||||
---
|
||||
|
||||
## Brand Identity
|
||||
|
||||
### Logo
|
||||
- **Primary Icon:** Custom SVG (geometric design)
|
||||
- **Color:** Adapts to theme (black light mode, white dark mode)
|
||||
- **Name:** "Laravel Starter Kit"
|
||||
- **Contexts:**
|
||||
- Header/nav: `flux:brand`
|
||||
- Sidebar: `flux:sidebar.brand`
|
||||
|
||||
### Loading/Placeholder States
|
||||
- Pattern SVG with diagonal lines
|
||||
- Color: `stroke-gray-900/20` (light) or `stroke-neutral-100/20` (dark)
|
||||
|
||||
---
|
||||
|
||||
## Animations & Transitions
|
||||
|
||||
### Alpine.js Transitions
|
||||
- **Message Dismiss:** `x-show.transition.out.opacity.duration.1500ms`
|
||||
- **Fade effects:** opacity transitions over 1500ms
|
||||
|
||||
### Flux Components
|
||||
- Built-in animations for:
|
||||
- Dropdown menus
|
||||
- Sidebar collapse/expand
|
||||
- Modal opens/closes
|
||||
|
||||
---
|
||||
|
||||
## CSS Custom Properties (Tokens)
|
||||
|
||||
```css
|
||||
--font-sans: 'Instrument Sans', ...
|
||||
--color-zinc-50 through --color-zinc-950
|
||||
--color-accent
|
||||
--color-accent-content
|
||||
--color-accent-foreground
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Vite Build Configuration
|
||||
|
||||
**File:** `vite.config.js`
|
||||
|
||||
```javascript
|
||||
import {
|
||||
defineConfig
|
||||
} from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
laravel({
|
||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
cors: true,
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Tailwind CSS v4 via @tailwindcss/vite plugin
|
||||
- Automatic CSS/JS refresh
|
||||
- CORS enabled for dev server
|
||||
- Ignores Laravel view cache files
|
||||
|
||||
---
|
||||
|
||||
## Flux UI Theme Configuration
|
||||
|
||||
Flux v2 is configured via:
|
||||
- CSS import: `@import '../../vendor/livewire/flux/dist/flux.css'`
|
||||
- Content sources point to Flux stubs for autocomplete
|
||||
- `@fluxAppearance` handles dark mode toggling
|
||||
- `@fluxScripts` loads JavaScript enhancements
|
||||
|
||||
No custom Flux config file exists; uses defaults with CSS customizations.
|
||||
|
||||
54
app/Console/Commands/BackfillSearchAreas.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Search;
|
||||
use App\Services\PostcodeService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BackfillSearchAreas extends Command
|
||||
{
|
||||
protected $signature = 'searches:backfill-areas {--limit=0 : Max distinct areas to resolve this run (0 = no limit)}';
|
||||
|
||||
protected $description = 'Reverse-geocode searches that have no area_label yet';
|
||||
|
||||
public function handle(PostcodeService $postcodes): int
|
||||
{
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$buckets = Search::query()
|
||||
->whereNull('area_label')
|
||||
->select('lat_bucket', 'lng_bucket')
|
||||
->distinct()
|
||||
->when($limit > 0, fn ($query) => $query->limit($limit))
|
||||
->get();
|
||||
|
||||
if ($buckets->isEmpty()) {
|
||||
$this->info('No searches need an area label.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$resolved = 0;
|
||||
$rowsUpdated = 0;
|
||||
|
||||
foreach ($buckets as $bucket) {
|
||||
$label = $postcodes->reverseResolve((float) $bucket->lat_bucket, (float) $bucket->lng_bucket);
|
||||
|
||||
if ($label === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolved++;
|
||||
$rowsUpdated += Search::query()
|
||||
->whereNull('area_label')
|
||||
->where('lat_bucket', $bucket->lat_bucket)
|
||||
->where('lng_bucket', $bucket->lng_bucket)
|
||||
->update(['area_label' => $label]);
|
||||
}
|
||||
|
||||
$this->info("Resolved {$resolved} of {$buckets->count()} areas, updated {$rowsUpdated} search rows.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,13 @@ final class ImportPostcodes extends Command
|
||||
continue;
|
||||
}
|
||||
|
||||
// ONS marks non-geographic postcodes (no grid reference) with a
|
||||
// placeholder latitude of 99.999999 — drop them so they don't
|
||||
// poison nearest-station distance maths with a bogus location.
|
||||
if (abs((float) $lat) >= 90) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
|
||||
|
||||
if ($pcd === '' || strlen($pcd) < 5) {
|
||||
|
||||
@@ -28,6 +28,11 @@ class SearchResource extends Resource
|
||||
->label('Searched At')
|
||||
->dateTime('d M Y H:i')
|
||||
->sortable(),
|
||||
TextColumn::make('area_label')
|
||||
->label('Area')
|
||||
->placeholder('Unknown')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('fuel_type')
|
||||
->label('Fuel Type')
|
||||
->badge(),
|
||||
@@ -67,6 +72,15 @@ class SearchResource extends Resource
|
||||
'B10' => 'B10',
|
||||
'HVO' => 'HVO',
|
||||
]),
|
||||
SelectFilter::make('area_label')
|
||||
->label('Area')
|
||||
->searchable()
|
||||
->options(fn (): array => Search::query()
|
||||
->whereNotNull('area_label')
|
||||
->distinct()
|
||||
->orderBy('area_label')
|
||||
->pluck('area_label', 'area_label')
|
||||
->all()),
|
||||
])
|
||||
->recordActions([])
|
||||
->toolbarActions([]);
|
||||
|
||||
60
app/Filament/Resources/WaitlistSubscriberResource.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\NavigationGroup;
|
||||
use App\Filament\Resources\WaitlistSubscriberResource\Pages\ListWaitlistSubscribers;
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class WaitlistSubscriberResource extends Resource
|
||||
{
|
||||
protected static ?string $model = WaitlistSubscriber::class;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Users;
|
||||
|
||||
protected static ?string $navigationLabel = 'Waitlist';
|
||||
|
||||
protected static ?int $navigationSort = 3;
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->searchable(),
|
||||
TextColumn::make('email')
|
||||
->searchable()
|
||||
->copyable(),
|
||||
TextColumn::make('source')
|
||||
->badge()
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
TextColumn::make('referrer')
|
||||
->limit(40)
|
||||
->placeholder('—')
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('created_at')
|
||||
->label('Joined')
|
||||
->dateTime('d M Y H:i')
|
||||
->sortable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->recordActions([])
|
||||
->filters([]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListWaitlistSubscribers::route('/'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\WaitlistSubscriberResource\Pages;
|
||||
|
||||
use App\Filament\Resources\WaitlistSubscriberResource;
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ListWaitlistSubscribers extends ListRecords
|
||||
{
|
||||
protected static string $resource = WaitlistSubscriberResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('export')
|
||||
->label('Export CSV')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->action(fn (): StreamedResponse => response()->streamDownload(function (): void {
|
||||
$handle = fopen('php://output', 'wb');
|
||||
|
||||
fputcsv($handle, ['name', 'email', 'source', 'referrer', 'joined_at']);
|
||||
|
||||
WaitlistSubscriber::query()
|
||||
->orderBy('created_at')
|
||||
->each(function (WaitlistSubscriber $subscriber) use ($handle): void {
|
||||
fputcsv($handle, [
|
||||
$subscriber->name,
|
||||
$subscriber->email,
|
||||
$subscriber->source,
|
||||
$subscriber->referrer,
|
||||
$subscriber->created_at?->toDateTimeString(),
|
||||
]);
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, 'waitlist.csv', ['Content-Type' => 'text/csv'])),
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Controllers/Api/WaitlistController.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\StoreWaitlistRequest;
|
||||
use App\Services\WaitlistService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class WaitlistController extends Controller
|
||||
{
|
||||
public function __construct(private readonly WaitlistService $waitlist) {}
|
||||
|
||||
public function store(StoreWaitlistRequest $request): JsonResponse
|
||||
{
|
||||
$this->waitlist->subscribe(
|
||||
name: $request->string('name')->toString(),
|
||||
email: $request->string('email')->toString(),
|
||||
source: $request->filled('source') ? $request->string('source')->toString() : null,
|
||||
referrer: $request->filled('referrer') ? $request->string('referrer')->toString() : null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => "You're on the list — we'll email you when alerts go live.",
|
||||
], 201);
|
||||
}
|
||||
}
|
||||
26
app/Http/Requests/Api/StoreWaitlistRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreWaitlistRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'source' => ['nullable', 'string', 'max:64'],
|
||||
'referrer' => ['nullable', 'string', 'max:2048'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -129,8 +129,9 @@ final class HandleStripeWebhook
|
||||
|
||||
private function bustPlanCache(User $user): void
|
||||
{
|
||||
$tag = Cache::tags(['plans']);
|
||||
$tag->forget("plan_for_user_{$user->id}");
|
||||
$tag->forget("plan_cadence_for_user_{$user->id}");
|
||||
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
|
||||
|
||||
$cache->forget("plan_for_user_{$user->id}");
|
||||
$cache->forget("plan_cadence_for_user_{$user->id}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['lat_bucket', 'lng_bucket', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
|
||||
#[Fillable(['lat_bucket', 'lng_bucket', 'area_label', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])]
|
||||
class Search extends Model
|
||||
{
|
||||
/** @use HasFactory<SearchFactory> */
|
||||
|
||||
15
app/Models/WaitlistSubscriber.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\WaitlistSubscriberFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['name', 'email', 'source', 'referrer'])]
|
||||
class WaitlistSubscriber extends Model
|
||||
{
|
||||
/** @use HasFactory<WaitlistSubscriberFactory> */
|
||||
use HasFactory;
|
||||
}
|
||||
@@ -52,6 +52,77 @@ class PostcodeService
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-geocode coordinates to a general UK area label (e.g. "Peterborough").
|
||||
*
|
||||
* Coordinates are bucketed to ~1km (2dp) before lookup so the cache is shared
|
||||
* across nearby searches and nothing more precise than the stored bucket is
|
||||
* ever queried. Returns null if the area cannot be determined.
|
||||
*/
|
||||
public function reverseResolve(float $lat, float $lng): ?string
|
||||
{
|
||||
$latBucket = round($lat, 2);
|
||||
$lngBucket = round($lng, 2);
|
||||
$cacheKey = "revgeo:{$latBucket},{$lngBucket}";
|
||||
|
||||
$cached = Cache::get($cacheKey);
|
||||
|
||||
if (is_string($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$label = $this->lookupArea($latBucket, $lngBucket);
|
||||
|
||||
if ($label !== null) {
|
||||
Cache::put($cacheKey, $label, self::CACHE_TTL);
|
||||
}
|
||||
|
||||
return $label;
|
||||
}
|
||||
|
||||
private function lookupArea(float $lat, float $lng): ?string
|
||||
{
|
||||
$url = self::BASE_URL.'/postcodes';
|
||||
// radius=2000 (postcodes.io max): we query the ~1km bucket centroid, which
|
||||
// can sit up to ~780m from any real point in the bucket. The default 100m
|
||||
// radius misses in low-density areas, so widen it to guarantee a hit.
|
||||
$logUrl = $url.'?lon='.$lng.'&lat='.$lat.'&radius=2000&limit=1';
|
||||
|
||||
try {
|
||||
$response = $this->apiLogger->send('postcodes_io', 'GET', $logUrl, fn () => Http::timeout(5)
|
||||
->get($url, ['lon' => $lng, 'lat' => $lat, 'radius' => 2000, 'limit' => 1]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$results = $response->json('result');
|
||||
|
||||
if (! is_array($results) || ! isset($results[0]) || ! is_array($results[0])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer the most human "town/district" field, falling back to broader areas.
|
||||
foreach (['admin_district', 'parish', 'admin_ward', 'region', 'country'] as $field) {
|
||||
$value = $results[0][$field] ?? null;
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (Throwable $e) {
|
||||
Log::error('PostcodeService: reverse geocode failed', [
|
||||
'lat' => $lat,
|
||||
'lng' => $lng,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalisePostcode(string $value): string
|
||||
{
|
||||
return strtoupper(preg_replace('/\s+/', '', $value));
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Services\Forecasting\LocalSnapshotService;
|
||||
use App\Services\Forecasting\WeeklyForecastService;
|
||||
use App\Services\HaversineQuery;
|
||||
use App\Services\PlanFeatures;
|
||||
use App\Services\PostcodeService;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -20,6 +21,7 @@ final class StationSearchService
|
||||
public function __construct(
|
||||
private readonly WeeklyForecastService $weeklyForecast,
|
||||
private readonly LocalSnapshotService $localSnapshot,
|
||||
private readonly PostcodeService $postcodeService,
|
||||
) {}
|
||||
|
||||
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
||||
@@ -118,6 +120,7 @@ final class StationSearchService
|
||||
Search::create([
|
||||
'lat_bucket' => round($criteria->lat, 2),
|
||||
'lng_bucket' => round($criteria->lng, 2),
|
||||
'area_label' => $this->postcodeService->reverseResolve($criteria->lat, $criteria->lng),
|
||||
'fuel_type' => $criteria->fuelType->value,
|
||||
'results_count' => $resultsCount,
|
||||
'lowest_pence' => $prices->min(),
|
||||
|
||||
29
app/Services/WaitlistService.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
|
||||
final class WaitlistService
|
||||
{
|
||||
/**
|
||||
* Add someone to the feature-launch waitlist.
|
||||
*
|
||||
* Idempotent: re-subscribing an existing email is a no-op that returns the
|
||||
* original subscriber unchanged — original meta (source, referrer) is kept,
|
||||
* never a duplicate row or an error.
|
||||
*/
|
||||
public function subscribe(
|
||||
string $name,
|
||||
string $email,
|
||||
?string $source = null,
|
||||
?string $referrer = null,
|
||||
): WaitlistSubscriber {
|
||||
$email = strtolower(trim($email));
|
||||
|
||||
return WaitlistSubscriber::firstOrCreate(
|
||||
['email' => $email],
|
||||
['name' => $name, 'source' => $source, 'referrer' => $referrer],
|
||||
);
|
||||
}
|
||||
}
|
||||
25
database/factories/WaitlistSubscriberFactory.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<WaitlistSubscriber>
|
||||
*/
|
||||
class WaitlistSubscriberFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('searches', function (Blueprint $table): void {
|
||||
$table->string('area_label', 100)
|
||||
->nullable()
|
||||
->after('lng_bucket')
|
||||
->comment('General UK area (e.g. district/town) reverse-geocoded from the lat/lng bucket');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('searches', function (Blueprint $table): void {
|
||||
$table->dropColumn('area_label');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('waitlist_subscribers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->string('source', 64)->nullable()->comment('Where they joined from, e.g. pricing');
|
||||
$table->text('referrer')->nullable()->comment('document.referrer at signup');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('waitlist_subscribers');
|
||||
}
|
||||
};
|
||||
89
deploy.sh
Executable file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# FuelAlert deploy script — run on the VPS from the project root:
|
||||
#
|
||||
# ./deploy.sh # deploy the latest main
|
||||
# ./deploy.sh v0.1.3 # deploy a specific tag
|
||||
#
|
||||
# It puts the site in maintenance mode, updates the code, runs migrations and
|
||||
# cache rebuilds, restarts the queue, then brings the site back up. The SPA is
|
||||
# always rebuilt; the heavier dependency installs (composer install, npm ci)
|
||||
# only run when their lockfiles changed or the installed dir is missing. If any
|
||||
# step fails the script aborts and the site stays in maintenance mode on
|
||||
# purpose — fix the issue, then re-run.
|
||||
#
|
||||
# See docs/ops/deployment.md for first-time setup and troubleshooting.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
REF="${1:-main}"
|
||||
echo "==> Deploying ref: ${REF}"
|
||||
|
||||
# Baseline for change detection: the last *fully* deployed commit, recorded at
|
||||
# the end of a successful run (falls back to current HEAD the first time). Using
|
||||
# a persisted marker instead of the pre-checkout HEAD keeps the diff honest even
|
||||
# when the same ref is re-deployed or a previous run aborted partway — both of
|
||||
# which otherwise make BEFORE == AFTER and silently skip build/install steps.
|
||||
MARKER=".deploy-last-commit"
|
||||
BEFORE="$(cat "${MARKER}" 2>/dev/null || git rev-parse HEAD)"
|
||||
|
||||
echo "==> Maintenance mode on"
|
||||
php artisan down --retry=15
|
||||
|
||||
git fetch --tags --prune origin
|
||||
git checkout "${REF}"
|
||||
|
||||
# Fast-forward to the remote only when on a branch (a tag leaves a detached HEAD).
|
||||
if git symbolic-ref -q HEAD >/dev/null; then
|
||||
git pull --ff-only
|
||||
fi
|
||||
|
||||
AFTER="$(git rev-parse HEAD)"
|
||||
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}" || true)"
|
||||
|
||||
# Reinstall PHP deps only when the lockfile moved or vendor is missing.
|
||||
if grep -q '^composer\.lock$' <<<"${CHANGED}" || [ ! -d vendor ]; then
|
||||
echo "==> Installing PHP deps (composer.lock changed or vendor missing)"
|
||||
composer install --no-dev --optimize-autoloader
|
||||
else
|
||||
echo "==> composer.lock unchanged — skipping composer install"
|
||||
fi
|
||||
|
||||
# Install JS deps only when the lockfile moved or node_modules is missing.
|
||||
if grep -qE '^package(-lock)?\.json$' <<<"${CHANGED}" || [ ! -d node_modules ]; then
|
||||
echo "==> Installing JS deps (lockfile changed or node_modules missing)"
|
||||
npm ci
|
||||
else
|
||||
echo "==> JS deps unchanged — skipping npm ci"
|
||||
fi
|
||||
|
||||
# Always rebuild the SPA. The build is cheap (a few seconds), and gating it on a
|
||||
# git diff silently shipped stale assets whenever BEFORE == AFTER — re-deploying
|
||||
# the same ref, or re-running after an aborted deploy. Correctness over the few
|
||||
# seconds saved.
|
||||
echo "==> Building SPA"
|
||||
npm run build
|
||||
|
||||
echo "==> Running migrations"
|
||||
php artisan migrate --force
|
||||
|
||||
echo "==> Rebuilding caches"
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
|
||||
echo "==> Restarting queue workers"
|
||||
php artisan queue:restart
|
||||
|
||||
echo "==> Maintenance mode off"
|
||||
php artisan up
|
||||
|
||||
# Record the just-deployed commit as the baseline for the next run. Only reached
|
||||
# on full success — `set -e` aborts earlier on any failure, so a broken deploy
|
||||
# never advances the baseline and the next run re-evaluates from the last good one.
|
||||
git rev-parse HEAD > "${MARKER}"
|
||||
|
||||
echo "==> Deploy complete"
|
||||
php artisan about
|
||||
415
docs/ops/deployment.md
Normal file
@@ -0,0 +1,415 @@
|
||||
# VPS Deployment Runbook — FuelAlert
|
||||
|
||||
How to deploy and run this app on the IONOS VPS (Nginx + PHP-FPM + MySQL + Redis).
|
||||
|
||||
Two parts:
|
||||
- **First-time setup** (§1–§7) — done once when provisioning the server.
|
||||
- **Every deploy** (§8) — the short sequence you repeat each time you ship.
|
||||
|
||||
If something breaks after deploy, jump to **§10 Troubleshooting** — most live
|
||||
problems are one of four things.
|
||||
|
||||
---
|
||||
|
||||
## 0. Server prerequisites
|
||||
|
||||
Install these once on the VPS:
|
||||
|
||||
| Software | Why | Notes |
|
||||
|---|---|---|
|
||||
| **PHP 8.4** + FPM | runs the app | extensions: `mbstring, pdo_mysql, redis, intl, bcmath, curl, xml, zip, gd` |
|
||||
| **Composer 2** | PHP deps | |
|
||||
| **Node 22 + npm** | builds the Vue SPA assets | only needed to run `npm run build` |
|
||||
| **MySQL 8** | database | InnoDB |
|
||||
| **Redis** | queue + cache | |
|
||||
| **Nginx** | web server | serves `public/` |
|
||||
| **Git** | pulls the code | |
|
||||
| **Certbot** | HTTPS cert | Sanctum cookie auth requires HTTPS |
|
||||
| **Supervisor** *or* systemd | keeps the queue worker alive | systemd shown below |
|
||||
|
||||
Quick check after install: `php -v`, `composer -V`, `node -v`, `redis-cli ping` (→ `PONG`), `mysql --version`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Get the code
|
||||
|
||||
```bash
|
||||
cd /var/www
|
||||
git clone <your-gitea-repo-url> fuel-alert
|
||||
cd fuel-alert
|
||||
git checkout main # main = live (see §9 for tagging releases)
|
||||
```
|
||||
|
||||
The app lives at `/var/www/fuel-alert`. Adjust paths below if you use another location.
|
||||
|
||||
---
|
||||
|
||||
## 2. Create the production `.env`
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
php artisan key:generate # sets APP_KEY
|
||||
```
|
||||
|
||||
Then edit `.env`. **The values below are the ones that matter for production** —
|
||||
see §11 for the full reference table.
|
||||
|
||||
### Critical — app
|
||||
|
||||
```dotenv
|
||||
APP_NAME=FuelAlert
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false # NEVER true on live — leaks stack traces + secrets
|
||||
APP_URL=https://fuel-alert.co.uk
|
||||
```
|
||||
|
||||
### Critical — SPA cookie/session auth (the #1 "login broke on live" trap)
|
||||
|
||||
This app is a Vue SPA using Sanctum cookie auth. If these don't match your real
|
||||
domain over HTTPS, login/registration fail with 419/401 even though the rest of
|
||||
the site looks fine:
|
||||
|
||||
```dotenv
|
||||
SESSION_DRIVER=redis
|
||||
SESSION_DOMAIN=.fuel-alert.co.uk
|
||||
SESSION_SECURE_COOKIE=true
|
||||
SANCTUM_STATEFUL_DOMAINS=fuel-alert.co.uk
|
||||
```
|
||||
|
||||
### Critical — database / redis / queue / cache
|
||||
|
||||
```dotenv
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=fuel_alert
|
||||
DB_USERNAME=fuel_alert
|
||||
DB_PASSWORD=<strong-password>
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=null
|
||||
|
||||
QUEUE_CONNECTION=redis # a worker MUST run — see §6
|
||||
CACHE_STORE=redis
|
||||
```
|
||||
|
||||
### Critical — your own API gate
|
||||
|
||||
```dotenv
|
||||
API_SECRET_KEY=<long-random-string>
|
||||
```
|
||||
|
||||
`API_SECRET_KEY` gates the station-search API (`VerifyApiKey` middleware). The SPA
|
||||
sends the matching key. If it's missing/wrong, `GET /api/stations` returns 401 and
|
||||
the search page shows nothing.
|
||||
|
||||
### Mail (Ionos SMTP)
|
||||
|
||||
```dotenv
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=smtp.ionos.co.uk
|
||||
MAIL_PORT=587
|
||||
MAIL_SCHEME=tls
|
||||
MAIL_USERNAME=<ionos-mailbox>
|
||||
MAIL_PASSWORD=<ionos-password>
|
||||
MAIL_FROM_ADDRESS=hello@fuel-alert.co.uk
|
||||
MAIL_FROM_NAME=FuelAlert
|
||||
```
|
||||
|
||||
### External data APIs (the product needs these to have live data)
|
||||
|
||||
```dotenv
|
||||
FUEL_FINDER_CLIENT_ID=
|
||||
FUEL_FINDER_CLIENT_SECRET=
|
||||
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
|
||||
FRED_API_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
ANTHROPIC_MODEL=claude-haiku-4-5-20251001
|
||||
EIA_API_KEY=
|
||||
```
|
||||
|
||||
### Notification providers (fill when those channels go live)
|
||||
|
||||
```dotenv
|
||||
ONESIGNAL_APP_ID=
|
||||
ONESIGNAL_API_KEY=
|
||||
VONAGE_KEY=
|
||||
VONAGE_SECRET=
|
||||
VONAGE_WHATSAPP_FROM=
|
||||
VONAGE_SMS_FROM=
|
||||
```
|
||||
|
||||
### Stripe — can be deferred
|
||||
|
||||
If launching free-only first, leave Stripe **test** keys and don't promote paid
|
||||
plans. When you go live with payments, see §7.
|
||||
|
||||
```dotenv
|
||||
CASHIER_CURRENCY=gbp
|
||||
STRIPE_KEY=
|
||||
STRIPE_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_BASIC_MONTHLY=
|
||||
STRIPE_PRICE_BASIC_ANNUAL=
|
||||
STRIPE_PRICE_PLUS_MONTHLY=
|
||||
STRIPE_PRICE_PLUS_ANNUAL=
|
||||
STRIPE_PRICE_PRO_MONTHLY=
|
||||
STRIPE_PRICE_PRO_ANNUAL=
|
||||
```
|
||||
|
||||
> **Remember:** after ANY `.env` change on a cached production box, re-run
|
||||
> `php artisan config:cache` or the change won't take effect (see §8).
|
||||
|
||||
---
|
||||
|
||||
## 3. Install dependencies & build
|
||||
|
||||
```bash
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci && npm run build # compiles the Vue SPA into public/build
|
||||
```
|
||||
|
||||
`--no-dev` skips dev-only packages. `npm run build` is required — without it the
|
||||
SPA has no compiled assets and you get a blank page / Vite manifest error.
|
||||
|
||||
---
|
||||
|
||||
## 4. Database: migrate + seed plans
|
||||
|
||||
```bash
|
||||
php artisan migrate --force # --force = run in production non-interactively
|
||||
php artisan db:seed --class=PlanSeeder --force # REQUIRED
|
||||
```
|
||||
|
||||
> **Do not** run `migrate:fresh`, `migrate:reset`, or `db:wipe` on the server —
|
||||
> they destroy data. Only `migrate` (forward) is safe.
|
||||
|
||||
`PlanSeeder` populates the `plans` table. The entire tier/entitlement system
|
||||
(`PlanFeatures`) resolves through these rows — skip it and features misbehave for
|
||||
every user. It's idempotent, so it's safe to re-run.
|
||||
|
||||
---
|
||||
|
||||
## 5. Storage link + production caches
|
||||
|
||||
```bash
|
||||
php artisan storage:link
|
||||
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
```
|
||||
|
||||
The cache commands make production fast. Trade-off: cached config ignores later
|
||||
`.env` edits until you re-run `config:cache`.
|
||||
|
||||
### File permissions
|
||||
|
||||
The web user (usually `www-data`) must be able to write to two dirs:
|
||||
|
||||
```bash
|
||||
sudo chown -R www-data:www-data storage bootstrap/cache
|
||||
sudo find storage -type d -exec chmod 775 {} \;
|
||||
sudo find storage -type f -exec chmod 664 {} \;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Background processes (the part everyone forgets)
|
||||
|
||||
The app is not just web requests. Two things must run continuously or the product
|
||||
silently stops working.
|
||||
|
||||
### 6a. Scheduler (cron) — keeps prices & predictions fresh
|
||||
|
||||
The app schedules the entire data pipeline: `fuel:poll`, `oil:fetch`,
|
||||
`forecast:llm-overlay`, `beis:import`, `forecast:resolve-outcomes`,
|
||||
`forecast:evaluate-volatility`, `fuel:archive`, plus morning/evening WhatsApp jobs.
|
||||
Without cron, live data goes stale.
|
||||
|
||||
Add ONE cron entry (`crontab -e` as the app user):
|
||||
|
||||
```cron
|
||||
* * * * * cd /var/www/fuel-alert && php artisan schedule:run >> /dev/null 2>&1
|
||||
```
|
||||
|
||||
Laravel's scheduler decides internally which task runs when — you only need this
|
||||
one line.
|
||||
|
||||
### 6b. Queue worker — sends notifications, processes polling jobs
|
||||
|
||||
Notifications and polling run as queued jobs. No worker = nothing ever sends.
|
||||
Run it as a systemd service so it restarts on crash/reboot.
|
||||
|
||||
Create `/etc/systemd/system/fuelalert-worker.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=FuelAlert queue worker
|
||||
After=network.target redis.service mysql.service
|
||||
|
||||
[Service]
|
||||
User=www-data
|
||||
Group=www-data
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
WorkingDirectory=/var/www/fuel-alert
|
||||
ExecStart=/usr/bin/php artisan queue:work redis --queue=notifications,default --tries=3 --max-time=3600
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable it:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now fuelalert-worker
|
||||
sudo systemctl status fuelalert-worker # should be "active (running)"
|
||||
```
|
||||
|
||||
> The `notifications` queue is listed first so alerts get priority over default jobs.
|
||||
|
||||
---
|
||||
|
||||
## 7. Nginx + HTTPS
|
||||
|
||||
Server block (`/etc/nginx/sites-available/fuel-alert`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name fuel-alert.co.uk www.fuel-alert.co.uk;
|
||||
root /var/www/fuel-alert/public;
|
||||
|
||||
index index.php;
|
||||
charset utf-8;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass unix:/run/php/php8.4-fpm.sock;
|
||||
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||
include fastcgi_params;
|
||||
}
|
||||
|
||||
location ~ /\.(?!well-known).* { deny all; }
|
||||
client_max_body_size 20M;
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/fuel-alert /etc/nginx/sites-enabled/
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
sudo certbot --nginx -d fuel-alert.co.uk -d www.fuel-alert.co.uk # HTTPS — required for secure cookies
|
||||
```
|
||||
|
||||
`root` points at `public/`, never the project root. The SPA routing is handled by
|
||||
Laravel's catch-all in `routes/web.php` via `index.php`, so the standard
|
||||
`try_files … /index.php` block is all you need.
|
||||
|
||||
---
|
||||
|
||||
## 8. Every deploy (the repeatable sequence)
|
||||
|
||||
After the first-time setup, each deploy is just this. Save it as
|
||||
`deploy.sh` in the project root (`chmod +x deploy.sh`) and run `./deploy.sh`:
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd /var/www/fuel-alert
|
||||
|
||||
php artisan down --render="errors::503" # maintenance mode (optional)
|
||||
|
||||
git fetch --tags
|
||||
git checkout "${1:-main}" # ./deploy.sh v0.2.0 → deploy a tag; no arg → main
|
||||
git pull --ff-only || true
|
||||
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci && npm run build
|
||||
|
||||
php artisan migrate --force
|
||||
|
||||
# refresh caches (config:cache picks up any .env changes)
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
php artisan view:cache
|
||||
php artisan event:cache
|
||||
|
||||
php artisan queue:restart # workers reload the NEW code
|
||||
php artisan up
|
||||
|
||||
php artisan about # sanity check: env=production, debug=false
|
||||
```
|
||||
|
||||
> `queue:restart` is important: long-running workers keep the OLD code in memory
|
||||
> until told to restart. Skip it and your new code won't run in queued jobs.
|
||||
>
|
||||
> Only run `db:seed --class=PlanSeeder --force` again if you changed plan/feature
|
||||
> definitions — it's safe (idempotent) but usually unnecessary per deploy.
|
||||
|
||||
---
|
||||
|
||||
## 9. Tagging a release (rollback points)
|
||||
|
||||
`main` is live. Tag the commit you actually deploy so you have a named, verified
|
||||
rollback point:
|
||||
|
||||
```bash
|
||||
# locally, once the deploy is confirmed working:
|
||||
git tag -a v0.1.0 -m "first live version"
|
||||
git push origin v0.1.0
|
||||
```
|
||||
|
||||
Roll back by deploying an older tag: `./deploy.sh v0.1.0`. List tags: `git tag`.
|
||||
Bump the middle number for meaningful releases, the last for small fixes.
|
||||
|
||||
---
|
||||
|
||||
## 10. Troubleshooting — the four usual suspects
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Login/register fails with **419** or **401** | SPA cookie domains wrong | Check `APP_URL`, `SESSION_DOMAIN`, `SANCTUM_STATEFUL_DOMAINS`, `SESSION_SECURE_COOKIE` in `.env`, then `config:cache` |
|
||||
| Station search returns **401 / empty** | `API_SECRET_KEY` missing or mismatched | Set it in `.env`, `config:cache`, rebuild SPA if the key is baked into the build |
|
||||
| Prices/predictions are **stale or empty** | scheduler cron not running | Verify the `* * * * *` cron line; test with `php artisan schedule:run` manually |
|
||||
| Notifications **never arrive** | queue worker not running | `systemctl status fuelalert-worker`; check `storage/logs/laravel.log` |
|
||||
| `.env` change **had no effect** | config is cached | `php artisan config:cache` |
|
||||
| **Blank page** / "Vite manifest not found" | assets not built | `npm ci && npm run build` |
|
||||
| **500** right after deploy | permissions on storage | re-run the `chown`/`chmod` in §5; check `storage/logs/laravel.log` |
|
||||
|
||||
Useful commands: `php artisan about` (env summary), `tail -f storage/logs/laravel.log`
|
||||
(live errors), `redis-cli ping`, `sudo systemctl status fuelalert-worker`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Environment variable reference
|
||||
|
||||
Keys that need real production values (from `.env.example`):
|
||||
|
||||
**App:** `APP_NAME` `APP_ENV=production` `APP_KEY` `APP_DEBUG=false` `APP_URL`
|
||||
**Session/SPA:** `SESSION_DRIVER` `SESSION_DOMAIN` `SESSION_SECURE_COOKIE` `SANCTUM_STATEFUL_DOMAINS`
|
||||
**Database:** `DB_CONNECTION` `DB_HOST` `DB_PORT` `DB_DATABASE` `DB_USERNAME` `DB_PASSWORD`
|
||||
**Redis/queue/cache:** `REDIS_CLIENT` `REDIS_HOST` `REDIS_PORT` `REDIS_PASSWORD` `QUEUE_CONNECTION` `CACHE_STORE`
|
||||
**Your API gate:** `API_SECRET_KEY`
|
||||
**Mail (Ionos):** `MAIL_MAILER` `MAIL_HOST` `MAIL_PORT` `MAIL_SCHEME` `MAIL_USERNAME` `MAIL_PASSWORD` `MAIL_FROM_ADDRESS` `MAIL_FROM_NAME`
|
||||
**Fuel data:** `FUEL_FINDER_CLIENT_ID` `FUEL_FINDER_CLIENT_SECRET` `FUEL_FINDER_BASE_URL` `FRED_API_KEY` `EIA_API_KEY`
|
||||
**LLM:** `ANTHROPIC_API_KEY` `ANTHROPIC_MODEL` `LLM_PREDICTION_PROVIDER`
|
||||
**Notifications:** `ONESIGNAL_APP_ID` `ONESIGNAL_API_KEY` `VONAGE_KEY` `VONAGE_SECRET` `VONAGE_WHATSAPP_FROM` `VONAGE_SMS_FROM`
|
||||
**Stripe (deferrable):** `STRIPE_KEY` `STRIPE_SECRET` `STRIPE_WEBHOOK_SECRET` `CASHIER_CURRENCY` `STRIPE_PRICE_*`
|
||||
|
||||
### Stripe go-live (when payments launch)
|
||||
|
||||
1. Swap in live `STRIPE_KEY` / `STRIPE_SECRET` and all six `STRIPE_PRICE_*` IDs.
|
||||
2. In the Stripe dashboard, add a webhook endpoint:
|
||||
`https://fuel-alert.co.uk/stripe/webhook`
|
||||
3. Copy its signing secret into `STRIPE_WEBHOOK_SECRET`.
|
||||
4. `php artisan config:cache`.
|
||||
5. Configure Stripe dashboard retries (days 1/3/5, cancel after final) for the
|
||||
grace-period dunning flow.
|
||||
112
docs/superpowers/specs/2026-06-12-inline-fuel-pills-design.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Inline Petrol/Diesel Quick Pills — Design
|
||||
|
||||
Date: 2026-06-12
|
||||
Status: Approved (brainstorm)
|
||||
Branch: `feature/inline-fuel-pills`
|
||||
|
||||
## Problem
|
||||
|
||||
After a station search, the only way to change fuel type is to open the
|
||||
"Filters" popover, which buries the two everyday fuels — Petrol (`e10`) and
|
||||
Diesel (`b7_standard`) — behind a button. Almost every user wants one of those
|
||||
two. We want them surfaced as one-tap pills directly in the results filter bar,
|
||||
while keeping the four long-tail fuels (Premium `e5`, Prem Diesel `b7_premium`,
|
||||
`b10`, `hvo`) reachable.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Single file:** `resources/js/components/PostSearchFilters.vue`.
|
||||
- **No backend/API change** — server-side fuel filtering already exists via the
|
||||
`fuel_type` query param on `/api/stations`.
|
||||
- **`Home.vue` unchanged** — the pills reuse the existing `search` event
|
||||
contract the popover already emits.
|
||||
- **`HeroSearch.vue` unchanged** — placement is results-bar only (the hero keeps
|
||||
forwarding whatever `fuelType` is in the URL).
|
||||
|
||||
## Layout
|
||||
|
||||
The top row of the filter bar changes from a single right-aligned "Filters"
|
||||
button to:
|
||||
|
||||
```
|
||||
[ ⛽ Petrol ] [ Diesel ] ……………… [ ≡ Filters ▾ ]
|
||||
```
|
||||
|
||||
- Two fuel pills left-aligned; the Filters pill pushed right via `ml-auto`.
|
||||
- All three reuse the existing `pill !rounded-xl` class + `is-active` state, so
|
||||
they are visually identical (the user's explicit "same row, same style"
|
||||
requirement).
|
||||
- ⛽ (`lucide:fuel`) icon on Petrol only; Diesel is label-only (matches the
|
||||
user's mock).
|
||||
- The row stays `flex flex-wrap items-center` so it wraps gracefully on narrow
|
||||
screens.
|
||||
|
||||
## Fuel model
|
||||
|
||||
- **Primary fuels (pills):** `e10` → "Petrol", `b7_standard` → "Diesel".
|
||||
- **Long-tail fuels (popover "More fuels"):** every other entry in `FUEL_TYPES`
|
||||
— `e5`, `b7_premium`, `b10`, `hvo`. Derived by filtering `FUEL_TYPES` to
|
||||
exclude the two primary values, so the list stays driven by the shared
|
||||
`window.FUEL_TYPES` source rather than a second hard-coded list.
|
||||
|
||||
The two primary fuel values (`e10`, `b7_standard`) are defined once as a local
|
||||
constant in the component, with the short pill labels ("Petrol" / "Diesel").
|
||||
The canonical enum labels ("Petrol (E10)", "Diesel (B7)") are intentionally not
|
||||
used on the pills — the short forms are a UX shortening.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- Tapping **Petrol** sets `fuelType = 'e10'`; tapping **Diesel** sets
|
||||
`fuelType = 'b7_standard'`. Either triggers an immediate re-search via the
|
||||
existing `watch([fuelType, radius, sort])` — no new emit wiring.
|
||||
- The popover "Fuel" section is relabeled **"More fuels"** and renders only the
|
||||
long-tail fuels. Selecting one re-searches and drops both pill highlights.
|
||||
|
||||
## Highlighting — the selected fuel is always visible
|
||||
|
||||
- `e10` active → **Petrol** pill `is-active`.
|
||||
- `b7_standard` active → **Diesel** pill `is-active`.
|
||||
- A long-tail fuel active → neither pill highlighted, **but** the selected fuel
|
||||
is highlighted inside the "More fuels" grid, and the Filters pill shows its
|
||||
active state + badge. Selection is never ambiguous in any state.
|
||||
|
||||
## Filters badge / active count
|
||||
|
||||
- Fuel contributes to the Filters badge (`activeCount`) **only when a long-tail
|
||||
fuel is active** — the pills already display Petrol/Diesel selection, so those
|
||||
need no badge.
|
||||
- Radius, sort, and brand contribute to `activeCount` as before.
|
||||
- `hasActive` (which controls the "Clear all" affordance) likewise treats fuel
|
||||
as active only when a long-tail fuel is selected.
|
||||
|
||||
## Clear all
|
||||
|
||||
- Resets radius / sort / brand to their defaults.
|
||||
- If a long-tail fuel is selected, snaps fuel back to Petrol (`e10`) so the badge
|
||||
clears.
|
||||
- A Petrol/Diesel pill choice is left alone (the default is Petrol anyway).
|
||||
|
||||
## Accessibility
|
||||
|
||||
- The two pills form a `role="radiogroup"` labelled "Fuel" with `aria-checked`
|
||||
on each, consistent with the existing radio pattern already used in this
|
||||
component for radius/sort/brand.
|
||||
- The "More fuels" grid remains its own radiogroup.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No new filter criteria (supermarket-only, open-now, amenities, price ceiling).
|
||||
- No hero fuel selector.
|
||||
- No server-side brand filtering (brand stays a client-side filter in `Home.vue`).
|
||||
- No change to the radius or sort options.
|
||||
|
||||
## Testing
|
||||
|
||||
- `npm run build` must succeed (no Vite/compile regressions).
|
||||
- Behavioural verification of: Petrol/Diesel toggles re-search and highlight;
|
||||
selecting a long-tail fuel via the popover shows the badge with neither pill
|
||||
highlighted; "Clear all" resets radius/sort/brand and snaps a long-tail fuel
|
||||
back to Petrol.
|
||||
- The exact mechanism (Pest 4 browser test vs. a JS unit harness vs. manual) is
|
||||
decided in the implementation plan after confirming what frontend test
|
||||
infrastructure exists in the repo.
|
||||
197
docs/superpowers/specs/2026-06-12-pricing-waitlist-design.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Pricing-page waitlist — design
|
||||
|
||||
**Date:** 2026-06-12
|
||||
**Status:** Approved, pending implementation plan
|
||||
|
||||
## Problem
|
||||
|
||||
The `/pricing` page shows disabled **"Coming soon"** buttons on the Daily
|
||||
(`basic`) and Smart (`plus`) cards, because their alerting features aren't
|
||||
shipped yet (`COMING_SOON = ['basic', 'plus']` in
|
||||
`resources/js/components/PricingGrid.vue`). We want to capture interest instead
|
||||
of showing a dead button: let a visitor leave their **name + email** to be
|
||||
notified when alerts launch.
|
||||
|
||||
This list is **separate from registered users** — it's a marketing signup log,
|
||||
not an account.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope (now):**
|
||||
|
||||
- Collect name + email from the pricing page.
|
||||
- Store in a dedicated table.
|
||||
- View + CSV export in the Filament admin panel (the "announce later" hook).
|
||||
|
||||
**Out of scope (deliberately deferred to a later task):**
|
||||
|
||||
- The announcement email itself — Mailable, queued bulk send, unsubscribe
|
||||
route/link. We collect now and build the send mechanism when we're actually
|
||||
ready to email the list.
|
||||
- Tier segmentation — we do **not** record which tier (Daily vs Smart) the
|
||||
visitor was interested in. One flat list.
|
||||
- IP / source / user-agent capture. Can be added later if abuse becomes a
|
||||
problem; not needed for v1.
|
||||
|
||||
## Why not the `offload-project/laravel-waitlist` package
|
||||
|
||||
That package is built around referrals, queue positions, and invite flows —
|
||||
none of which this needs. It would add a dependency and migrations we'd work
|
||||
against. A single table + endpoint is less code and less maintenance for
|
||||
"collect name + email, announce later."
|
||||
|
||||
## Architecture
|
||||
|
||||
Follows the existing shape: **Vue SPA → public REST API → thin controller →
|
||||
fat service → model** (`.claude/rules/architecture.md`,
|
||||
`.claude/rules/frontend.md`).
|
||||
|
||||
```
|
||||
WaitlistForm.vue (below the pricing grid)
|
||||
└── useWaitlist.js (composable, uses configured `api` axios instance)
|
||||
└── POST /api/waitlist (public, throttle:10,1)
|
||||
└── Api\WaitlistController@store
|
||||
└── StoreWaitlistRequest (validation)
|
||||
└── WaitlistService::subscribe()
|
||||
└── WaitlistSubscriber (model)
|
||||
```
|
||||
|
||||
## Backend
|
||||
|
||||
### Migration — `create_waitlist_subscribers_table`
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------------|-----------------------|-------------------------------|
|
||||
| `id` | bigIncrements | |
|
||||
| `name` | string | |
|
||||
| `email` | string, **unique** | stored lowercased + trimmed |
|
||||
| `created_at` / `updated_at` | timestamps | |
|
||||
|
||||
No other columns, per the minimal-schema decision.
|
||||
|
||||
### Model — `WaitlistSubscriber`
|
||||
|
||||
- `$fillable = ['name', 'email']`.
|
||||
- A factory (`WaitlistSubscriberFactory`) for tests.
|
||||
|
||||
### Service — `WaitlistService`
|
||||
|
||||
```php
|
||||
public function subscribe(string $name, string $email): WaitlistSubscriber
|
||||
```
|
||||
|
||||
- Normalises email: `trim` + `strtolower`.
|
||||
- `firstOrCreate(['email' => $email], ['name' => $name])` — **idempotent**.
|
||||
Re-submitting an existing email is a no-op success, never an error and never
|
||||
a duplicate row. Does not reveal whether the email was already present.
|
||||
- `final` class, constructor injection only (per `code-style.md`).
|
||||
- Returns the `WaitlistSubscriber` model (typed return, per `architecture.md`).
|
||||
|
||||
### Form Request — `Api/StoreWaitlistRequest`
|
||||
|
||||
```php
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
```
|
||||
|
||||
`authorize()` returns `true` (public endpoint).
|
||||
|
||||
### Controller — `Api\WaitlistController@store`
|
||||
|
||||
- Thin: validates via `StoreWaitlistRequest`, calls `WaitlistService::subscribe()`.
|
||||
- Returns `201`:
|
||||
```json
|
||||
{ "message": "You're on the list — we'll email you when alerts go live." }
|
||||
```
|
||||
- Does **not** echo the model back (nothing sensitive to return; no API
|
||||
Resource needed).
|
||||
|
||||
### Route — `routes/api.php`
|
||||
|
||||
Added to the **public** block (alongside `/auth/register`):
|
||||
|
||||
```php
|
||||
Route::post('/waitlist', [WaitlistController::class, 'store'])
|
||||
->middleware('throttle:10,1');
|
||||
```
|
||||
|
||||
Public (no API key, no Sanctum auth), but throttled to 10/min per IP. Uses the
|
||||
same Sanctum-stateful XSRF path as `/auth/register`, so the SPA posts to it with
|
||||
its existing axios credentials/XSRF config.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Composable — `resources/js/composables/useWaitlist.js`
|
||||
|
||||
Exposes:
|
||||
|
||||
- `submit(name, email)` — `POST /waitlist` via the configured `api` instance
|
||||
(`resources/js/axios.js`); never a bare axios/fetch call (per `frontend.md`).
|
||||
- `loading` (ref bool)
|
||||
- `error` (ref string|null) — surfaces validation/throttle errors
|
||||
- `success` (ref bool)
|
||||
|
||||
### Component — `resources/js/components/WaitlistForm.vue`
|
||||
|
||||
- A single shared "band" rendered **once below the pricing grid**.
|
||||
- Heading (e.g. "Want in when alerts launch?") + `name` input + `email` input +
|
||||
"Notify me" button, laid out inline on the band.
|
||||
- States: idle → loading (button disabled/spinner) → success (inline
|
||||
"You're on the list ✓", form hidden/replaced) or error (message shown,
|
||||
inputs retained).
|
||||
- Tailwind styling consistent with the pricing cards
|
||||
(`.claude/rules/code-style.md`, Tailwind v4).
|
||||
|
||||
### `resources/js/components/PricingGrid.vue`
|
||||
|
||||
- **No change** to the `COMING_SOON` logic — the Daily/Smart buttons stay as
|
||||
disabled "Coming soon".
|
||||
- Render `<WaitlistForm />` once, below the `.grid` (still inside the section).
|
||||
|
||||
## Admin — Filament `WaitlistSubscriberResource`
|
||||
|
||||
In `app/Filament/Resources/` (matching siblings like `UserResource`).
|
||||
|
||||
- **Table:** `name`, `email`, joined date (`created_at`). Searchable on name +
|
||||
email; default sort newest first.
|
||||
- **CSV export:** Filament table export action — this is the mechanism for
|
||||
pulling the list to announce later.
|
||||
- **Read-only:** no create / edit / delete actions. It's a signup log; rows
|
||||
arrive only via the public endpoint.
|
||||
|
||||
## Tests (Pest — `.claude/rules/testing.md`)
|
||||
|
||||
**Feature** (`tests/Feature/`, `RefreshDatabase`, factory-first):
|
||||
|
||||
- `POST /api/waitlist` with valid name + email → `201`, row exists.
|
||||
- Duplicate email → `201`, still exactly one row (idempotent, no error).
|
||||
- Missing name → `422`.
|
||||
- Invalid email → `422`.
|
||||
- (Throttle middleware present on the route.)
|
||||
|
||||
**Unit** (`tests/Unit/Services/WaitlistServiceTest.php`):
|
||||
|
||||
- `subscribe()` lowercases + trims the email before storing.
|
||||
- `subscribe()` called twice with the same email creates one row and returns
|
||||
the existing subscriber.
|
||||
|
||||
## Files touched
|
||||
|
||||
**New:**
|
||||
|
||||
- `database/migrations/xxxx_create_waitlist_subscribers_table.php`
|
||||
- `app/Models/WaitlistSubscriber.php`
|
||||
- `database/factories/WaitlistSubscriberFactory.php`
|
||||
- `app/Services/WaitlistService.php`
|
||||
- `app/Http/Requests/Api/StoreWaitlistRequest.php`
|
||||
- `app/Http/Controllers/Api/WaitlistController.php`
|
||||
- `app/Filament/Resources/WaitlistSubscriberResource/...` (resource + list page)
|
||||
- `resources/js/composables/useWaitlist.js`
|
||||
- `resources/js/components/WaitlistForm.vue`
|
||||
- `tests/Feature/WaitlistTest.php`
|
||||
- `tests/Unit/Services/WaitlistServiceTest.php`
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `routes/api.php` (add public throttled route)
|
||||
- `resources/js/components/PricingGrid.vue` (render `<WaitlistForm />` below grid)
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 823 B |
@@ -1,3 +1,3 @@
|
||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="166" height="166" viewBox="0 0 512 512">
|
||||
<path fill="#bb5b3e" d="M32 64C32 28.7 60.7 0 96 0L256 0c35.3 0 64 28.7 64 64l0 192 8 0c48.6 0 88 39.4 88 88l0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-154c-27.6-7.1-48-32.2-48-62l0-59.5-25.8-28.3c-8.9-9.8-8.2-25 1.6-33.9s25-8.2 33.9 1.6l71.7 78.8c9.4 10.3 14.6 23.7 14.6 37.7L512 376c0 39.8-32.2 72-72 72s-72-32.2-72-72l0-32c0-22.1-17.9-40-40-40l-8 0 0 161.4c9.3 3.3 16 12.2 16 22.6 0 13.3-10.7 24-24 24L40 512c-13.3 0-24-10.7-24-24 0-10.5 6.7-19.3 16-22.6L32 64zM96 80l0 96c0 8.8 7.2 16 16 16l128 0c8.8 0 16-7.2 16-16l0-96c0-8.8-7.2-16-16-16L112 64c-8.8 0-16 7.2-16 16z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 672 B |
@@ -1,12 +1,27 @@
|
||||
<template>
|
||||
<div ref="popoverRoot">
|
||||
<div class="flex flex-wrap items-center justify-end gap-2 md:gap-2.5 py-2 border-b border-zinc-200">
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-2 border-b border-zinc-200">
|
||||
<div class="grid grid-cols-2 gap-2 md:gap-2.5" role="radiogroup" aria-label="Fuel">
|
||||
<button
|
||||
v-for="fuel in PRIMARY_FUELS"
|
||||
:key="fuel.value"
|
||||
:aria-checked="fuelType === fuel.value"
|
||||
:class="{ 'is-active': fuelType === fuel.value }"
|
||||
class="pill !rounded-xl w-full justify-center"
|
||||
role="radio"
|
||||
type="button"
|
||||
@click="fuelType = fuel.value"
|
||||
>
|
||||
<span class="text-sm font-medium">{{ fuel.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:aria-expanded="open"
|
||||
:class="{ 'is-active': activeCount > 0 || open }"
|
||||
aria-controls="post-search-filters-panel"
|
||||
aria-haspopup="dialog"
|
||||
class="pill !rounded-xl"
|
||||
class="pill !rounded-xl ml-auto"
|
||||
type="button"
|
||||
@click="open = !open"
|
||||
>
|
||||
@@ -35,11 +50,11 @@
|
||||
aria-label="Filters"
|
||||
class="mt-3 rounded-2xl border border-zinc-200 bg-white shadow-sm p-4 space-y-4 max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
<div>
|
||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">Fuel</span>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2" role="radiogroup" aria-label="Fuel type">
|
||||
<div v-if="SECONDARY_FUELS.length">
|
||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">More fuels</span>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2" role="radiogroup" aria-label="More fuel types">
|
||||
<button
|
||||
v-for="fuel in FUEL_TYPES"
|
||||
v-for="fuel in SECONDARY_FUELS"
|
||||
:key="fuel.value"
|
||||
:aria-checked="fuelType === fuel.value"
|
||||
:class="{ 'is-active': fuelType === fuel.value }"
|
||||
@@ -152,6 +167,18 @@ const DEFAULTS = Object.freeze({
|
||||
sort: 'reliable',
|
||||
})
|
||||
|
||||
// The two everyday fuels are surfaced as equal-width quick pills outside the
|
||||
// popover; everything else in the shared FUEL_TYPES source stays under
|
||||
// "More fuels".
|
||||
const PRIMARY_FUEL_VALUES = Object.freeze(['e10', 'b7_standard'])
|
||||
|
||||
const PRIMARY_FUELS = Object.freeze([
|
||||
{ value: 'e10', label: 'Petrol' },
|
||||
{ value: 'b7_standard', label: 'Diesel' },
|
||||
])
|
||||
|
||||
const SECONDARY_FUELS = FUEL_TYPES.filter((fuel) => !PRIMARY_FUEL_VALUES.includes(fuel.value))
|
||||
|
||||
const sortOptions = [
|
||||
{ label: 'Reliable', value: 'reliable', icon: 'lucide:shield-check' },
|
||||
{ label: 'Price', value: 'price', icon: 'lucide:pound-sterling' },
|
||||
@@ -195,8 +222,12 @@ watch([fuelType, radius, sort], () => {
|
||||
if (postcode.value.trim() || coords.value) emitSearch()
|
||||
})
|
||||
|
||||
// Petrol/Diesel are shown as pills, so only a long-tail fuel counts as a
|
||||
// "hidden" filter on the Filters badge.
|
||||
const isSecondaryFuel = computed(() => !PRIMARY_FUEL_VALUES.includes(fuelType.value))
|
||||
|
||||
const hasActive = computed(() => (
|
||||
fuelType.value !== DEFAULTS.fuelType
|
||||
isSecondaryFuel.value
|
||||
|| radius.value !== DEFAULTS.radius
|
||||
|| sort.value !== DEFAULTS.sort
|
||||
|| Boolean(props.brandFilter)
|
||||
@@ -204,7 +235,7 @@ const hasActive = computed(() => (
|
||||
|
||||
const activeCount = computed(() => {
|
||||
let count = 0
|
||||
if (fuelType.value !== DEFAULTS.fuelType) count++
|
||||
if (isSecondaryFuel.value) count++
|
||||
if (radius.value !== DEFAULTS.radius) count++
|
||||
if (sort.value !== DEFAULTS.sort) count++
|
||||
if (props.brandFilter) count++
|
||||
@@ -212,7 +243,8 @@ const activeCount = computed(() => {
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
fuelType.value = DEFAULTS.fuelType
|
||||
// Leave a Petrol/Diesel pill choice alone; only snap a long-tail fuel back.
|
||||
if (isSecondaryFuel.value) fuelType.value = DEFAULTS.fuelType
|
||||
radius.value = DEFAULTS.radius
|
||||
sort.value = DEFAULTS.sort
|
||||
if (props.brandFilter) emit('update:brandFilter', '')
|
||||
|
||||
@@ -80,13 +80,16 @@
|
||||
<button v-else type="button" disabled class="w-full py-3 px-4 bg-zinc-100 rounded-xl text-center font-bold text-zinc-400 cursor-not-allowed">Coming soon</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WaitlistForm v-if="hasComingSoon" source="pricing" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
import WaitlistForm from './WaitlistForm.vue'
|
||||
|
||||
const { isAuthenticated, userTier } = useAuth()
|
||||
|
||||
@@ -102,6 +105,8 @@ const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
||||
// disabled until then. Remove a tier from this list to make its button live.
|
||||
const COMING_SOON = ['basic', 'plus']
|
||||
|
||||
const hasComingSoon = computed(() => COMING_SOON.length > 0)
|
||||
|
||||
function isComingSoon(tier) {
|
||||
return COMING_SOON.includes(tier)
|
||||
}
|
||||
|
||||
67
resources/js/components/WaitlistForm.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto mt-12">
|
||||
<div class="bg-white border border-zinc-300 rounded-3xl p-8 text-center">
|
||||
<template v-if="success">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent/10 text-accent">
|
||||
<iconify-icon class="text-2xl" icon="lucide:check"></iconify-icon>
|
||||
</span>
|
||||
<h3 class="text-xl font-bold font-display text-zinc-800">You're on the list</h3>
|
||||
<p class="text-zinc-500 text-sm">We'll email you the moment price alerts go live.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<h3 class="text-xl font-bold font-display text-zinc-800 mb-1">Want in when alerts launch?</h3>
|
||||
<p class="text-zinc-500 text-sm mb-6">Leave your details and we'll let you know the day alerts go live.</p>
|
||||
|
||||
<form class="flex flex-col sm:flex-row gap-3" @submit.prevent="onSubmit">
|
||||
<input
|
||||
v-model.trim="name"
|
||||
type="text"
|
||||
autocomplete="name"
|
||||
placeholder="Your name"
|
||||
required
|
||||
:disabled="loading"
|
||||
class="flex-1 px-4 py-3 border border-zinc-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-60"
|
||||
>
|
||||
<input
|
||||
v-model.trim="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
:disabled="loading"
|
||||
class="flex-1 px-4 py-3 border border-zinc-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-60"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="px-6 py-3 bg-accent text-white rounded-xl font-bold text-sm shadow-lg hover:bg-primary-dark transition-all disabled:opacity-60 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{{ loading ? 'Joining…' : 'Notify me' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="text-red-600 text-sm mt-3 text-left">{{ error }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useWaitlist } from '../composables/useWaitlist.js'
|
||||
|
||||
const props = defineProps({
|
||||
source: { type: String, default: 'pricing' },
|
||||
})
|
||||
|
||||
const name = ref('')
|
||||
const email = ref('')
|
||||
const { loading, success, error, submit } = useWaitlist()
|
||||
|
||||
function onSubmit() {
|
||||
submit(name.value, email.value, props.source)
|
||||
}
|
||||
</script>
|
||||
31
resources/js/composables/useWaitlist.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ref } from 'vue'
|
||||
import api from '../axios.js'
|
||||
|
||||
export function useWaitlist() {
|
||||
const loading = ref(false)
|
||||
const success = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
async function submit(name, email, source = null) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await api.post('/waitlist', {
|
||||
name,
|
||||
email,
|
||||
source,
|
||||
referrer: document.referrer || null,
|
||||
})
|
||||
success.value = true
|
||||
} catch (e) {
|
||||
const fieldErrors = e.response?.data?.errors
|
||||
error.value = fieldErrors
|
||||
? Object.values(fieldErrors)[0][0]
|
||||
: (e.response?.data?.message || 'Something went wrong — please try again.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, success, error, submit }
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
</p>
|
||||
<p>
|
||||
Ovidiu Ungureanu is registered with the UK Information Commissioner's Office (ICO) as a
|
||||
data controller. <strong>ICO registration reference: 00014395133.</strong>
|
||||
data controller. <strong>ICO registration reference: ZC171362.</strong>
|
||||
</p>
|
||||
<p>
|
||||
If you have any questions about this policy or how we handle your personal data, contact us at
|
||||
@@ -276,7 +276,7 @@
|
||||
</p>
|
||||
<p class="text-sm text-zinc-600">
|
||||
Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
||||
ICO registration reference: 00014395133.
|
||||
ICO registration reference: ZC171362.
|
||||
</p>
|
||||
</section>
|
||||
</x-layouts.legal>
|
||||
@@ -11,7 +11,7 @@
|
||||
FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in
|
||||
Peterborough, United Kingdom ("we", "us", "our"). These terms form a legally binding
|
||||
contract between you and Ovidiu Ungureanu trading as FuelAlert.
|
||||
ICO registration reference: 00014395133.
|
||||
ICO registration reference: ZC171362.
|
||||
</p>
|
||||
<p>
|
||||
By creating an account or using the service, you confirm that you have read, understood
|
||||
@@ -242,7 +242,7 @@
|
||||
</p>
|
||||
<p class="text-sm text-zinc-600">
|
||||
Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
||||
ICO registration reference: 00014395133.
|
||||
ICO registration reference: ZC171362.
|
||||
</p>
|
||||
</section>
|
||||
</x-layouts.legal>
|
||||
@@ -5,6 +5,7 @@ use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\StationController;
|
||||
use App\Http\Controllers\Api\StatsController;
|
||||
use App\Http\Controllers\Api\UserController;
|
||||
use App\Http\Controllers\Api\WaitlistController;
|
||||
use App\Http\Middleware\VerifyApiKey;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -22,6 +23,9 @@ Route::get('/fuel-types', function () {
|
||||
|
||||
Route::get('/stats/live', [StatsController::class, 'live']);
|
||||
|
||||
// Feature-launch waitlist signup (public, separate from registered users)
|
||||
Route::post('/waitlist', [WaitlistController::class, 'store'])->middleware('throttle:10,1');
|
||||
|
||||
// Protected endpoints (API key required)
|
||||
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
|
||||
Route::get('/stations', [StationController::class, 'index']);
|
||||
|
||||
@@ -77,6 +77,16 @@ Schedule::command('fuel:archive')
|
||||
->onOneServer()
|
||||
->runInBackground();
|
||||
|
||||
// Retry area labels that failed to reverse-geocode at search time (transient
|
||||
// postcodes.io blip, or a genuinely remote point). Searches normally get their
|
||||
// area_label inline; this just mops up stragglers. Cached per bucket, so it
|
||||
// only calls the API for buckets it hasn't resolved yet.
|
||||
Schedule::command('searches:backfill-areas')
|
||||
->hourly()
|
||||
->withoutOverlapping()
|
||||
->onOneServer()
|
||||
->runInBackground();
|
||||
|
||||
// Scheduled WhatsApp updates — morning and evening
|
||||
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
|
||||
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();
|
||||
|
||||
@@ -8,14 +8,17 @@ use Illuminate\Support\Facades\Route;
|
||||
// Named dashboard route so route('dashboard') resolves; Vue Router handles rendering
|
||||
Route::get('/dashboard', fn () => view('app'))->middleware(['auth', 'verified'])->name('dashboard');
|
||||
|
||||
// Server-side logout — handles hard navigation to /logout
|
||||
// Server-side logout for the SPA's hard navigation (GET /logout).
|
||||
// Intentionally unnamed: the `logout` route name belongs to Fortify's POST /logout,
|
||||
// which the Blade auth forms target via route('logout'). Both can share the /logout
|
||||
// URL (different verbs), but two routes cannot share a name — that breaks route:cache.
|
||||
Route::get('/logout', function (Request $request) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
})->middleware('auth')->name('logout');
|
||||
})->middleware('auth');
|
||||
|
||||
Route::middleware(['auth'])->prefix('billing')->name('billing.')->group(function () {
|
||||
Route::get('/checkout/{tier}/{cadence}', [BillingController::class, 'checkout'])->name('checkout');
|
||||
|
||||
31
tests/Feature/Admin/WaitlistSubscriberResourceTest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\WaitlistSubscriberResource\Pages\ListWaitlistSubscribers;
|
||||
use App\Models\User;
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->actingAs(User::factory()->admin()->create());
|
||||
});
|
||||
|
||||
it('lists waitlist subscribers', function () {
|
||||
$subscribers = WaitlistSubscriber::factory()->count(3)->create();
|
||||
|
||||
Livewire::test(ListWaitlistSubscribers::class)
|
||||
->assertOk()
|
||||
->assertCanSeeTableRecords($subscribers);
|
||||
});
|
||||
|
||||
it('exposes a CSV export header action', function () {
|
||||
Livewire::test(ListWaitlistSubscribers::class)
|
||||
->assertActionExists('export');
|
||||
});
|
||||
|
||||
it('exports a CSV download when the action runs against real subscribers', function () {
|
||||
WaitlistSubscriber::factory()->count(2)->create();
|
||||
|
||||
Livewire::test(ListWaitlistSubscribers::class)
|
||||
->callAction('export')
|
||||
->assertFileDownloaded('waitlist.csv');
|
||||
});
|
||||
@@ -12,6 +12,17 @@ uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
|
||||
|
||||
// Every search reverse-geocodes its lat/lng bucket to an area label. Fake the
|
||||
// postcodes.io reverse endpoint (query-string form) so tests never hit the
|
||||
// network; the path form (/postcodes/SW1A1AA) used for forward lookups is
|
||||
// matched by per-test stubs and is unaffected by this.
|
||||
Http::fake([
|
||||
'api.postcodes.io/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [['admin_district' => 'Testshire']],
|
||||
]),
|
||||
]);
|
||||
});
|
||||
|
||||
function asPaidUserOnStations(string $tier = 'plus'): User
|
||||
@@ -114,6 +125,19 @@ it('logs a search record for each request', function () {
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores the reverse-geocoded area label on the search record', function () {
|
||||
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
|
||||
StationPriceCurrent::factory()->create(['station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]);
|
||||
|
||||
$this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10');
|
||||
|
||||
$this->assertDatabaseHas('searches', [
|
||||
'lat_bucket' => '52.56',
|
||||
'lng_bucket' => '-0.26',
|
||||
'area_label' => 'Testshire',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 422 when required params are missing', function () {
|
||||
$this->getJson('/api/stations?lat=52.5')
|
||||
->assertUnprocessable();
|
||||
|
||||
64
tests/Feature/Api/WaitlistTest.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
|
||||
it('stores a subscriber and returns 201', function () {
|
||||
$response = $this->postJson('/api/waitlist', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
]);
|
||||
|
||||
$response->assertCreated()
|
||||
->assertJsonStructure(['message']);
|
||||
|
||||
$this->assertDatabaseHas('waitlist_subscribers', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a duplicate email as success without creating a second row', function () {
|
||||
WaitlistSubscriber::factory()->create(['email' => 'ada@example.com']);
|
||||
|
||||
$this->postJson('/api/waitlist', [
|
||||
'name' => 'Someone Else',
|
||||
'email' => 'ada@example.com',
|
||||
])->assertCreated();
|
||||
|
||||
expect(WaitlistSubscriber::where('email', 'ada@example.com')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('stores the source and referrer sent with the request', function () {
|
||||
$this->postJson('/api/waitlist', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'ada@example.com',
|
||||
'source' => 'pricing',
|
||||
'referrer' => 'https://duckduckgo.com/',
|
||||
])->assertCreated();
|
||||
|
||||
$this->assertDatabaseHas('waitlist_subscribers', [
|
||||
'email' => 'ada@example.com',
|
||||
'source' => 'pricing',
|
||||
'referrer' => 'https://duckduckgo.com/',
|
||||
]);
|
||||
});
|
||||
|
||||
it('requires a name', function () {
|
||||
$this->postJson('/api/waitlist', [
|
||||
'email' => 'ada@example.com',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('name');
|
||||
});
|
||||
|
||||
it('rejects an invalid email', function () {
|
||||
$this->postJson('/api/waitlist', [
|
||||
'name' => 'Ada Lovelace',
|
||||
'email' => 'not-an-email',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('email');
|
||||
});
|
||||
|
||||
it('throttles the endpoint', function () {
|
||||
$route = collect(app('router')->getRoutes())
|
||||
->first(fn ($route) => $route->uri() === 'api/waitlist' && in_array('POST', $route->methods(), true));
|
||||
|
||||
expect($route?->gatherMiddleware())->toContain('throttle:10,1');
|
||||
});
|
||||
50
tests/Feature/Console/BackfillSearchAreasTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Search;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('fills the area label for searches missing one, once per bucket', function () {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [['admin_district' => 'Peterborough']],
|
||||
]),
|
||||
]);
|
||||
|
||||
// Two searches share a bucket; a third is in a different bucket.
|
||||
Search::factory()->count(2)->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => null]);
|
||||
Search::factory()->create(['lat_bucket' => 51.50, 'lng_bucket' => -0.14, 'area_label' => null]);
|
||||
|
||||
$this->artisan('searches:backfill-areas')->assertSuccessful();
|
||||
|
||||
expect(Search::whereNull('area_label')->count())->toBe(0)
|
||||
->and(Search::where('area_label', 'Peterborough')->count())->toBe(3);
|
||||
|
||||
// One reverse-geocode call per distinct bucket, not per row.
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
it('leaves searches that already have an area label untouched', function () {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [['admin_district' => 'Peterborough']],
|
||||
]),
|
||||
]);
|
||||
|
||||
Search::factory()->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => 'Manchester']);
|
||||
|
||||
$this->artisan('searches:backfill-areas')->assertSuccessful();
|
||||
|
||||
expect(Search::where('area_label', 'Manchester')->count())->toBe(1);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
it('reports when there is nothing to backfill', function () {
|
||||
$this->artisan('searches:backfill-areas')
|
||||
->expectsOutputToContain('No searches need an area label.')
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -63,6 +63,21 @@ CSV;
|
||||
->and(Postcode::find('BT11AA'))->toBeNull();
|
||||
});
|
||||
|
||||
it('skips postcodes with placeholder coordinates (no grid reference)', function (): void {
|
||||
$csv = <<<'CSV'
|
||||
pcd,pcds,doterm,lat,long
|
||||
"SW1A1AA","SW1A 1AA","",51.501009,-0.141588
|
||||
"GIR0AA","GIR 0AA","",99.999999,0.000000
|
||||
CSV;
|
||||
|
||||
$path = writeOnspdFixture($csv);
|
||||
|
||||
$this->artisan('postcodes:import', ['--file' => $path])->assertSuccessful();
|
||||
|
||||
expect(Postcode::count())->toBe(1)
|
||||
->and(Postcode::find('GIR0AA'))->toBeNull();
|
||||
});
|
||||
|
||||
it('accepts ArcGIS ONSPD exports that use PCD7 instead of PCD', function (): void {
|
||||
$csv = <<<'CSV'
|
||||
OBJECTID,PCD7,PCD8,PCDS,DOTERM,LAT,LONG,x,y
|
||||
|
||||
@@ -24,6 +24,24 @@ it('busts the plan cache on customer.subscription.created', function (): void {
|
||||
expect(Cache::tags(['plans'])->get("plan_for_user_{$user->id}"))->toBeNull();
|
||||
});
|
||||
|
||||
it('busts the plan cache without error on a cache store that does not support tags', function (): void {
|
||||
// The `file` driver is not taggable — calling Cache::tags() on it throws.
|
||||
// This guards against a regression where bustPlanCache assumed a taggable store.
|
||||
config(['cache.default' => 'file']);
|
||||
Cache::store('file')->flush();
|
||||
expect(Cache::supportsTags())->toBeFalse();
|
||||
|
||||
$user = User::factory()->create(['stripe_id' => 'cus_notags_1']);
|
||||
Cache::put("plan_for_user_{$user->id}", 'stale', 3600);
|
||||
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
'data' => ['object' => ['customer' => 'cus_notags_1']],
|
||||
]));
|
||||
|
||||
expect(Cache::get("plan_for_user_{$user->id}"))->toBeNull();
|
||||
});
|
||||
|
||||
it('ignores subscription.created when the user is not found', function (): void {
|
||||
(new HandleStripeWebhook)->handle(new WebhookReceived([
|
||||
'type' => 'customer.subscription.created',
|
||||
|
||||
@@ -279,3 +279,76 @@ it('persists an outcode resolved via HTTP fallback', function (): void {
|
||||
->and((float) $row->lat)->toBe(52.536397)
|
||||
->and((float) $row->lng)->toBe(-0.210181);
|
||||
});
|
||||
|
||||
// --- Reverse geocoding (area label) ---
|
||||
|
||||
it('reverse-geocodes coordinates to the admin district', function (): void {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [
|
||||
['admin_district' => 'Peterborough', 'region' => 'East of England'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
expect($this->service->reverseResolve(52.5364, -0.2102))->toBe('Peterborough');
|
||||
});
|
||||
|
||||
it('falls back to a broader area when admin district is missing', function (): void {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [
|
||||
['admin_district' => null, 'region' => 'Scotland'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
expect($this->service->reverseResolve(57.4, -4.2))->toBe('Scotland');
|
||||
});
|
||||
|
||||
it('returns null when reverse geocoding finds no area', function (): void {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response(['status' => 200, 'result' => null]),
|
||||
]);
|
||||
|
||||
expect($this->service->reverseResolve(0.0, 0.0))->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the reverse geocode request fails', function (): void {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
expect($this->service->reverseResolve(52.5364, -0.2102))->toBeNull();
|
||||
});
|
||||
|
||||
it('caches the reverse-geocoded area per bucket', function (): void {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [['admin_district' => 'Peterborough']],
|
||||
]),
|
||||
]);
|
||||
|
||||
// Two coordinates inside the same ~1km (2dp) bucket → one HTTP call.
|
||||
$this->service->reverseResolve(52.5364, -0.2102);
|
||||
$this->service->reverseResolve(52.5359, -0.2148);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
it('queries postcodes.io with a wide radius so low-density buckets still match', function (): void {
|
||||
Http::fake([
|
||||
'*/postcodes?*' => Http::response([
|
||||
'status' => 200,
|
||||
'result' => [['admin_district' => 'Peterborough']],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->service->reverseResolve(52.54, -0.25);
|
||||
|
||||
// The default 100m radius misses the bucket centroid in rural areas; we send 2000m.
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'radius=2000'));
|
||||
});
|
||||
|
||||
47
tests/Unit/Services/WaitlistServiceTest.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\WaitlistSubscriber;
|
||||
use App\Services\WaitlistService;
|
||||
|
||||
it('normalises the email to trimmed lowercase before storing', function () {
|
||||
$subscriber = app(WaitlistService::class)->subscribe('Ada Lovelace', ' Ada@Example.COM ');
|
||||
|
||||
expect($subscriber->email)->toBe('ada@example.com')
|
||||
->and($subscriber->name)->toBe('Ada Lovelace');
|
||||
|
||||
$this->assertDatabaseHas('waitlist_subscribers', ['email' => 'ada@example.com']);
|
||||
});
|
||||
|
||||
it('is idempotent — re-subscribing the same email keeps a single row and returns the original', function () {
|
||||
$service = app(WaitlistService::class);
|
||||
|
||||
$first = $service->subscribe('Ada', 'ada@example.com');
|
||||
$second = $service->subscribe('Different Name', 'ADA@example.com');
|
||||
|
||||
expect($second->id)->toBe($first->id)
|
||||
->and($second->name)->toBe('Ada');
|
||||
|
||||
expect(WaitlistSubscriber::where('email', 'ada@example.com')->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('stores the source and referrer on a new subscriber', function () {
|
||||
$subscriber = app(WaitlistService::class)->subscribe(
|
||||
'Ada',
|
||||
'ada@example.com',
|
||||
source: 'pricing',
|
||||
referrer: 'https://duckduckgo.com/',
|
||||
);
|
||||
|
||||
expect($subscriber->source)->toBe('pricing')
|
||||
->and($subscriber->referrer)->toBe('https://duckduckgo.com/');
|
||||
});
|
||||
|
||||
it('preserves the original meta when an existing email re-subscribes', function () {
|
||||
$service = app(WaitlistService::class);
|
||||
|
||||
$service->subscribe('Ada', 'ada@example.com', source: 'pricing', referrer: 'https://a.test');
|
||||
$second = $service->subscribe('Ada', 'ada@example.com', source: 'home', referrer: 'https://b.test');
|
||||
|
||||
expect($second->source)->toBe('pricing')
|
||||
->and($second->referrer)->toBe('https://a.test');
|
||||
});
|
||||