Compare commits
14 Commits
97e27fc057
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a6967dc01 | ||
|
|
257c09d178 | ||
|
|
f14006dc28 | ||
|
|
8fe3461adf | ||
|
|
ad2230728c | ||
|
|
5bd909d227 | ||
|
|
a841a7c900 | ||
|
|
adf0b93a45 | ||
|
|
1ae2f1396d | ||
|
|
df4ebdb7c6 | ||
|
|
7a8bd5c86a | ||
|
|
ecd45588e9 | ||
|
|
598ef04645 | ||
|
|
07e0789044 |
@@ -1,57 +1,118 @@
|
||||
# Architecture
|
||||
|
||||
## Shape: Vue SPA ⇄ REST API ⇄ fat Laravel Services
|
||||
|
||||
The frontend is a **Vue 3 single-page app** (`resources/js/`). It talks to the
|
||||
backend exclusively over a **REST API** (`routes/api.php` →
|
||||
`app/Http/Controllers/Api/`). Controllers stay thin; all business logic lives in
|
||||
**Service classes** (`app/Services/`). Blade is reduced to a one-line SPA shell,
|
||||
server-rendered legal pages, transactional emails, and the Filament admin panel.
|
||||
|
||||
```
|
||||
Browser
|
||||
└── Vue SPA (resources/js, mounted on #app via app.blade.php)
|
||||
└── axios (resources/js/axios.js — baseURL '/api', cookie + XSRF auth)
|
||||
└── REST API (routes/api.php → Http/Controllers/Api/*)
|
||||
└── Services (app/Services/*) ← all business logic
|
||||
└── Models / Jobs / Events / Notifications
|
||||
```
|
||||
|
||||
## Core principle: fat Services, thin everything else
|
||||
|
||||
All business logic lives in Service classes. Controllers, Livewire components,
|
||||
and console commands are thin orchestrators — they call Services and return results.
|
||||
This keeps the app API-extractable later without a rewrite.
|
||||
All business logic lives in Service classes. API controllers, console commands,
|
||||
jobs, listeners, and Filament resources are thin orchestrators — they call
|
||||
Services and return results. Never put domain logic in a controller or a Vue
|
||||
component.
|
||||
|
||||
## Directory structure
|
||||
## Directory structure (actual)
|
||||
|
||||
```
|
||||
app/
|
||||
├── Console/Commands/ # Scheduler commands (PollFuelPrices, PredictOilPrices, RunScoringEngine)
|
||||
├── Http/Controllers/ # Minimal — auth + Stripe webhook only
|
||||
├── Livewire/ # Classic two-file Livewire components
|
||||
├── Models/ # Eloquent models
|
||||
├── Notifications/ # Laravel Notification classes (multi-channel)
|
||||
├── Services/ # ALL business logic lives here
|
||||
│ ├── FuelPriceService.php # Fuel Finder API polling + storage
|
||||
│ ├── AlertScoringService.php # Fill-up timing recommendation engine
|
||||
│ ├── StationTaggingService.php # Supermarket brand detection
|
||||
│ ├── NotificationDispatchService.php # Tier-aware notification routing
|
||||
│ ├── SubscriptionService.php # Cashier/tier helpers
|
||||
│ ├── PostcodeService.php # Resolves postcodes/outcodes/place names → lat/lng
|
||||
│ ├── OilPriceService.php # FRED fetch + EWMA/LLM Brent crude prediction
|
||||
│ ├── LocationResult.php # DTO returned by PostcodeService
|
||||
│ └── ApiLogger.php # Wraps all outbound HTTP calls, logs to api_logs
|
||||
└── Jobs/ # Queued jobs (dispatch notifications per user)
|
||||
├── Console/Commands/ # Scheduler: PollFuelPrices, FetchOilPrices, BackfillOilPrices,
|
||||
│ # RunLlmOverlay, EvaluateVolatilityRegime, ResolveForecastOutcomes,
|
||||
│ # ArchiveOldPricesCommand, ImportBeisFuelPrices, ImportPostcodes
|
||||
├── Http/
|
||||
│ ├── Controllers/Api/ # AuthController, StationController, StatsController, UserController
|
||||
│ ├── Controllers/ # BillingController (Stripe Checkout/Portal redirects)
|
||||
│ ├── Middleware/ # VerifyApiKey (API-key gate), RequiresFeature (tier gate)
|
||||
│ ├── Requests/ # Form requests
|
||||
│ └── Resources/Api/ # StationResource (API output shaping)
|
||||
├── Actions/Fortify/ # Fortify auth actions (CreateNewUser, …)
|
||||
├── Services/ # ALL business logic — see below
|
||||
├── Models/ # Eloquent models
|
||||
├── Notifications/ # FuelPriceAlert + custom Channels (OneSignal, Vonage WA/SMS)
|
||||
├── Jobs/ # DispatchUserNotificationJob, PollFuelPricesJob,
|
||||
│ # SendScheduledWhatsAppJob, SendPaymentFailedReminderJob
|
||||
├── Events/ # PricesUpdatedEvent
|
||||
├── Listeners/ # HandleStripeWebhook (Cashier WebhookReceived)
|
||||
├── Filament/ # Admin panel (Resources, Widgets) + Providers/Filament
|
||||
├── Enums/ # FuelType, PlanTier, …
|
||||
└── Livewire/Actions/ # Logout only — Livewire is otherwise vestigial
|
||||
|
||||
resources/views/
|
||||
├── livewire/ # Livewire Blade templates
|
||||
└── emails/ # Mailable templates
|
||||
app/Services/
|
||||
├── FuelPriceService.php # Fuel Finder API polling + storage
|
||||
├── StationTaggingService.php # Supermarket brand detection
|
||||
├── PostcodeService.php # postcode/outcode/place → lat/lng (+ LocationResult DTO)
|
||||
├── PlanFeatures.php # Single source of tier-entitlement truth (see tiers.md)
|
||||
├── HaversineQuery.php # Distance SQL helper
|
||||
├── ApiLogger.php # Wraps outbound HTTP, logs to api_logs
|
||||
├── StationSearch/ # StationSearchService (+ SearchCriteria, SearchResult DTOs)
|
||||
├── BrentPriceSources/ # FRED + EIA Brent sources (+ BrentPriceFetcher)
|
||||
└── Forecasting/ # Weekly forecast subsystem — WeeklyForecastService,
|
||||
# LlmOverlayService, BacktestRunner, OutcomeResolver,
|
||||
# VolatilityRegimeService, feature/leak tooling, …
|
||||
|
||||
resources/
|
||||
├── js/ # Vue 3 SPA — see .claude/rules/frontend.md
|
||||
└── views/
|
||||
├── app.blade.php # SPA shell (mounts #app)
|
||||
├── legal/ # Server-rendered legal pages
|
||||
├── emails/ # Mailable templates
|
||||
└── livewire/auth/ # Starter-kit Volt auth screens (left untouched)
|
||||
|
||||
routes/
|
||||
├── web.php # All web routes (Livewire pages)
|
||||
└── api.php # Empty for now — API added later if needed
|
||||
├── web.php # SPA shell + catch-all, legal pages, billing redirects, server logout
|
||||
└── api.php # REST API consumed by the SPA (see below)
|
||||
```
|
||||
|
||||
> **Stale service names elsewhere.** Domain rule files (`scoring.md`,
|
||||
> `prediction.md`, `notifications.md`) still name services that have since been
|
||||
> refactored away — e.g. `AlertScoringService`, `OilPriceService`,
|
||||
> `NationalFuelPredictionService`, `NotificationDispatchService`,
|
||||
> `SubscriptionService`. Those concerns now live under `Services/Forecasting/`,
|
||||
> `Services/StationSearch/`, and `PlanFeatures`. Always verify a service name
|
||||
> against `app/Services/` before trusting it from those files.
|
||||
|
||||
## Service class conventions
|
||||
|
||||
- Constructor injection only — no facade usage inside Services
|
||||
- Services are bound in AppServiceProvider if they need interfaces
|
||||
- Each Service has one responsibility — do not merge concerns
|
||||
- Return typed DTOs or Eloquent collections — never raw arrays from Services
|
||||
- Services never dispatch jobs directly — that's the controller/command's job
|
||||
- Services never dispatch jobs directly — that's the controller/command/listener's job
|
||||
|
||||
## No API yet
|
||||
## The REST API
|
||||
|
||||
`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum
|
||||
token auth. The app is Livewire-only until subscriber count justifies a native app.
|
||||
When the API is added later, it will reuse the same Service classes.
|
||||
`routes/api.php` is the SPA's backend. Three access tiers:
|
||||
|
||||
## Livewire components (classic only)
|
||||
- **Public** (no auth): `POST /api/auth/register`, `POST /api/auth/login`,
|
||||
`GET /api/auth/me`, `GET /api/fuel-types`, `GET /api/stats/live`
|
||||
- **API-key** (`VerifyApiKey` + `throttle:60,1`): `GET /api/stations`,
|
||||
`GET /api/stats/searches`
|
||||
- **Sanctum** (`auth:sanctum`): `POST /api/auth/logout`, `/api/user/*`
|
||||
(preferences, saved stations, profile, password, delete account)
|
||||
|
||||
Use two-file classic Livewire components. Do NOT use Volt single-file syntax.
|
||||
Volt files from the starter kit (auth screens) are left as-is — do not convert them.
|
||||
New components go in `app/Livewire/` with corresponding Blade in `resources/views/livewire/`.
|
||||
Conventions:
|
||||
|
||||
- Shape responses with API Resources (`app/Http/Resources/Api/`), never raw arrays.
|
||||
- The fuel **prediction is embedded in the `/api/stations` response** under the
|
||||
`prediction` key — there is no standalone prediction endpoint (see `prediction.md`).
|
||||
- Tier-gate routes with the `feature` middleware (`RequiresFeature`); never
|
||||
inline entitlement checks (see `tiers.md`).
|
||||
- Auth is cookie/session via Sanctum SPA mode (axios sends XSRF + credentials);
|
||||
the API-key gate protects the expensive station search from scraping.
|
||||
|
||||
## Frontend
|
||||
|
||||
The Vue SPA is documented in `.claude/rules/frontend.md`. Do **not** build new
|
||||
Livewire components — Livewire/Flux remain only for the starter-kit auth screens
|
||||
and the `Logout` action.
|
||||
|
||||
74
.claude/rules/frontend.md
Normal file
74
.claude/rules/frontend.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Frontend — Vue 3 SPA
|
||||
|
||||
The entire user-facing app (landing, station search, dashboard, settings) is a
|
||||
**Vue 3 single-page application** under `resources/js/`. It is served from the
|
||||
Blade shell `resources/views/app.blade.php` via the SPA catch-all in
|
||||
`routes/web.php` and consumes the REST API (`routes/api.php`). There is **no
|
||||
Livewire in the product UI** — do not add Livewire / Volt / Alpine components for
|
||||
new features. Build Vue.
|
||||
|
||||
## Stack
|
||||
|
||||
- Vue 3.5 with `<script setup>` (Composition API), Vue Router 4 (`createWebHistory`)
|
||||
- Vite 8 (`npm run dev` / `npm run build`), `@vitejs/plugin-vue`
|
||||
- Tailwind CSS v4 (`@tailwindcss/vite`)
|
||||
- Leaflet for maps, iconify-icon + Lucide for icons, axios for HTTP
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
resources/js/
|
||||
├── app.js # Entry — createApp(App).use(router).mount('#app')
|
||||
├── App.vue # Root — <RouterView/>, fetches the user on mount
|
||||
├── axios.js # Configured axios instance (baseURL '/api', XSRF + credentials)
|
||||
├── router/index.js # Routes + requiresAuth guard (redirects to /login)
|
||||
├── views/
|
||||
│ ├── Home.vue # Landing + station search (mirrors state to the URL query)
|
||||
│ └── dashboard/ # Overview, SavedStations, Preferences, settings/{Profile,Security,Appearance}
|
||||
├── components/ # LeafletMap, StationCard, StationList, PredictionCard/Full,
|
||||
│ # PostSearchFilters, UpsellBanner, landing/*
|
||||
├── composables/ # useAuth, useStations, useSavedStations
|
||||
└── constants/ # fuelTypes.js — shared fuel-type source of truth (front + back)
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Composition API + `<script setup>` only.** No Options API.
|
||||
- **All server state goes through composables** that call the configured `api`
|
||||
axios instance (`axios.js`) — never `fetch()` or a bare `axios` call inside a
|
||||
component.
|
||||
- **Auth is cookie/session (Sanctum SPA mode).** axios is configured with
|
||||
`withCredentials` + `withXSRFToken`; don't add bearer-token handling.
|
||||
- Route guards live in `router/index.js` (`meta.requiresAuth`). Hard navigations
|
||||
(`/login`, `/logout`) use `window.location.href`, not the router.
|
||||
- Keep business logic in the API / Services. Vue components render and orchestrate.
|
||||
|
||||
## Search & the shareable URL
|
||||
|
||||
`Home.vue` is the search surface. Search params (postcode **or** lat/lng,
|
||||
`fuel_type`, `radius`, `sort`) are mirrored into the **URL query** via
|
||||
`router.push` (`queryFromParams`) so searches are shareable and bookmarkable, and
|
||||
restored on load (`paramsFromQuery`). Because the URL is shareable, **any
|
||||
coordinates written to it must be coarsened** — a precise GPS pair from "Use my
|
||||
location" (`HeroSearch.vue` → browser Geolocation) would otherwise broadcast the
|
||||
sharer's exact position to everyone they send the link to.
|
||||
|
||||
## Maps (Leaflet)
|
||||
|
||||
`components/LeafletMap.vue` wraps Leaflet directly (Leaflet is a plain JS library,
|
||||
not a Vue plugin). Station and user markers and the Google-Maps directions links
|
||||
are built from `station.lat` / `station.lng`; the user marker comes from the
|
||||
browser Geolocation API.
|
||||
|
||||
## Prediction
|
||||
|
||||
The fuel prediction ships **inside** the `/api/stations` response under the
|
||||
`prediction` key and is rendered by `PredictionCard.vue` / `PredictionFull.vue`
|
||||
(`useStations` reads `response.data.prediction`). See `prediction.md` for the
|
||||
payload shape and the tier gate.
|
||||
|
||||
---
|
||||
paths:
|
||||
- "resources/js/**/*.vue"
|
||||
- "resources/js/**/*.js"
|
||||
---
|
||||
@@ -1,61 +0,0 @@
|
||||
# Livewire Components
|
||||
|
||||
## Classic two-file components only
|
||||
|
||||
Do NOT use Volt single-file syntax for new components.
|
||||
Volt files created by the Livewire starter kit (auth screens) are left untouched.
|
||||
|
||||
## Component locations
|
||||
|
||||
```
|
||||
app/Livewire/
|
||||
├── Dashboard/
|
||||
│ ├── FuelRecommendation.php # Main fill-up/wait card
|
||||
│ ├── NearbyStations.php # Map + station list
|
||||
│ └── PriceHistory.php # 14-day trend chart
|
||||
├── Account/
|
||||
│ ├── NotificationSettings.php # Channel prefs + WhatsApp OTP
|
||||
│ ├── SubscriptionManager.php # Upgrade/downgrade UI
|
||||
│ └── FuelPreferences.php # Fuel type, postcode
|
||||
└── Public/
|
||||
└── PriceWatchdog.php # Public-facing local price watchdog
|
||||
```
|
||||
|
||||
## Component conventions
|
||||
|
||||
- Component properties that are shown in the view must be `public`
|
||||
- Use `#[Computed]` attribute for derived values — not re-computed on every render
|
||||
- Validate with `#[Validate]` attribute on properties, not in separate rules array
|
||||
- Never put Service instantiation in the component — inject via method parameter or mount()
|
||||
- Dispatch browser events with `$this->dispatch()` not `$this->emit()` (Livewire 3 syntax)
|
||||
- Use `wire:loading` on all buttons that trigger server actions
|
||||
|
||||
## Alpine.js usage
|
||||
|
||||
Alpine.js handles local UI state only: dropdowns, modals, tab switching, copy-to-clipboard.
|
||||
Do not replicate server state in Alpine — use Livewire for anything that needs PHP.
|
||||
Alpine components stay inline in Blade (`x-data="{}"`), not in separate JS files unless reused 3+ times.
|
||||
|
||||
## Map (Leaflet.js)
|
||||
|
||||
Leaflet is a plain JS drop-in, not a Livewire component.
|
||||
Station data is fetched from a dedicated Livewire endpoint and passed to Leaflet via Alpine:
|
||||
```blade
|
||||
<div
|
||||
x-data="stationMap(@entangle('stations'))"
|
||||
id="map"
|
||||
style="height: 400px"
|
||||
></div>
|
||||
```
|
||||
Map initialisation lives in `resources/js/maps/station-map.js`.
|
||||
|
||||
## Page routing
|
||||
|
||||
Livewire full-page components are mounted in `routes/web.php` using `Route::get()->component()`.
|
||||
No separate view files for pages — the Livewire component IS the page.
|
||||
|
||||
---
|
||||
paths:
|
||||
- "app/Livewire/**/*.php"
|
||||
- "resources/views/livewire/**/*.blade.php"
|
||||
---
|
||||
@@ -6,7 +6,7 @@
|
||||
|------------|--------|-------|------------|----------|-----------|
|
||||
| Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ |
|
||||
| Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ |
|
||||
| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 1/day triggered |
|
||||
| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
|
||||
| Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
|
||||
|
||||
## NotificationDispatchService
|
||||
@@ -41,8 +41,8 @@ Subject line / message copy adapts based on `recommendation` and `confidence`.
|
||||
|
||||
- Same Vonage client, SMS channel
|
||||
- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it
|
||||
- Plus: max 8 SMS/month (enforced via alerts table count)
|
||||
- Pro: max 30 SMS/month
|
||||
- Plus (Smart): max 3 SMS/day (enforced via notification_log count)
|
||||
- Pro: deferred from launch — see `tiers.md`
|
||||
- Cost: ~3.5p per message UK
|
||||
|
||||
## Email
|
||||
|
||||
@@ -50,7 +50,7 @@ All six are available to all tiers. The restriction is quantity only:
|
||||
| email | weekly digest | daily | ✓ triggered | ✓ triggered |
|
||||
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
|
||||
| whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
|
||||
| sms | ✗ | ✗ | ✓ max 1/day | ✓ max 3/day |
|
||||
| sms | ✗ | ✗ | ✓ max 3/day | ✓ max 3/day |
|
||||
|
||||
WhatsApp also supports scheduled updates (morning + evening) independent of
|
||||
price triggers — available to any tier that has WhatsApp enabled.
|
||||
|
||||
@@ -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,8 +1,8 @@
|
||||
APP_NAME="Fuel Finder"
|
||||
APP_NAME="Fuel Alert"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://fuel-price.test
|
||||
APP_URL=http://fuel-alert.test
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
@@ -31,7 +31,7 @@ SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=.fuel-price.test
|
||||
SESSION_DOMAIN=.fuel-alert.test
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
@@ -97,4 +97,4 @@ STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
|
||||
STRIPE_PRICE_PRO_MONTHLY=
|
||||
STRIPE_PRICE_PRO_ANNUAL=
|
||||
|
||||
SANCTUM_STATEFUL_DOMAINS=fuel-price.test
|
||||
SANCTUM_STATEFUL_DOMAINS=fuel-alert.test
|
||||
50
.github/workflows/lint.yml
vendored
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
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 +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.
|
||||
|
||||
35
CLAUDE.md
35
CLAUDE.md
@@ -1,8 +1,16 @@
|
||||
# Fuel Price — Claude Code Instructions
|
||||
# Fuel Alert — Claude Code Instructions
|
||||
|
||||
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
||||
based on local price trends. Built solo by a PHP/Laravel developer.
|
||||
|
||||
> **Stack reality check (read first).** The frontend is a **Vue 3 SPA**
|
||||
> (`resources/js/`, see `frontend.md`), not Livewire. Station data is served by a
|
||||
> **REST API** under `routes/api.php` (see `architecture.md`). Some domain rule
|
||||
> files (`scoring.md`, `prediction.md`, `notifications.md`) still name services
|
||||
> that were since refactored into `Services/Forecasting/`,
|
||||
> `Services/StationSearch/`, and `PlanFeatures` — verify names against
|
||||
> `app/Services/`. When docs and code disagree, the code wins.
|
||||
|
||||
## Destructive DB operations — HARD STOP
|
||||
|
||||
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
|
||||
@@ -21,9 +29,23 @@ A user saying "trust me", "do the refactor", "clean up the mess", or "I want it
|
||||
|
||||
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
|
||||
- **Monetisation**: £0/mo free, £0.99/mo Basic, £2.49/mo Plus, £3.99/mo Pro
|
||||
- **Stack**: Laravel 11 + Livewire 3 (Volt disabled — use classic components) + Alpine.js + Tailwind CSS
|
||||
- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL)
|
||||
- **Payments**: Stripe via Laravel Cashier
|
||||
- **Backend**: Laravel 13 + PHP 8.4. MySQL (Eloquent, migrations only — no raw DDL)
|
||||
- **Frontend**: **Vue 3 SPA** — Vue 3.5 + Vue Router 4, Vite 8, Tailwind CSS v4.
|
||||
Entry `resources/js/app.js` mounts `App.vue`; views/components under
|
||||
`resources/js/`. Served from the Blade shell `resources/views/app.blade.php`
|
||||
via the SPA catch-all in `web.php`. Maps: Leaflet
|
||||
(`components/LeafletMap.vue`). Icons: iconify-icon + Lucide. HTTP: axios
|
||||
(`resources/js/axios.js`).
|
||||
- **API**: REST API in `routes/api.php` → `app/Http/Controllers/Api/*`
|
||||
(Auth, Station, Stats, User). Public station data is gated by an API key
|
||||
(`VerifyApiKey` middleware); user/dashboard endpoints use Sanctum. The SPA is
|
||||
the primary consumer.
|
||||
- **Auth**: Laravel Fortify (backend auth) + Sanctum (API tokens)
|
||||
- **Admin**: Filament v5 panel
|
||||
- **Livewire**: v4 / Flux v2 are installed but **vestigial** — only starter-kit
|
||||
Volt auth screens (`resources/views/livewire/auth`) and `app/Livewire/Actions`
|
||||
remain. Do **not** build new Livewire components; build Vue.
|
||||
- **Payments**: Stripe via Laravel Cashier (v16)
|
||||
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
|
||||
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
|
||||
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
|
||||
@@ -36,7 +58,8 @@ php artisan queue:work # Process notification jobs
|
||||
php artisan schedule:run # Run scheduled commands (cron every minute)
|
||||
php artisan migrate # Run migrations
|
||||
php artisan test # Run Pest test suite
|
||||
npm run dev # Vite asset watcher
|
||||
npm run dev # Vite dev server (Vue SPA + HMR)
|
||||
npm run build # Production build — run if SPA changes don't show up
|
||||
```
|
||||
|
||||
## Imports
|
||||
@@ -48,7 +71,7 @@ npm run dev # Vite asset watcher
|
||||
@.claude/rules/prediction.md
|
||||
@.claude/rules/payments.md
|
||||
@.claude/rules/tiers.md
|
||||
@.claude/rules/livewire.md
|
||||
@.claude/rules/frontend.md
|
||||
@.claude/rules/api-data.md
|
||||
@.claude/rules/testing.md
|
||||
@.claude/rules/code-style.md
|
||||
|
||||
@@ -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,21 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error', 'response_body'])]
|
||||
#[Fillable([
|
||||
'service',
|
||||
'method',
|
||||
'url',
|
||||
'status_code',
|
||||
'duration_ms',
|
||||
'error',
|
||||
'response_body',
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'cache_read_tokens',
|
||||
'cache_write_tokens',
|
||||
'ratelimit_remaining',
|
||||
'ratelimit_reset_at',
|
||||
])]
|
||||
class ApiLog extends Model
|
||||
{
|
||||
/** @use HasFactory<ApiLogFactory> */
|
||||
@@ -19,6 +33,7 @@ class ApiLog extends Model
|
||||
{
|
||||
return [
|
||||
'created_at' => 'datetime',
|
||||
'ratelimit_reset_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,10 +34,12 @@ class ApiLogger
|
||||
$statusCode = null;
|
||||
$error = null;
|
||||
$responseBody = null;
|
||||
$usage = [];
|
||||
|
||||
try {
|
||||
$response = $request();
|
||||
$statusCode = $response->status();
|
||||
$usage = $this->extractUsage($response);
|
||||
|
||||
if ($response->failed()) {
|
||||
$body = $response->body();
|
||||
@@ -53,6 +55,7 @@ class ApiLogger
|
||||
// doesn't. Pull the body when it's available.
|
||||
if ($e instanceof RequestException) {
|
||||
$responseBody = $this->truncate($e->response->body());
|
||||
$usage = $this->extractUsage($e->response);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
@@ -65,6 +68,7 @@ class ApiLogger
|
||||
'duration_ms' => (int) round((microtime(true) - $start) * 1000),
|
||||
'error' => $error,
|
||||
'response_body' => $responseBody,
|
||||
...$usage,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -75,4 +79,39 @@ class ApiLogger
|
||||
? substr($body, 0, self::RESPONSE_BODY_CAP)
|
||||
: $body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull token-usage and rate-limit telemetry from a provider response.
|
||||
*
|
||||
* Today only Anthropic exposes both. Other providers return mostly
|
||||
* NULLs — callers don't need to know which is which.
|
||||
*
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
private function extractUsage(?Response $response): array
|
||||
{
|
||||
if ($response === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$usage = $response->json('usage');
|
||||
$tokens = is_array($usage) ? $usage : [];
|
||||
|
||||
$reset = $response->header('anthropic-ratelimit-input-tokens-reset');
|
||||
$remaining = $response->header('anthropic-ratelimit-input-tokens-remaining');
|
||||
|
||||
return [
|
||||
'input_tokens' => $this->intOrNull($tokens['input_tokens'] ?? null),
|
||||
'output_tokens' => $this->intOrNull($tokens['output_tokens'] ?? null),
|
||||
'cache_read_tokens' => $this->intOrNull($tokens['cache_read_input_tokens'] ?? null),
|
||||
'cache_write_tokens' => $this->intOrNull($tokens['cache_creation_input_tokens'] ?? null),
|
||||
'ratelimit_remaining' => $this->intOrNull($remaining !== '' ? $remaining : null),
|
||||
'ratelimit_reset_at' => $reset !== '' ? $reset : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function intOrNull(mixed $value): ?int
|
||||
{
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ use App\Models\BrentPrice;
|
||||
use App\Models\LlmOverlay;
|
||||
use App\Models\VolatilityRegime;
|
||||
use App\Services\ApiLogger;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -15,9 +17,21 @@ use Throwable;
|
||||
/**
|
||||
* Layer 4 — daily news-aware overlay on the calibrated ridge forecast.
|
||||
*
|
||||
* Calls Anthropic Haiku with the web_search tool, then forces a
|
||||
* submit_overlay tool call to get structured output. Cites events with
|
||||
* URLs; URLs are verified before storing. Empty citations → rejection.
|
||||
* Runs as two independent Anthropic API calls:
|
||||
* Phase 1 — web_search tool only; we capture the URLs/titles from
|
||||
* the returned web_search_tool_result blocks.
|
||||
* Phase 2 — fresh conversation containing those URLs+titles as plain
|
||||
* text plus a forced submit_overlay tool call.
|
||||
*
|
||||
* Phase 1's transcript is never sent back to Phase 2. Anthropic's
|
||||
* web_search auto-caches the encrypted page text (~55k tokens per
|
||||
* search) and requires it intact when web_search_tool_result blocks
|
||||
* are resent. Threading it through to Phase 2 either blows the Tier-1
|
||||
* 50k ITPM bucket or 400s if we try to strip it. Two clean calls keep
|
||||
* Phase 2 around 3k input tokens.
|
||||
*
|
||||
* Citations are harvested directly from Phase 1's web_search_tool_result
|
||||
* blocks — Haiku is unreliable about populating `events_cited` itself.
|
||||
*
|
||||
* Read-only with respect to the volatility flag — Layer 4 writes its
|
||||
* `llm_overlays` row; Layer 5's hourly cron picks it up and decides
|
||||
@@ -31,6 +45,15 @@ final class LlmOverlayService
|
||||
|
||||
private const int COOLDOWN_HOURS = 4;
|
||||
|
||||
private const int MAX_SEARCH_TURNS = 2;
|
||||
|
||||
/**
|
||||
* Approximate input-token cost of Phase 2 (system + tool schema +
|
||||
* forecast context + harvested URL list). If Phase 1 leaves
|
||||
* remaining ITPM below this, wait for the bucket to refill.
|
||||
*/
|
||||
private const int SUBMIT_TOKEN_BUDGET = 4_000;
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiLogger $apiLogger,
|
||||
private readonly WeeklyForecastService $weeklyForecast,
|
||||
@@ -55,19 +78,24 @@ final class LlmOverlayService
|
||||
$forecast = $this->weeklyForecast->currentForecast();
|
||||
$context = $this->buildContext($forecast);
|
||||
|
||||
$rawResult = $this->callAnthropic($context);
|
||||
if ($rawResult === null) {
|
||||
$callResult = $this->callAnthropic($context);
|
||||
if ($callResult === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$verifiedEvents = $this->verifyCitedUrls($rawResult['events_cited'] ?? []);
|
||||
$rawResult = $callResult['raw'];
|
||||
$harvested = $callResult['harvested'];
|
||||
|
||||
$mergedEvents = $this->mergeEvents($rawResult['events_cited'] ?? [], $harvested);
|
||||
$verifiedEvents = $this->verifyCitedUrls($mergedEvents);
|
||||
|
||||
if ($verifiedEvents === []) {
|
||||
Log::warning('LlmOverlayService: no verified citations, rejecting overlay', [
|
||||
'events_cited_count' => count($rawResult['events_cited'] ?? []),
|
||||
'model_events' => $rawResult['events_cited'] ?? null,
|
||||
'harvested_urls' => array_column($harvested, 'url'),
|
||||
'direction' => $rawResult['direction'] ?? null,
|
||||
'confidence' => $rawResult['confidence'] ?? null,
|
||||
'reasoning_short' => $rawResult['reasoning_short'] ?? null,
|
||||
'raw_result' => $rawResult,
|
||||
]);
|
||||
|
||||
return null;
|
||||
@@ -131,70 +159,44 @@ final class LlmOverlayService
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
/**
|
||||
* Two independent API calls:
|
||||
*
|
||||
* Phase 1 — runs the web_search tool, captures the assistant's
|
||||
* returned `web_search_tool_result` blocks, then
|
||||
* discards the transcript.
|
||||
*
|
||||
* Phase 2 — issues a brand-new conversation with the harvested
|
||||
* URLs/titles flattened into a plain-text user message
|
||||
* and forces a `submit_overlay` tool call.
|
||||
*
|
||||
* Why not one stitched conversation: Anthropic auto-caches web_search
|
||||
* results into ITPM (≈55k tokens for a 1-search call) and requires
|
||||
* `encrypted_content` intact when those blocks are sent back.
|
||||
* Resending the Phase 1 transcript to Phase 2 either rate-limits us
|
||||
* (29k+ tokens twice → exceeds the Tier-1 50k ITPM bucket) or 400s
|
||||
* if we strip the encrypted blob. A fresh Phase 2 sends ~3k tokens
|
||||
* total — small enough to fit in the recovered bucket after a
|
||||
* short adaptive sleep.
|
||||
*
|
||||
* @return array{raw: array<string, mixed>, harvested: array<int, array{url: string, title: string}>}|null
|
||||
*/
|
||||
private function callAnthropic(array $context): ?array
|
||||
{
|
||||
$messages = [['role' => 'user', 'content' => $this->prompt($context)]];
|
||||
|
||||
try {
|
||||
// Phase 1: web search loop. Append the assistant turn after every
|
||||
// successful response, then decide whether to keep looping —
|
||||
// this guarantees the messages array stays well-formed regardless
|
||||
// of whether we exit via `break` or by exhausting iterations.
|
||||
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
|
||||
->withHeaders($this->headers())
|
||||
->post(self::URL, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 1024,
|
||||
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
|
||||
'messages' => $messages,
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('LlmOverlayService: search request failed', ['status' => $response->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
|
||||
if ($response->json('stop_reason') !== 'pause_turn') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$messages[] = ['role' => 'user', 'content' => 'Now submit your overlay using the submit_overlay tool. Cite at least one event with a URL.'];
|
||||
|
||||
// Phase 2: forced structured output
|
||||
$submitResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
|
||||
->withHeaders($this->headers())
|
||||
->post(self::URL, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 512,
|
||||
'tools' => [$this->submitOverlayTool()],
|
||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
|
||||
'messages' => $messages,
|
||||
]));
|
||||
|
||||
if (! $submitResponse->successful()) {
|
||||
Log::error('LlmOverlayService: submit request failed', ['status' => $submitResponse->status()]);
|
||||
|
||||
$phase1 = $this->runWebSearch($context);
|
||||
if ($phase1 === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$submitContent = $submitResponse->json('content') ?? [];
|
||||
$rawResult = $this->extractToolInput($submitContent);
|
||||
$this->waitForRateLimitIfNeeded($phase1['response']);
|
||||
|
||||
// Haiku sometimes calls submit_overlay without `events_cited` even
|
||||
// though the schema marks it required. Confirmed in laravel.log on
|
||||
// 2026-05-12: tool_use input had only direction/confidence/reasoning.
|
||||
// Retry once with an explicit tool_result error.
|
||||
if ($this->citationsMissing($rawResult)) {
|
||||
$rawResult = $this->retrySubmitWithCitationError($messages, $submitContent) ?? $rawResult;
|
||||
$rawResult = $this->runSubmit($context, $phase1['harvested']);
|
||||
if ($rawResult === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $rawResult;
|
||||
return ['raw' => $rawResult, 'harvested' => $phase1['harvested']];
|
||||
} catch (Throwable $e) {
|
||||
Log::error('LlmOverlayService: callAnthropic failed', ['error' => $e->getMessage()]);
|
||||
|
||||
@@ -202,6 +204,239 @@ final class LlmOverlayService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: ask the model to search for news and capture the
|
||||
* web_search_tool_result blocks. Returns the harvested citations
|
||||
* and the final response (whose rate-limit headers tell us when
|
||||
* the ITPM bucket will be replenished for Phase 2).
|
||||
*
|
||||
* @return array{harvested: array<int, array{url: string, title: string}>, response: Response}|null
|
||||
*/
|
||||
private function runWebSearch(array $context): ?array
|
||||
{
|
||||
$messages = [['role' => 'user', 'content' => $this->searchUserMessage($context)]];
|
||||
$response = null;
|
||||
|
||||
for ($i = 0; $i < self::MAX_SEARCH_TURNS; $i++) {
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(45)
|
||||
->withHeaders($this->headers())
|
||||
->post(self::URL, [
|
||||
'model' => $this->model(),
|
||||
'max_tokens' => 1024,
|
||||
'system' => $this->searchSystem(),
|
||||
'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']],
|
||||
'messages' => $messages,
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('LlmOverlayService: search request failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => substr($response->body(), 0, 500),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||
|
||||
if ($response->json('stop_reason') !== 'pause_turn') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'harvested' => $this->harvestSearchResults($messages),
|
||||
'response' => $response,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: fresh API call — no Phase 1 transcript — with the
|
||||
* harvested citations as plain text and a forced submit_overlay
|
||||
* tool call.
|
||||
*
|
||||
* @param array<int, array{url: string, title: string}> $harvested
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function runSubmit(array $context, array $harvested): ?array
|
||||
{
|
||||
$response = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
|
||||
->withHeaders($this->headers())
|
||||
->post(self::URL, [
|
||||
'model' => $this->model(),
|
||||
'max_tokens' => 512,
|
||||
'system' => $this->submitSystem(),
|
||||
'tools' => [$this->submitOverlayTool()],
|
||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
|
||||
'messages' => [['role' => 'user', 'content' => $this->submitUserMessage($context, $harvested)]],
|
||||
]));
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('LlmOverlayService: submit request failed', [
|
||||
'status' => $response->status(),
|
||||
'body' => substr($response->body(), 0, 500),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$rawResult = $this->extractToolInput($response->json('content') ?? []);
|
||||
if ($rawResult === null) {
|
||||
Log::warning('LlmOverlayService: submit response missing tool_use block');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $rawResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anthropic's web_search burns ≈55k input tokens (mostly auto-cached
|
||||
* search results) on Phase 1. At Tier 1's 50k ITPM the bucket can
|
||||
* be at zero immediately afterwards. Read the rate-limit headers
|
||||
* and sleep until the bucket has refilled enough for Phase 2.
|
||||
* Capped at 65s so the daily cron never hangs longer than a minute.
|
||||
*/
|
||||
private function waitForRateLimitIfNeeded(Response $response): void
|
||||
{
|
||||
$remaining = (int) $response->header('anthropic-ratelimit-input-tokens-remaining');
|
||||
if ($response->header('anthropic-ratelimit-input-tokens-remaining') === ''
|
||||
|| $remaining >= self::SUBMIT_TOKEN_BUDGET) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resetAt = $response->header('anthropic-ratelimit-input-tokens-reset');
|
||||
$bucketSize = (int) $response->header('anthropic-ratelimit-input-tokens-limit');
|
||||
if ($resetAt === '' || $bucketSize <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$secondsUntilFullReset = max(0, CarbonImmutable::parse($resetAt)->getTimestamp() - now()->getTimestamp());
|
||||
} catch (Throwable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Anthropic's bucket refills linearly. We don't need to wait for
|
||||
// the full reset — only enough for SUBMIT_TOKEN_BUDGET tokens to
|
||||
// become available. Sleep proportionally + a small safety margin,
|
||||
// hard-capped at 65s.
|
||||
$tokensNeeded = self::SUBMIT_TOKEN_BUDGET - $remaining;
|
||||
$proportional = (int) ceil(($tokensNeeded / $bucketSize) * $secondsUntilFullReset);
|
||||
$waitSeconds = max(1, min(65, $proportional + 2));
|
||||
|
||||
Log::info('LlmOverlayService: waiting for ITPM bucket refill before submit', [
|
||||
'remaining' => $remaining,
|
||||
'wait_seconds' => $waitSeconds,
|
||||
'full_reset_in' => $secondsUntilFullReset,
|
||||
]);
|
||||
|
||||
sleep($waitSeconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every assistant turn and extract `{url, title}` from each
|
||||
* `web_search_tool_result` block. Anthropic's web_search returns
|
||||
* these blocks directly — they are the authoritative citation
|
||||
* source, not anything the model transcribes back to us.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $messages
|
||||
* @return array<int, array{url: string, title: string}>
|
||||
*/
|
||||
private function harvestSearchResults(array $messages): array
|
||||
{
|
||||
$byUrl = [];
|
||||
foreach ($messages as $message) {
|
||||
if (($message['role'] ?? null) !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
$content = $message['content'] ?? [];
|
||||
if (! is_array($content)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($content as $block) {
|
||||
if (! is_array($block) || ($block['type'] ?? null) !== 'web_search_tool_result') {
|
||||
continue;
|
||||
}
|
||||
$results = $block['content'] ?? [];
|
||||
if (! is_array($results)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($results as $result) {
|
||||
if (! is_array($result) || ($result['type'] ?? null) !== 'web_search_result') {
|
||||
continue;
|
||||
}
|
||||
$url = (string) ($result['url'] ?? '');
|
||||
if ($url === '' || isset($byUrl[$url])) {
|
||||
continue;
|
||||
}
|
||||
$byUrl[$url] = ['url' => $url, 'title' => (string) ($result['title'] ?? '')];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($byUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge model-provided events_cited with citations harvested from
|
||||
* `web_search_tool_result`. Model entries (which include `impact`
|
||||
* tagging) take precedence on URL collision; harvested-only entries
|
||||
* default to `impact: 'neutral'`.
|
||||
*
|
||||
* @param array<int, mixed> $modelEvents
|
||||
* @param array<int, array{url: string, title: string}> $harvested
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function mergeEvents(array $modelEvents, array $harvested): array
|
||||
{
|
||||
$byUrl = [];
|
||||
|
||||
foreach ($modelEvents as $event) {
|
||||
if (! is_array($event)) {
|
||||
continue;
|
||||
}
|
||||
$url = (string) ($event['url'] ?? '');
|
||||
if ($url === '') {
|
||||
continue;
|
||||
}
|
||||
$byUrl[$url] = [
|
||||
'headline' => (string) ($event['headline'] ?? ''),
|
||||
'source' => (string) ($event['source'] ?? ''),
|
||||
'url' => $url,
|
||||
'impact' => in_array($event['impact'] ?? null, ['rising', 'falling', 'neutral'], true)
|
||||
? $event['impact']
|
||||
: 'neutral',
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($harvested as $result) {
|
||||
$url = $result['url'];
|
||||
if (isset($byUrl[$url])) {
|
||||
continue;
|
||||
}
|
||||
$byUrl[$url] = [
|
||||
'headline' => $result['title'],
|
||||
'source' => $this->domainOf($url),
|
||||
'url' => $url,
|
||||
'impact' => 'neutral',
|
||||
];
|
||||
}
|
||||
|
||||
return array_values($byUrl);
|
||||
}
|
||||
|
||||
private function domainOf(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
|
||||
return is_string($host) ? preg_replace('/^www\./', '', $host) : '';
|
||||
}
|
||||
|
||||
private function verificationUserAgent(): string
|
||||
{
|
||||
$appUrl = rtrim((string) config('app.url'), '/');
|
||||
@@ -320,37 +555,61 @@ final class LlmOverlayService
|
||||
return config('services.anthropic.api_key');
|
||||
}
|
||||
|
||||
private function prompt(array $context): string
|
||||
private function model(): string
|
||||
{
|
||||
$json = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return <<<PROMPT
|
||||
You are providing a daily news-aware overlay for a UK weekly pump-price forecast.
|
||||
|
||||
The calibrated ridge model has already produced a directional call from price history.
|
||||
Your job is to search recent oil/fuel news and decide whether to AGREE or DISAGREE
|
||||
— and most importantly, surface any major-impact event that the ridge model can't see
|
||||
from price history alone.
|
||||
return config('services.anthropic.model', 'claude-haiku-4-5-20251001');
|
||||
}
|
||||
|
||||
private function searchSystem(): string
|
||||
{
|
||||
return <<<'PROMPT'
|
||||
You are researching news that may affect this week's UK pump-price forecast.
|
||||
Search recent news (last 48 hours) for:
|
||||
- OPEC+ production decisions or unexpected announcements
|
||||
- Geopolitical events affecting oil supply (sanctions, conflict, shipping disruption)
|
||||
- Major refinery outages or pipeline incidents
|
||||
- US/EU inventory reports that materially moved Brent
|
||||
|
||||
Context for this week:
|
||||
$json
|
||||
|
||||
After searching, you will be asked to submit_overlay with direction, confidence
|
||||
(capped at $this->confidenceCap), short reasoning, cited events with URLs,
|
||||
agrees_with_ridge, and major_impact_event.
|
||||
|
||||
Citing events with REAL URLs is mandatory. An empty citation array will be
|
||||
rejected and the overlay discarded.
|
||||
Return only the search results — you will be asked to summarise separately.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
private string $confidenceCap = '75';
|
||||
private function searchUserMessage(array $context): string
|
||||
{
|
||||
$json = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return "Use web_search to find oil/fuel news from the last 48 hours that could move UK pump prices this week.\n\nContext for this week:\n\n".$json;
|
||||
}
|
||||
|
||||
private function submitSystem(): string
|
||||
{
|
||||
$cap = self::CONFIDENCE_CAP;
|
||||
|
||||
return <<<PROMPT
|
||||
You are providing a news-aware directional overlay for a UK weekly pump-price forecast.
|
||||
Decide whether to AGREE or DISAGREE with the ridge model based on the news headlines
|
||||
provided in the user message. Cap confidence at $cap.
|
||||
Include events_cited (with impact tags) for any specific headline that drove your
|
||||
reasoning; you may leave events_cited empty if the news is unremarkable.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{url: string, title: string}> $harvested
|
||||
*/
|
||||
private function submitUserMessage(array $context, array $harvested): string
|
||||
{
|
||||
$contextJson = json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($harvested === []) {
|
||||
$headlines = '(none — no relevant news found)';
|
||||
} else {
|
||||
$headlines = collect($harvested)
|
||||
->map(fn (array $r): string => '- '.$r['title'].' — '.$r['url'])
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
return "Context for this week:\n\n".$contextJson."\n\nNews headlines found:\n".$headlines."\n\nNow call submit_overlay with your decision.";
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function submitOverlayTool(): array
|
||||
@@ -366,7 +625,7 @@ final class LlmOverlayService
|
||||
'reasoning_short' => ['type' => 'string', 'description' => '1–2 sentences.'],
|
||||
'events_cited' => [
|
||||
'type' => 'array',
|
||||
'minItems' => 1,
|
||||
'description' => 'Optional. Events that drove your reasoning, with directional impact. Citations are otherwise harvested from web_search_tool_result.',
|
||||
'items' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
@@ -381,7 +640,7 @@ final class LlmOverlayService
|
||||
'agrees_with_ridge' => ['type' => 'boolean'],
|
||||
'major_impact_event' => ['type' => 'boolean'],
|
||||
],
|
||||
'required' => ['direction', 'confidence', 'reasoning_short', 'events_cited', 'agrees_with_ridge', 'major_impact_event'],
|
||||
'required' => ['direction', 'confidence', 'reasoning_short', 'agrees_with_ridge', 'major_impact_event'],
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -396,57 +655,4 @@ final class LlmOverlayService
|
||||
|
||||
return $block['input'] ?? null;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed>|null $rawResult */
|
||||
private function citationsMissing(?array $rawResult): bool
|
||||
{
|
||||
return $rawResult === null
|
||||
|| ! isset($rawResult['events_cited'])
|
||||
|| ! is_array($rawResult['events_cited'])
|
||||
|| $rawResult['events_cited'] === [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $messages
|
||||
* @param array<int, mixed> $failedSubmitContent
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function retrySubmitWithCitationError(array $messages, array $failedSubmitContent): ?array
|
||||
{
|
||||
$toolUseId = collect($failedSubmitContent)->firstWhere('type', 'tool_use')['id'] ?? null;
|
||||
|
||||
if ($toolUseId === null) {
|
||||
Log::warning('LlmOverlayService: cannot retry — no tool_use id in failed submit');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Log::info('LlmOverlayService: retrying submit with citation error', ['tool_use_id' => $toolUseId]);
|
||||
|
||||
$messages[] = ['role' => 'assistant', 'content' => $failedSubmitContent];
|
||||
$messages[] = ['role' => 'user', 'content' => [[
|
||||
'type' => 'tool_result',
|
||||
'tool_use_id' => $toolUseId,
|
||||
'content' => 'events_cited was missing or empty. Resubmit submit_overlay with at least one event from your earlier web search results, including its real URL, headline, source, and impact.',
|
||||
'is_error' => true,
|
||||
]]];
|
||||
|
||||
$retryResponse = $this->apiLogger->send('anthropic', 'POST', self::URL, fn () => Http::timeout(20)
|
||||
->withHeaders($this->headers())
|
||||
->post(self::URL, [
|
||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||
'max_tokens' => 512,
|
||||
'tools' => [$this->submitOverlayTool()],
|
||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_overlay'],
|
||||
'messages' => $messages,
|
||||
]));
|
||||
|
||||
if (! $retryResponse->successful()) {
|
||||
Log::error('LlmOverlayService: retry submit failed', ['status' => $retryResponse->status()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->extractToolInput($retryResponse->json('content') ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/livewire-starter-kit",
|
||||
"name": "fuel-alert/fuel-alert",
|
||||
"type": "project",
|
||||
"description": "The official Laravel starter kit for Livewire.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"description": "Fuel Alert — UK fuel price intelligence app.",
|
||||
"keywords": ["laravel", "fuel", "alerts"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
|
||||
@@ -80,7 +80,7 @@ class PlanFactory extends Factory
|
||||
'whatsapp_daily_limit' => 5,
|
||||
'whatsapp_scheduled_updates' => 2,
|
||||
'sms_enabled' => true,
|
||||
'sms_daily_limit' => 1,
|
||||
'sms_daily_limit' => 3,
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Capture token usage and rate-limit headers from token-metering
|
||||
* providers (today: Anthropic). These columns let us see the
|
||||
* cumulative input-tokens-per-minute trajectory directly in
|
||||
* api_logs rather than inferring it from request counts.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('api_logs', function (Blueprint $table) {
|
||||
$table->unsignedInteger('input_tokens')->nullable()->after('response_body')
|
||||
->comment('Input tokens billed (Anthropic usage.input_tokens). NULL for providers that do not report usage.');
|
||||
$table->unsignedInteger('output_tokens')->nullable()->after('input_tokens')
|
||||
->comment('Output tokens billed (Anthropic usage.output_tokens).');
|
||||
$table->unsignedInteger('cache_read_tokens')->nullable()->after('output_tokens')
|
||||
->comment('Cache-hit tokens (Anthropic usage.cache_read_input_tokens). Do not count toward ITPM on most models.');
|
||||
$table->unsignedInteger('cache_write_tokens')->nullable()->after('cache_read_tokens')
|
||||
->comment('Cache-write tokens (Anthropic usage.cache_creation_input_tokens). Count toward ITPM.');
|
||||
$table->unsignedInteger('ratelimit_remaining')->nullable()->after('cache_write_tokens')
|
||||
->comment('Provider-reported input-tokens remaining in the rolling window (anthropic-ratelimit-input-tokens-remaining).');
|
||||
$table->dateTime('ratelimit_reset_at')->nullable()->after('ratelimit_remaining')
|
||||
->comment('When the input-tokens bucket will be fully replenished (anthropic-ratelimit-input-tokens-reset, RFC 3339).');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('api_logs', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'input_tokens',
|
||||
'output_tokens',
|
||||
'cache_read_tokens',
|
||||
'cache_write_tokens',
|
||||
'ratelimit_remaining',
|
||||
'ratelimit_reset_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -57,7 +57,7 @@ class PlanSeeder extends Seeder
|
||||
'whatsapp_daily_limit' => 5,
|
||||
'whatsapp_scheduled_updates' => 2,
|
||||
'sms_enabled' => true,
|
||||
'sms_daily_limit' => 1,
|
||||
'sms_daily_limit' => 3,
|
||||
'ai_predictions' => true,
|
||||
'price_threshold' => true,
|
||||
'score_alerts' => true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FuelAlert API Reference
|
||||
|
||||
Base URL: `https://fuel-price.test/api`
|
||||
Base URL: `https://fuel-alert.test/api`
|
||||
All endpoints return JSON. All endpoints require an `X-Api-Key` header.
|
||||
|
||||
**Authentication:**
|
||||
|
||||
415
docs/ops/deployment.md
Normal file
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.
|
||||
214
docs/ops/nginx-log-masking.md
Normal file
214
docs/ops/nginx-log-masking.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Runbook — Mask lat/lng coordinates in server logs (IONOS VPS)
|
||||
|
||||
**Goal:** stop precise search coordinates (`lat`/`lng`) from being written to server
|
||||
logs, while keeping GET-based shareable search URLs and full debugging value (IP,
|
||||
status, path, fuel_type all stay readable).
|
||||
|
||||
**Applies to:** the production Nginx + PHP-FPM box on IONOS. None of this lives in the
|
||||
app repo — it is server config you apply over SSH. The app already stores only a
|
||||
~1km-rounded location bucket + a SHA-256 IP hash in the `searches` table; this runbook
|
||||
covers the *raw* coordinates that transit the server in the URL.
|
||||
|
||||
## Scope — what this does and does NOT cover
|
||||
|
||||
| Sink | Covered here? |
|
||||
|---|---|
|
||||
| Nginx **access** log | ✅ Step 1 (request line + referer) |
|
||||
| Nginx **error** log | ✅ Step 2 (scrub, since `log_format` can't touch it) |
|
||||
| **PHP-FPM** access log | ✅ Step 3 (check / disable) |
|
||||
| Laravel app logs | ✅ Nothing to do — stock config logs no URL, no error tracker installed |
|
||||
| **IONOS edge** (CDN / WAF / managed LB) | ❌ Cannot be redacted from your side — see "Honest limit" |
|
||||
|
||||
> If you ever add Sentry/Flare/Bugsnag later, they capture the full request URL by
|
||||
> default — you'd need a `before_send` scrubber there too.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Mask the Nginx access log
|
||||
|
||||
### 1a. Create `/etc/nginx/conf.d/fuel-alert-log-masking.conf`
|
||||
|
||||
```nginx
|
||||
# Redact lat/lng from the access log — request line AND referer.
|
||||
# Every other field and query param is left intact.
|
||||
|
||||
# --- request line (e.g. GET /api/stations?lat=..&lng=.. HTTP/1.1) ---
|
||||
map $request $fa_req_1 {
|
||||
default $request;
|
||||
"~^(?<rqa>.*[?&])lat=[^& ]*(?<rqb>.*)$" "${rqa}lat=***${rqb}";
|
||||
}
|
||||
map $fa_req_1 $fa_request_masked {
|
||||
default $fa_req_1;
|
||||
"~^(?<rqc>.*[?&])lng=[^& ]*(?<rqd>.*)$" "${rqc}lng=***${rqd}";
|
||||
}
|
||||
|
||||
# --- referer header (e.g. https://fuel-alert.co.uk/?lat=..&lng=..) ---
|
||||
map $http_referer $fa_ref_1 {
|
||||
default $http_referer;
|
||||
"~^(?<rfa>.*[?&])lat=[^&]*(?<rfb>.*)$" "${rfa}lat=***${rfb}";
|
||||
}
|
||||
map $fa_ref_1 $fa_referer_masked {
|
||||
default $fa_ref_1;
|
||||
"~^(?<rfc>.*[?&])lng=[^&]*(?<rfd>.*)$" "${rfc}lng=***${rfd}";
|
||||
}
|
||||
|
||||
# A copy of the standard "combined" format, but using the masked values.
|
||||
log_format fuelalert_masked
|
||||
'$remote_addr - $remote_user [$time_local] '
|
||||
'"$fa_request_masked" $status $body_bytes_sent '
|
||||
'"$fa_referer_masked" "$http_user_agent"';
|
||||
```
|
||||
|
||||
The `map` blocks must sit in the `http {}` context. Files in `/etc/nginx/conf.d/` are
|
||||
included there by default — if your setup doesn't include `conf.d/*.conf`, paste the
|
||||
block inside `http {}` in `/etc/nginx/nginx.conf` instead.
|
||||
|
||||
### 1b. Point the site at the masked format
|
||||
|
||||
Find your site's server block and its current `access_log` line:
|
||||
|
||||
```bash
|
||||
sudo nginx -T | grep -nE "server_name|access_log|root" | grep -i fuel
|
||||
ls /etc/nginx/sites-enabled/ # the site file is usually here
|
||||
```
|
||||
|
||||
Inside that `server { … }` block, set:
|
||||
|
||||
```nginx
|
||||
access_log /var/log/nginx/fuel-alert.access.log fuelalert_masked;
|
||||
```
|
||||
|
||||
(Keep the path the same as whatever it currently is; only the trailing format name
|
||||
`fuelalert_masked` is the change.)
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Scrub the Nginx error log
|
||||
|
||||
Error-log lines are generated internally by Nginx and **do not pass through
|
||||
`log_format`**, so they can't be masked at write time. They include `request:` and
|
||||
`referrer:` fields that carry the coordinates. Two parts:
|
||||
|
||||
### 2a. Reduce how often request lines are written
|
||||
|
||||
In the same `server { … }` block (or globally), lower the level so routine
|
||||
warn/error/info entries that embed the request line are dropped:
|
||||
|
||||
```nginx
|
||||
error_log /var/log/nginx/fuel-alert.error.log crit;
|
||||
```
|
||||
|
||||
### 2b. Scrub whatever still gets written
|
||||
|
||||
Create `/usr/local/bin/scrub-fuel-logs.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
# Redact lat/lng values in the Nginx error log. GNU sed (Linux). Verified portable expr.
|
||||
sed -E -i 's/([?&])(lat|lng)=[^ &"]*/\1\2=***/g' /var/log/nginx/fuel-alert.error.log
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo chmod +x /usr/local/bin/scrub-fuel-logs.sh
|
||||
```
|
||||
|
||||
Run it every minute via a systemd timer.
|
||||
`/etc/systemd/system/scrub-fuel-logs.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Scrub lat/lng from Nginx error log
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/scrub-fuel-logs.sh
|
||||
```
|
||||
|
||||
`/etc/systemd/system/scrub-fuel-logs.timer`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Run lat/lng scrub every minute
|
||||
|
||||
[Timer]
|
||||
OnBootSec=1min
|
||||
OnUnitActiveSec=1min
|
||||
AccuracySec=10s
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now scrub-fuel-logs.timer
|
||||
```
|
||||
|
||||
> **Trade-off:** a coordinate from an *errored* request can sit on disk for up to ~60s
|
||||
> before the scrub runs. Error entries are low-volume, so the surface is small but not
|
||||
> zero. For a zero-window setup (nothing un-redacted ever hits disk) you'd route
|
||||
> `error_log` to syslog and scrub at the syslog layer, or tail a RAM-backed raw file
|
||||
> with Vector and write only the redacted copy — more setup; ask if you want it.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Check PHP-FPM
|
||||
|
||||
```bash
|
||||
grep -nE '^\s*(access\.log|access\.format)' /etc/php/*/fpm/pool.d/*.conf
|
||||
```
|
||||
|
||||
- **No output / commented out** → FPM isn't logging requests. Nothing to do.
|
||||
- **`access.log` is enabled** → either comment it out, or remove the `%r`, `%q`, `%Q`
|
||||
tokens from `access.format`, then `sudo systemctl reload php*-fpm`.
|
||||
|
||||
(FPM slowlog and error log record the PHP script/backtrace, not the query string —
|
||||
no action needed there.)
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Apply, test, verify
|
||||
|
||||
```bash
|
||||
# 1. Validate config BEFORE reloading — catches typos safely.
|
||||
sudo nginx -t
|
||||
# If this FAILS, do NOT reload. Fix the error first.
|
||||
|
||||
# 2. Reload Nginx (zero downtime).
|
||||
sudo systemctl reload nginx
|
||||
|
||||
# 3. Trigger a real search in the app, then inspect the freshest lines:
|
||||
sudo tail -n 5 /var/log/nginx/fuel-alert.access.log
|
||||
sudo tail -n 5 /var/log/nginx/fuel-alert.error.log
|
||||
# Expect: lat=***&lng=*** — IP, status, path, fuel_type all still present.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Honest limit — IONOS edge
|
||||
|
||||
Masking only reaches logs **you** control (your Nginx, FPM, app). It cannot touch
|
||||
anything IONOS runs in front of the box:
|
||||
|
||||
- **Plain IONOS VPS / Cloud Server (root server):** there is no HTTP-layer edge — your
|
||||
Nginx is the first HTTP hop, and IONOS network logging is L3/L4 (IPs/ports/bytes, no
|
||||
URLs). In this case this runbook covers everything that exists. ✅
|
||||
- **Managed hosting / Deploy Now / CDN / managed WAF or LB:** those terminate HTTPS and
|
||||
log full URLs with coordinates, on their retention, and you **cannot** redact them.
|
||||
Levers: ask IONOS what they log + for how long, or stop putting coords in the URL.
|
||||
|
||||
**The only way coords never reach *any* HTTP log (yours or theirs) is to keep them out
|
||||
of the URL.** To preserve shareable links without coords in the URL, use **opaque share
|
||||
tokens**: store the search server-side, share `/s/<token>`, resolve token → coords on
|
||||
the server. Coords then appear in zero logs and live in one DB row you control
|
||||
(set its precision + expiry). This is an app change, tracked separately if wanted.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
1. In the site `server {}` block, restore the original `access_log` (drop the
|
||||
`fuelalert_masked` name) and `error_log` lines.
|
||||
2. `sudo rm /etc/nginx/conf.d/fuel-alert-log-masking.conf`
|
||||
3. `sudo nginx -t && sudo systemctl reload nginx`
|
||||
4. `sudo systemctl disable --now scrub-fuel-logs.timer` (optional).
|
||||
403
docs/superpowers/specs/legal-pages-spec.md
Normal file
403
docs/superpowers/specs/legal-pages-spec.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# FuelAlert — Legal Pages Spec
|
||||
|
||||
## Context for Claude Code
|
||||
|
||||
You are generating four legal pages for **FuelAlert**, a UK consumer subscription web/mobile app that shows real-time UK fuel prices and forecasts.
|
||||
|
||||
### Reference comparable (structural check, NOT for copying)
|
||||
|
||||
A near-identical business already exists and has competent legal pages: **Fuel Finder UK** at https://www.fuel-finder.uk, operated by Scott Benson as a UK sole trader. Same business model, same data sources, same jurisdiction.
|
||||
|
||||
**Use it ONLY as a structural sanity check.** Do not copy any text. Their content is copyrighted and their specific processors / retention periods / business decisions are not FuelAlert's.
|
||||
|
||||
What Fuel Finder UK does well that this spec already incorporates:
|
||||
- Explicit sole trader disclosure naming the operator
|
||||
- Named ICO registration number in the Privacy Policy
|
||||
- Specific named processors (Stripe, hosting provider, email provider, analytics, mapping services) with what data flows to each
|
||||
- Strong price-accuracy disclaimer in Terms ("prices may not reflect real-time changes, always verify at the pump")
|
||||
- "AS IS" / "AS AVAILABLE" basis with liability limitations
|
||||
- ICO complaint route clearly stated
|
||||
- Account deletion flow that cancels paid subscription first
|
||||
|
||||
What FuelAlert must do BETTER than Fuel Finder UK:
|
||||
- **Standalone Refund & Cancellation Policy page** — Fuel Finder UK appears to bundle this into Terms. Stripe specifically asks for a refund policy; keep it as a separate page at `/legal/refund`.
|
||||
- **Express-consent checkbox at subscription checkout** for the 14-day cooling-off period under Consumer Contracts Regulations 2013 (see Refund Policy section below). This is the single most important implementation detail in the entire spec.
|
||||
|
||||
If you find yourself generating content that overlaps verbatim with Fuel Finder UK or any other site, stop and rewrite in your own words. Legal pages are copyrighted works.
|
||||
|
||||
**Business operator (data controller):**
|
||||
- Name: Ovidiu Ungureanu
|
||||
- Trading as: FuelAlert
|
||||
- Status: Sole trader (not a limited company — do not use "Ltd", "Limited", or any corporate form)
|
||||
- Location: Peterborough, United Kingdom
|
||||
- Contact email: [hello@fuelalert.co.uk]
|
||||
- ICO registration number: [PLACEHOLDER: ZAxxxxxxx — Ovidiu to register at ico.org.uk before launch, fee ~£40/yr]
|
||||
|
||||
**Service:**
|
||||
- UK fuel price comparison and forecasting
|
||||
- Free tier + two paid subscription tiers: Daily (£0.99/mo) and Smart (£2.49/mo); annual billing also offered
|
||||
- Data sources: UK government Fuel Finder / Pump Watch open data, ONS Postcode Directory (OGL/Crown Copyright)
|
||||
- Processes: email address, postcode/location data, account credentials, payment data (via Stripe), usage telemetry
|
||||
|
||||
**Jurisdiction:**
|
||||
- England and Wales
|
||||
- Consumer law: Consumer Rights Act 2015, Consumer Contracts (Information, Cancellation and Additional Charges) Regulations 2013 ("CCRs")
|
||||
- Data protection: UK GDPR + Data Protection Act 2018, PECR for cookies/marketing
|
||||
- Digital markets: Digital Markets, Competition and Consumers Act 2024 ("DMCC")
|
||||
|
||||
**Tech stack & integration:**
|
||||
- Laravel + Blade for server-rendered pages (NOT Vue components — these must render in raw HTML for Stripe reviewers and SEO)
|
||||
- Route prefix: `/legal/*`
|
||||
- Layout: extend the existing site layout but with a readable prose container (max-width ~720px, generous line-height)
|
||||
- Each page has a `last_updated` date in front matter — set to today
|
||||
- All pages must be linkable from the footer and accessible without JavaScript
|
||||
|
||||
### ⚠️ CRITICAL: Server-rendered Blade only, NOT Vue
|
||||
|
||||
The rest of this application is a Vue SPA. **That is irrelevant for these pages.** The legal pages must be server-rendered Blade templates.
|
||||
|
||||
Rules:
|
||||
- Files live at `resources/views/legal/{name}.blade.php` — never `.vue`
|
||||
- Body content must be plain HTML with Blade directives (`@extends`, `@section`, `{{ }}`)
|
||||
- No Vue components anywhere in the page body — no custom tags like `<legal-privacy-page>`, no `<div id="app">`, no Vue mounting points
|
||||
- No client-side rendering for the legal content itself — Alpine.js is acceptable only for the cookie banner show/hide behaviour, nothing else
|
||||
- Pages must work fully with JavaScript disabled
|
||||
|
||||
**Verification test (run after generation):**
|
||||
|
||||
```bash
|
||||
curl -s https://[host]/legal/privacy | grep -i "data controller"
|
||||
curl -s https://[host]/legal/terms | grep -i "subscription"
|
||||
curl -s https://[host]/legal/refund | grep -i "14-day"
|
||||
curl -s https://[host]/legal/cookies | grep -i "essential"
|
||||
```
|
||||
|
||||
Each command must return matching lines from the raw HTML response. If any returns empty, the page has been built as a client-rendered component and must be redone as Blade.
|
||||
|
||||
If you find yourself reaching for `<script>` tags, Vue single-file components, or any pattern that requires the browser to execute JS before content appears — stop and use plain Blade instead.
|
||||
|
||||
---
|
||||
|
||||
## Pages to generate
|
||||
|
||||
1. `/legal/privacy` — Privacy Policy
|
||||
2. `/legal/terms` — Terms of Service
|
||||
3. `/legal/refund` — Refund & Cancellation Policy (can be a section of Terms, but a separate page is cleaner for Stripe review)
|
||||
4. `/legal/cookies` — Cookie Policy (paired with a real consent banner — see separate section)
|
||||
|
||||
---
|
||||
|
||||
## 1. Privacy Policy (`/legal/privacy`)
|
||||
|
||||
### Required sections, in order:
|
||||
|
||||
**1. Who we are**
|
||||
- Identify Ovidiu Ungureanu as data controller, trading as FuelAlert
|
||||
- Sole trader status, Peterborough address (use generic "Peterborough, UK" unless a registered business address is provided)
|
||||
- ICO registration number
|
||||
- Contact email for privacy queries
|
||||
|
||||
**2. What data we collect**
|
||||
Break into clear subsections:
|
||||
- *Account data*: email, hashed password, account creation date
|
||||
- *Location data*: postcodes entered, optional precise GPS if granted, derived location for nearby-station queries
|
||||
- *Payment data*: handled by Stripe; FuelAlert does NOT store card numbers. Stripe customer ID and subscription metadata only.
|
||||
- *Usage data*: features used, queries made, price alerts configured (for service delivery and improvement)
|
||||
- *Technical data*: IP address, browser type, device type (for security and analytics)
|
||||
- *Marketing preferences*: only if user opts in
|
||||
|
||||
**3. Lawful basis for processing**
|
||||
For each category, state the UK GDPR Article 6 basis:
|
||||
- Account + service delivery: **contract** (Art. 6(1)(b))
|
||||
- Payment processing: **contract**
|
||||
- Security/fraud prevention: **legitimate interests** (Art. 6(1)(f))
|
||||
- Analytics/product improvement: **legitimate interests**, with opt-out via cookie banner
|
||||
- Marketing emails: **consent** (Art. 6(1)(a))
|
||||
|
||||
**4. How we use your data**
|
||||
Bullet list, plain English. Tie back to the lawful basis for each use.
|
||||
|
||||
**5. Who we share data with (processors)**
|
||||
Named list — be specific:
|
||||
- **Stripe** (payment processing) — refer to Stripe's privacy policy
|
||||
- **[Hosting provider, e.g. Hetzner]** (infrastructure) — EU-based, note location
|
||||
- **[Email provider, e.g. Fastmail / Postmark]** (transactional email)
|
||||
- **[Analytics, e.g. Plausible]** — if used; if self-hosted, say so
|
||||
- State that we do NOT sell data to third parties
|
||||
|
||||
**6. International transfers**
|
||||
- State whether any processors are outside the UK/EEA (Stripe has US operations)
|
||||
- Reference appropriate safeguards: Standard Contractual Clauses (SCCs) and UK International Data Transfer Addendum
|
||||
|
||||
**7. How long we keep data**
|
||||
- Active account data: while account is active + 12 months after closure
|
||||
- Payment records: 6 years (HMRC requirement for sole traders)
|
||||
- Marketing data: until consent is withdrawn
|
||||
- Logs/analytics: max 24 months
|
||||
|
||||
**8. Your rights under UK GDPR**
|
||||
List all eight rights with one-line explanations:
|
||||
- Right of access
|
||||
- Right to rectification
|
||||
- Right to erasure ("right to be forgotten")
|
||||
- Right to restrict processing
|
||||
- Right to data portability
|
||||
- Right to object
|
||||
- Rights related to automated decision-making (state we do NOT make solely automated decisions with legal effect)
|
||||
- Right to withdraw consent
|
||||
|
||||
Explain how to exercise rights (email to the contact address, response within one month).
|
||||
|
||||
**9. Cookies**
|
||||
Brief mention with link to the dedicated Cookie Policy.
|
||||
|
||||
**10. Security**
|
||||
Plain-English summary: HTTPS, hashed passwords, encrypted database fields where appropriate, access controls, regular updates. Don't overpromise ("bank-grade" etc. — meaningless and creates liability).
|
||||
|
||||
**11. Children**
|
||||
Service is not directed at under-16s; we do not knowingly collect data from children.
|
||||
|
||||
**12. Complaints**
|
||||
Right to complain to the ICO: ico.org.uk, 0303 123 1113. Encourage contacting FuelAlert first.
|
||||
|
||||
**13. Changes to this policy**
|
||||
We will notify users of material changes by email; non-material changes shown by updated "Last updated" date.
|
||||
|
||||
**14. Contact**
|
||||
Email address for privacy queries.
|
||||
|
||||
### Tone & style
|
||||
- Plain English, Hemingway readability score ~grade 8
|
||||
- No legalese where avoidable
|
||||
- Use "we" / "you"
|
||||
- Short paragraphs, lots of headings, scannable
|
||||
|
||||
---
|
||||
|
||||
## 2. Terms of Service (`/legal/terms`)
|
||||
|
||||
### Required sections, in order:
|
||||
|
||||
**1. About these terms**
|
||||
- Who FuelAlert is (sole trader disclosure)
|
||||
- These terms form a contract between you and Ovidiu Ungureanu trading as FuelAlert
|
||||
- By using the service you agree to these terms
|
||||
- Governing law: England and Wales
|
||||
|
||||
**2. The service**
|
||||
- What FuelAlert does
|
||||
- Free tier and paid tier descriptions
|
||||
- We may add, remove, or change features with reasonable notice
|
||||
|
||||
**3. Your account**
|
||||
- Eligibility: 18+, UK resident, accurate information required
|
||||
- One account per person
|
||||
- You're responsible for keeping login credentials secure
|
||||
- We may suspend accounts for breach of these terms
|
||||
|
||||
**4. Subscriptions, billing and payment**
|
||||
This is the section Stripe cares most about. Cover:
|
||||
- Prices (link to /pricing) — currently £0.99/mo Daily, £2.49/mo Smart, with annual discount
|
||||
- Billing cycle: monthly or annual, charged in advance
|
||||
- **Auto-renewal**: subscriptions auto-renew at the end of each period at the then-current price, until cancelled
|
||||
- Payment method: card via Stripe; you authorise FuelAlert (via Stripe) to charge your method on each renewal
|
||||
- Failed payments: we'll retry and notify you; persistent failure suspends paid features
|
||||
- Price changes: 30 days' notice by email before any price increase takes effect on renewal; you may cancel before the increase
|
||||
- VAT: prices include UK VAT where applicable (sole traders below the VAT threshold do not charge VAT — clarify FuelAlert's current status)
|
||||
|
||||
**5. Cancellation and refunds**
|
||||
Cross-reference the Refund Policy. Summarise:
|
||||
- You can cancel at any time from your account settings
|
||||
- Cancellation stops the next renewal; you keep access until the end of the current billing period
|
||||
- 14-day right to cancel for new subscribers (see Refund Policy for the express-consent mechanism that affects this)
|
||||
|
||||
**6. Acceptable use**
|
||||
You agree not to:
|
||||
- Scrape, reverse-engineer, or bulk-extract data from the service
|
||||
- Resell or redistribute price data
|
||||
- Use the service for any unlawful purpose
|
||||
- Attempt to compromise security
|
||||
- Create automated queries beyond normal personal use
|
||||
|
||||
**7. Accuracy of price data — IMPORTANT**
|
||||
This is your liability shield. Say clearly:
|
||||
- Prices are sourced from official UK government data feeds (Pump Watch / Fuel Finder) and refreshed periodically
|
||||
- We make reasonable efforts to display accurate prices but **cannot guarantee** prices are correct at the moment you arrive at a station
|
||||
- Stations may change prices between data feed updates
|
||||
- FuelAlert is not liable for losses arising from inaccurate price data (wasted journey, fuel cost differences, etc.)
|
||||
- Always confirm the price at the pump before fuelling
|
||||
|
||||
**8. Forecasts and predictions**
|
||||
- Forecasts are informational only and not financial advice
|
||||
- Past trends do not guarantee future prices
|
||||
- We do not warrant the accuracy of any forecast
|
||||
|
||||
**9. Intellectual property**
|
||||
- The FuelAlert name, logo, software, and original content are owned by Ovidiu Ungureanu
|
||||
- Underlying fuel price data is owned by the respective retailers and published under government open data schemes; ONS Postcode Directory data is © Crown Copyright, used under Open Government Licence v3.0
|
||||
- You get a limited, non-exclusive, revocable licence to use the service for personal, non-commercial purposes (or commercial fleet use if on a Fleet plan)
|
||||
|
||||
**10. Third-party services**
|
||||
We use Stripe for payments. Your use of Stripe is also subject to Stripe's terms.
|
||||
|
||||
**11. Limitation of liability**
|
||||
- We don't exclude liability for death/personal injury caused by negligence, fraud, or anything that can't be excluded under UK consumer law
|
||||
- We exclude liability for indirect, consequential, or business losses
|
||||
- For consumers using the paid service, our total liability in any 12-month period is capped at the amount you paid in subscription fees in that period
|
||||
- We don't accept liability for issues caused by third-party services we depend on (Stripe outage, data feed outage, etc.)
|
||||
|
||||
**12. Termination**
|
||||
- You can stop using the service and close your account at any time
|
||||
- We can terminate access for serious breach of these terms, with notice where reasonable
|
||||
- On termination, paid sections 4–11 of these terms survive
|
||||
|
||||
**13. Changes to these terms**
|
||||
We may update these terms; material changes notified by email at least 14 days before they take effect.
|
||||
|
||||
**14. Disputes**
|
||||
- Try to resolve directly by contacting us first
|
||||
- These terms are governed by the laws of England and Wales; courts of England and Wales have non-exclusive jurisdiction
|
||||
- Consumers retain the right to bring proceedings in their country of residence
|
||||
- Reference to alternative dispute resolution if applicable
|
||||
|
||||
**15. Contact**
|
||||
Email address.
|
||||
|
||||
---
|
||||
|
||||
## 3. Refund & Cancellation Policy (`/legal/refund`)
|
||||
|
||||
This page is required by Stripe specifically and by UK consumer law. Keep it short and clear.
|
||||
|
||||
### Required sections:
|
||||
|
||||
**1. Your 14-day right to cancel (cooling-off period)**
|
||||
- Under the Consumer Contracts Regulations 2013, you have 14 days from the date you subscribe to cancel without giving a reason
|
||||
- This applies to **new subscribers only**; not subsequent renewals
|
||||
|
||||
**2. Express consent to start the service immediately — CRITICAL CLAUSE**
|
||||
- When you subscribe, you can choose to start using the paid features immediately
|
||||
- By doing so, you expressly acknowledge that you **lose your right to cancel** once the service has been fully supplied (i.e. once you've used the paid features within the 14-day window)
|
||||
- If you cancel before using the paid features, you receive a full refund
|
||||
- If you cancel within 14 days having used some paid features, we may reduce the refund proportionally to reflect usage
|
||||
|
||||
**Implementation note for the signup flow:** the express consent must be an unticked checkbox at checkout, with explicit text. Do not pre-tick it. Suggested wording for the checkbox label:
|
||||
> "I want my subscription to start immediately. I understand that by using paid features within the 14-day cooling-off period, I will lose my right to cancel under the Consumer Contracts Regulations 2013."
|
||||
|
||||
**3. How to cancel**
|
||||
- From your account: Settings → Subscription → Cancel
|
||||
- Or by emailing the contact address
|
||||
- Cancellation is effective at the end of the current billing period unless you exercise the 14-day right above
|
||||
|
||||
**4. Refunds outside the 14-day period**
|
||||
- After 14 days, subscription fees are non-refundable for the remainder of the period you paid for
|
||||
- You keep access until the end of the period
|
||||
- We may issue discretionary refunds in cases of service failure or where required by law
|
||||
|
||||
**5. Annual subscriptions**
|
||||
- Same 14-day right applies
|
||||
- After 14 days, annual fees are non-refundable; no pro-rata refund for unused months
|
||||
|
||||
**6. Failed payments and involuntary cancellation**
|
||||
- If your payment fails, we'll retry and notify you
|
||||
- Paid features suspend after [N] failed retries
|
||||
- Your account is not deleted; you can resume by updating payment
|
||||
|
||||
**7. How long refunds take**
|
||||
- Refunds are issued to the original payment method within 5–10 business days of approval
|
||||
|
||||
**8. Contact**
|
||||
Email address for refund requests.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cookie Policy (`/legal/cookies`)
|
||||
|
||||
### Required sections:
|
||||
|
||||
**1. What cookies are**
|
||||
One short paragraph.
|
||||
|
||||
**2. Cookies we use**
|
||||
Table format with columns: Name | Purpose | Duration | Type (Essential / Analytics / Marketing).
|
||||
|
||||
Categories to include:
|
||||
- **Essential**: session, CSRF, authentication, cookie-consent preference itself — these do not require consent under PECR
|
||||
- **Analytics**: only if you use them; name them specifically (Plausible is cookie-less in many configs, GA4 uses several)
|
||||
- **Marketing**: only if you run any — likely none at launch
|
||||
|
||||
--- ignore for now. need to decide what tool to use for this
|
||||
|
||||
**3. Your choices**
|
||||
- You can accept, decline, or customise non-essential cookies via the consent banner
|
||||
- You can change your choice at any time via "Cookie Settings" in the footer
|
||||
- Most browsers also let you block cookies — link to ICO guidance on managing cookies
|
||||
|
||||
**4. Changes to this policy**
|
||||
Standard wording.
|
||||
|
||||
**5. Contact**
|
||||
Email.
|
||||
|
||||
---
|
||||
|
||||
## Cookie consent banner (separate component)
|
||||
|
||||
Implement as a Blade component, server-rendered, that:
|
||||
|
||||
- Appears on first visit
|
||||
- Has three buttons: **Accept all**, **Reject all**, **Customise**
|
||||
- "Customise" expands to checkboxes per category (Essential — locked on; Analytics; Marketing if applicable)
|
||||
- Stores choice in a first-party cookie (`fa_cookie_consent`) for 12 months
|
||||
- **Blocks non-essential scripts from loading until consent is given** — this is the critical bit. Use a tag manager pattern or conditional Blade includes; do NOT load Google Analytics, etc. before consent.
|
||||
- Persistent "Cookie Settings" link in the footer reopens the customise panel
|
||||
|
||||
PECR / ICO require equal prominence for accept and reject. Don't make "Reject" smaller or harder to find.
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes for Claude Code
|
||||
|
||||
1. **Create Laravel routes** in `routes/web.php`:
|
||||
```php
|
||||
Route::view('/legal/privacy', 'legal.privacy')->name('legal.privacy');
|
||||
Route::view('/legal/terms', 'legal.terms')->name('legal.terms');
|
||||
Route::view('/legal/refund', 'legal.refund')->name('legal.refund');
|
||||
Route::view('/legal/cookies', 'legal.cookies')->name('legal.cookies');
|
||||
```
|
||||
|
||||
2. **Create Blade layout** `resources/views/layouts/legal.blade.php` extending the main site layout, with a narrow prose container.
|
||||
|
||||
3. **Each page** at `resources/views/legal/{name}.blade.php` should:
|
||||
- Set page title
|
||||
- Set meta description (short summary, ~150 chars)
|
||||
- Render the content in semantic HTML (h1, h2, h3, ul, ol, table where appropriate)
|
||||
- Show "Last updated: [date]" at top
|
||||
- Use Blade `@section` for content
|
||||
|
||||
4. **Update footer** to link to all four legal pages with `route()` helpers. Replace any existing "Cookie Settings" link with a button that opens the consent banner customise panel.
|
||||
|
||||
5. **Cookie banner component** at `resources/views/components/cookie-banner.blade.php` — include in main layout, with Alpine.js or vanilla JS for show/hide logic. Must be functional with JavaScript disabled (degrade to "you can change cookie settings in your browser" message).
|
||||
|
||||
6. **Do NOT** auto-generate the ICO registration number or contact email. Leave clear `[PLACEHOLDER: ...]` markers throughout for Ovidiu to fill in before deployment.
|
||||
|
||||
7. **Do NOT** include claims that aren't true yet — if uncertain, use a `[PLACEHOLDER: verify before launch]` marker rather than guessing.
|
||||
|
||||
8. **At the top of each generated file**, add a comment:
|
||||
```
|
||||
{{-- DRAFT: Generated [date]. Review by UK-qualified solicitor recommended before launch. --}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-launch checklist for Ovidiu (not for Claude Code)
|
||||
|
||||
- [ ] Register with the ICO (ico.org.uk) and obtain registration number — required by law for processing personal data commercially
|
||||
- [ ] Replace all `[PLACEHOLDER: ...]` markers
|
||||
- [ ] Confirm hosting provider and processors named in the Privacy Policy are accurate
|
||||
- [ ] Test the cookie banner: does it block non-essential scripts before consent?
|
||||
- [ ] Test the signup flow: is the express-consent checkbox unticked by default?
|
||||
- [ ] Test account cancellation: does it work end-to-end?
|
||||
- [ ] Have a UK-qualified solicitor review (or use Termly/iubenda as a sanity check)
|
||||
- [ ] Set `last_updated` dates to the actual go-live date
|
||||
- [ ] Submit to Stripe with the live URL
|
||||
@@ -12,7 +12,7 @@ decision — which channels a user can receive, how often, and what features the
|
||||
|-------|--------|---------------|-----------|-----------|------------|----------------|-----------------|--------------|------------|
|
||||
| free | £0 | weekly digest | — | — | — | — | — | — | 1 |
|
||||
| basic | £0.99 | daily | daily | daily | — | — | ✓ | ✓ | 1 |
|
||||
| plus | £2.49 | triggered | triggered | triggered | max 1/day | ✓ | ✓ | ✓ | 1 |
|
||||
| plus | £2.49 | triggered | triggered | triggered | max 3/day | ✓ | ✓ | ✓ | 1 |
|
||||
| pro | £3.99 | triggered | triggered | triggered | max 3/day | ✓ | ✓ | ✓ | unlimited |
|
||||
|
||||
Tiers are stored in the `plans` table. The `features` JSON column defines every limit and flag.
|
||||
|
||||
862
package-lock.json
generated
862
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,10 +11,11 @@
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"axios": "^1.15.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"iconify-icon": "^3.0.2",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"shell-quote": "^1.8.4",
|
||||
"tailwindcss": "^4.0.7",
|
||||
"vite": "^8.0.0",
|
||||
"vue": "^3.5.32",
|
||||
|
||||
135
resources/js/components/PricingGrid.vue
Normal file
135
resources/js/components/PricingGrid.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<!-- Pricing -->
|
||||
<section id="pricing" class="py-4 md:py-24 px-6 bg-zinc-50">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
|
||||
<p class="text-zinc-500 text-lg mb-8">Save hundreds for less than the cost of a coffee.</p>
|
||||
<div class="inline-flex items-center gap-1 p-1 bg-white border border-zinc-300 rounded-full">
|
||||
<button
|
||||
:class="cadence === 'monthly' ? 'bg-accent text-white' : 'text-zinc-500'"
|
||||
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
|
||||
type="button"
|
||||
@click="cadence = 'monthly'"
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
:class="cadence === 'annual' ? 'bg-accent text-white' : 'text-zinc-500'"
|
||||
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
|
||||
type="button"
|
||||
@click="cadence = 'annual'"
|
||||
>
|
||||
Annual <span class="text-[10px] opacity-80">(save 17%)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<!-- Free -->
|
||||
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Free</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black">£0</span>
|
||||
<span class="text-zinc-500 text-sm">/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Local station price search</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-wait verdict</li>
|
||||
<li class="text-sm flex gap-2 text-zinc-500"><iconify-icon class="text-zinc-300" icon="lucide:x"></iconify-icon> No price alerts</li>
|
||||
</ul>
|
||||
<a :href="ctaHref('free')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('free') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Daily (backend: basic) -->
|
||||
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Daily</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black">{{ PRICES[cadence].basic }}</span>
|
||||
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-wait score + reason</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Daily email, push & WhatsApp</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Price-drop & score alerts</li>
|
||||
</ul>
|
||||
<a v-if="!isComingSoon('basic')" :href="ctaHref('basic')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('basic') }}</a>
|
||||
<button v-else type="button" disabled class="w-full py-3 px-4 border border-zinc-200 bg-zinc-50 rounded-xl text-center font-bold text-zinc-400 cursor-not-allowed">Coming soon</button>
|
||||
</div>
|
||||
|
||||
<!-- Smart (backend: plus) -->
|
||||
<div class="bg-white border-2 border-accent p-8 rounded-3xl flex flex-col h-full relative">
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-accent text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Most pick this</div>
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Smart</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black text-accent">{{ PRICES[cadence].plus }}</span>
|
||||
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2 font-bold"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> AI fuel-price prediction</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Real-time email, push & WhatsApp</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> SMS alerts (up to 3/day)</li>
|
||||
</ul>
|
||||
<a v-if="!isComingSoon('plus')" :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
|
||||
const { isAuthenticated, userTier } = useAuth()
|
||||
|
||||
const cadence = ref('monthly')
|
||||
|
||||
const PRICES = {
|
||||
monthly: { basic: '£0.99', plus: '£2.49', pro: '£3.99' },
|
||||
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
|
||||
}
|
||||
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
||||
|
||||
// Paid tiers whose alerting features aren't fully shipped yet — their CTAs are
|
||||
// disabled until then. Remove a tier from this list to make its button live.
|
||||
const COMING_SOON = ['basic', 'plus']
|
||||
|
||||
function isComingSoon(tier) {
|
||||
return COMING_SOON.includes(tier)
|
||||
}
|
||||
|
||||
function ctaHref(tier) {
|
||||
if (tier === 'free') {
|
||||
return isAuthenticated.value ? '/dashboard' : '/register'
|
||||
}
|
||||
if (!isAuthenticated.value) {
|
||||
return '/register?tier=' + tier + '&cadence=' + cadence.value
|
||||
}
|
||||
if (userTier.value === tier) {
|
||||
return '/billing/portal'
|
||||
}
|
||||
return '/billing/checkout/' + tier + '/' + cadence.value
|
||||
}
|
||||
|
||||
function ctaLabel(tier) {
|
||||
if (tier === 'free') {
|
||||
return isAuthenticated.value ? 'Go to dashboard' : 'Start free'
|
||||
}
|
||||
if (isAuthenticated.value && userTier.value === tier) {
|
||||
return 'Manage subscription'
|
||||
}
|
||||
return {
|
||||
basic: 'Choose Daily',
|
||||
plus: 'Choose Smart',
|
||||
pro: 'Choose Pro',
|
||||
}[tier]
|
||||
}
|
||||
</script>
|
||||
@@ -47,6 +47,6 @@ const stationCountLabel = computed(() => {
|
||||
return new Intl.NumberFormat('en-GB').format(props.stationCount)
|
||||
})
|
||||
|
||||
const ctaHref = computed(() => isAuthenticated.value ? '#pricing' : '/register?tier=plus&cadence=monthly')
|
||||
const ctaHref = computed(() => isAuthenticated.value ? '/pricing' : '/register?tier=plus&cadence=monthly')
|
||||
const ctaLabel = computed(() => isAuthenticated.value ? 'See plans' : 'Start saving')
|
||||
</script>
|
||||
|
||||
@@ -58,6 +58,18 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['search'])
|
||||
|
||||
// Coarsen GPS coordinates to ~111 m (3 dp) before they leave the browser.
|
||||
// "Use my location" coords flow into the shareable URL, the /api/stations
|
||||
// request, and server/access logs — full precision would broadcast the user's
|
||||
// exact position to anyone they share the resulting link with. 3 dp is ample
|
||||
// for a radius station search. See .claude/rules/frontend.md.
|
||||
const COORDINATE_DECIMALS = 3
|
||||
|
||||
function coarsenCoordinate(value) {
|
||||
const factor = 10 ** COORDINATE_DECIMALS
|
||||
return Math.round(value * factor) / factor
|
||||
}
|
||||
|
||||
const postcode = ref('')
|
||||
const locating = ref(false)
|
||||
|
||||
@@ -88,8 +100,8 @@ function useMyLocation() {
|
||||
postcode.value = ''
|
||||
emit('search', {
|
||||
postcode: null,
|
||||
lat: coords.latitude,
|
||||
lng: coords.longitude,
|
||||
lat: coarsenCoordinate(coords.latitude),
|
||||
lng: coarsenCoordinate(coords.longitude),
|
||||
fuelType: props.fuelType,
|
||||
radius: props.radius,
|
||||
sort: props.sort,
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
<div class="hidden lg:flex items-center gap-8 font-mono text-[11px] uppercase tracking-widest text-zinc-600">
|
||||
<a class="hover:text-accent transition-colors" href="#how-it-works">How it works</a>
|
||||
<a class="hover:text-accent transition-colors" href="#features">Why it works</a>
|
||||
<a class="hover:text-accent transition-colors" href="#pricing">Pricing</a>
|
||||
<a class="hover:text-accent transition-colors" href="#fleet">Fleet</a>
|
||||
<RouterLink class="hover:text-accent transition-colors" to="/pricing">Pricing</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 md:gap-5">
|
||||
|
||||
55
resources/js/components/landing/SiteFooter.vue
Normal file
55
resources/js/components/landing/SiteFooter.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<!-- Footer -->
|
||||
<footer class="bg-zinc-50 border-t border-zinc-300 pt-16 pb-8 px-6">
|
||||
<div class="max-w-7xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-12 mb-12">
|
||||
<div class="col-span-2 md:col-span-1 space-y-4">
|
||||
<RouterLink class="flex items-center gap-2" to="/">
|
||||
<div class="w-8 h-8 rounded bg-accent flex items-center justify-center">
|
||||
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
|
||||
</div>
|
||||
<span class="text-xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
|
||||
</RouterLink>
|
||||
<p class="text-sm text-zinc-500 leading-relaxed">
|
||||
Helping UK drivers save money at the pump. Real-time data, smarter choices.
|
||||
</p>
|
||||
<p class="text-sm text-zinc-500">
|
||||
Questions? <a class="text-accent hover:underline" href="mailto:hello@fuel-alert.co.uk">hello@fuel-alert.co.uk</a>
|
||||
</p>
|
||||
<div class="flex gap-4">
|
||||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:twitter"></iconify-icon>
|
||||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:facebook"></iconify-icon>
|
||||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:instagram"></iconify-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Product</h5>
|
||||
<ul class="space-y-2 text-sm text-zinc-500">
|
||||
<li><RouterLink class="hover:text-accent transition-colors" to="/pricing">Pricing</RouterLink></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#how-it-works">How it works</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#features">Why it works</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Legal</h5>
|
||||
<ul class="space-y-2 text-sm text-zinc-500">
|
||||
<li><a class="hover:text-accent transition-colors" href="/legal/privacy">Privacy Policy</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="/legal/terms">Terms of Service</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="/legal/refund">Refund & Cancellation</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="/legal/cookies">Cookie Policy</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] tracking-widest text-zinc-500">
|
||||
<p>© 2026 FuelAlert UK. FuelAlert is a trading name of Ovidiu Ungureanu, sole trader, based in Peterborough, UK.</p>
|
||||
<p>Data provided by official UK retail price transparency schemes.</p>
|
||||
<p>Postcode data from <a class="underline hover:text-accent" href="https://geoportal.statistics.gov.uk/datasets/ons::onspd-online-latest-centroids-1/about" rel="noopener" target="_blank">ONS Postcode Directory</a>: contains OS data © Crown copyright & database right, Royal Mail data © Royal Mail copyright & database right, and National Statistics data © Crown copyright & database right.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
@@ -22,7 +22,7 @@ const stats = computed(() => [
|
||||
value: props.stationCount ? props.stationCount.toLocaleString('en-GB') : '11,482',
|
||||
label: 'Stations tracked',
|
||||
},
|
||||
{ value: '£273', label: 'Median saving / yr' },
|
||||
{ value: '£8.40', label: 'Avg saving per tank*' },
|
||||
{ value: '84%', label: 'Forecast accuracy' },
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '../views/Home.vue'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
|
||||
const Pricing = () => import('../views/Pricing.vue')
|
||||
const DashboardLayout = () => import('../views/dashboard/DashboardLayout.vue')
|
||||
const Overview = () => import('../views/dashboard/Overview.vue')
|
||||
const SavedStations = () => import('../views/dashboard/SavedStations.vue')
|
||||
@@ -13,6 +14,7 @@ const Appearance = () => import('../views/dashboard/settings/Appearance.vue')
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: Home, name: 'home' },
|
||||
{ path: '/pricing', component: Pricing, name: 'pricing' },
|
||||
{
|
||||
path: '/logout',
|
||||
name: 'logout',
|
||||
|
||||
@@ -225,90 +225,8 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pricing -->
|
||||
<section id="pricing" class="py-4 md:py-24 px-6 bg-zinc-50">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
|
||||
<p class="text-zinc-500 text-lg mb-8">Save hundreds for less than the cost of a coffee.</p>
|
||||
<div class="inline-flex items-center gap-1 p-1 bg-white border border-zinc-300 rounded-full">
|
||||
<button
|
||||
:class="cadence === 'monthly' ? 'bg-accent text-white' : 'text-zinc-500'"
|
||||
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
|
||||
type="button"
|
||||
@click="cadence = 'monthly'"
|
||||
>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
:class="cadence === 'annual' ? 'bg-accent text-white' : 'text-zinc-500'"
|
||||
class="px-5 py-2 rounded-full text-sm font-bold transition-colors"
|
||||
type="button"
|
||||
@click="cadence = 'annual'"
|
||||
>
|
||||
Annual <span class="text-[10px] opacity-80">(save 17%)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
<!-- Free -->
|
||||
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Free</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black">£0</span>
|
||||
<span class="text-zinc-500 text-sm">/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Basic Search</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Daily Updates</li>
|
||||
<li class="text-sm flex gap-2 text-zinc-500"><iconify-icon class="text-zinc-300" icon="lucide:x"></iconify-icon> No Alerts</li>
|
||||
</ul>
|
||||
<a :href="ctaHref('free')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('free') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Daily (backend: basic) -->
|
||||
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Daily</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black">{{ PRICES[cadence].basic }}</span>
|
||||
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-Wait Score</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
|
||||
</ul>
|
||||
<a :href="ctaHref('basic')" class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('basic') }}</a>
|
||||
</div>
|
||||
|
||||
<!-- Smart (backend: plus) -->
|
||||
<div class="bg-white border-2 border-accent p-8 rounded-3xl flex flex-col h-full relative">
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-accent text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Most pick this</div>
|
||||
<div class="mb-8">
|
||||
<h4 class="text-xl font-bold font-display mb-2">Smart</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-4xl font-black text-accent">{{ PRICES[cadence].plus }}</span>
|
||||
<span class="text-zinc-500 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="space-y-4 mb-8 flex-1">
|
||||
<li class="text-sm flex gap-2 font-bold"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Supermarket Anchor</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Priority Price Alerts</li>
|
||||
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-location tracking</li>
|
||||
</ul>
|
||||
<a :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Testimonials -->
|
||||
<section class="py-12 md:py-24 px-6">
|
||||
<!-- <section class="py-12 md:py-24 px-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex flex-col md:flex-row gap-12 items-center">
|
||||
<div class="md:w-1/3">
|
||||
@@ -346,7 +264,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>-->
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center" v-if="!isAuthenticated">
|
||||
@@ -360,68 +278,14 @@
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-zinc-50 border-t border-zinc-300 pt-16 pb-8 px-6">
|
||||
<div class="max-w-7xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-12 mb-12">
|
||||
<div class="col-span-2 md:col-span-1 space-y-4">
|
||||
<RouterLink class="flex items-center gap-2" to="/">
|
||||
<div class="w-8 h-8 rounded bg-accent flex items-center justify-center">
|
||||
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
|
||||
</div>
|
||||
<span class="text-xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
|
||||
</RouterLink>
|
||||
<p class="text-sm text-zinc-500 leading-relaxed">
|
||||
Helping UK drivers save money at the pump since 2021. Real-time data, smarter choices.
|
||||
</p>
|
||||
<div class="flex gap-4">
|
||||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:twitter"></iconify-icon>
|
||||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:facebook"></iconify-icon>
|
||||
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:instagram"></iconify-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Product</h5>
|
||||
<ul class="space-y-2 text-sm text-zinc-500">
|
||||
<li><a class="hover:text-accent transition-colors" href="#pricing">Pricing</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#features">Features</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#">FuelAlert Pro</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#">Enterprise API</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Resources</h5>
|
||||
<ul class="space-y-2 text-sm text-zinc-500">
|
||||
<li><a class="hover:text-accent transition-colors" href="#">Market Insights</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#">How We Track</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#">Help Center</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#">Driver Safety</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h5 class="font-black text-xs text-zinc-800 tracking-widest">Legal</h5>
|
||||
<ul class="space-y-2 text-sm text-zinc-500">
|
||||
<li><a class="hover:text-accent transition-colors" href="#">Privacy Policy</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#">Terms of Service</a></li>
|
||||
<li><a class="hover:text-accent transition-colors" href="#">Cookie Settings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] tracking-widest text-zinc-500">
|
||||
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
|
||||
<p>Data provided by official UK retail price transparency schemes.</p>
|
||||
<p>Postcode data from <a class="underline hover:text-accent" href="https://geoportal.statistics.gov.uk/datasets/ons::onspd-online-latest-centroids-1/about" rel="noopener" target="_blank">ONS Postcode Directory</a>: contains OS data © Crown copyright & database right, Royal Mail data © Royal Mail copyright & database right, and National Statistics data © Crown copyright & database right.</p>
|
||||
</div>
|
||||
</footer>
|
||||
<SiteFooter />
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, nextTick, defineAsyncComponent } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuth } from '../composables/useAuth.js'
|
||||
import { useStations } from '../composables/useStations.js'
|
||||
import api from '../axios.js'
|
||||
@@ -437,8 +301,9 @@ import LiveTicker from '../components/landing/LiveTicker.vue'
|
||||
import VerdictCard from '../components/landing/VerdictCard.vue'
|
||||
import HeroSearch from '../components/landing/HeroSearch.vue'
|
||||
import StatsRow from '../components/landing/StatsRow.vue'
|
||||
import SiteFooter from '../components/landing/SiteFooter.vue'
|
||||
|
||||
const { isAuthenticated, userTier } = useAuth()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const liveStats = ref({ stationCount: null, latestPriceAt: null })
|
||||
|
||||
@@ -454,40 +319,6 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
const cadence = ref('monthly')
|
||||
|
||||
function ctaHref(tier) {
|
||||
if (tier === 'free') {
|
||||
return isAuthenticated.value ? '/dashboard' : '/register'
|
||||
}
|
||||
if (!isAuthenticated.value) {
|
||||
return '/register?tier=' + tier + '&cadence=' + cadence.value
|
||||
}
|
||||
if (userTier.value === tier) {
|
||||
return '/billing/portal'
|
||||
}
|
||||
return '/billing/checkout/' + tier + '/' + cadence.value
|
||||
}
|
||||
|
||||
function ctaLabel(tier) {
|
||||
if (tier === 'free') {
|
||||
return isAuthenticated.value ? 'Go to dashboard' : 'Start free'
|
||||
}
|
||||
if (isAuthenticated.value && userTier.value === tier) {
|
||||
return 'Manage subscription'
|
||||
}
|
||||
return {
|
||||
basic: 'Choose Daily',
|
||||
plus: 'Choose Smart',
|
||||
pro: 'Choose Pro',
|
||||
}[tier]
|
||||
}
|
||||
|
||||
const PRICES = {
|
||||
monthly: { basic: '£0.99', plus: '£2.49', pro: '£3.99' },
|
||||
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
|
||||
}
|
||||
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
|
||||
const { stations, meta, prediction, loading, error, search, reset } = useStations()
|
||||
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
|
||||
|
||||
@@ -621,6 +452,14 @@ function queryFromParams(params) {
|
||||
}
|
||||
|
||||
async function runSearch(params) {
|
||||
// Single dedup choke point. A user search calls onSearch, which both pushes to
|
||||
// the URL (synchronously firing the route.query watcher → runSearch) and then
|
||||
// calls runSearch directly — two triggers for one intent. Guarding here, where
|
||||
// both paths funnel through, collapses them into one request for a given query.
|
||||
if (lastParams.value
|
||||
&& JSON.stringify(queryFromParams(lastParams.value)) === JSON.stringify(queryFromParams(params))) {
|
||||
return
|
||||
}
|
||||
lastParams.value = params
|
||||
sort.value = params.sort ?? sort.value
|
||||
radiusMiles.value = params.radius ?? radiusMiles.value
|
||||
@@ -642,9 +481,6 @@ watch(() => route.query, (query) => {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
const sameAsLast = lastParams.value
|
||||
&& JSON.stringify(queryFromParams(lastParams.value)) === JSON.stringify(queryFromParams(params))
|
||||
if (sameAsLast) return
|
||||
runSearch(params)
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
15
resources/js/views/Pricing.vue
Normal file
15
resources/js/views/Pricing.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-zinc-50">
|
||||
<LandingNav />
|
||||
<main class="pt-20 md:pt-24">
|
||||
<PricingGrid />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LandingNav from '../components/landing/LandingNav.vue'
|
||||
import PricingGrid from '../components/PricingGrid.vue'
|
||||
import SiteFooter from '../components/landing/SiteFooter.vue'
|
||||
</script>
|
||||
@@ -64,7 +64,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="flex pt-20 max-w-7xl mx-auto w-full px-6 py-8 gap-8">
|
||||
<div class="flex flex-1 pt-20 max-w-7xl mx-auto w-full px-6 py-8 gap-8">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-56 flex-shrink-0 hidden md:block">
|
||||
<nav class="space-y-1">
|
||||
@@ -88,6 +88,8 @@
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -95,6 +97,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuth } from '../../composables/useAuth.js'
|
||||
import SiteFooter from '../../components/landing/SiteFooter.vue'
|
||||
|
||||
const { user } = useAuth()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ __('Welcome') }} - {{ config('app.name', 'Laravel') }}</title>
|
||||
<meta name="description" content="Live UK fuel prices across 11,000+ stations. See whether to fill up today or wait, based on local trends.">
|
||||
<script
|
||||
defer src="https://umami.local.uovidiu.com/script.js"
|
||||
data-website-id="26b2df00-e3fc-4c8c-97d1-75d99daa4545"
|
||||
data-do-not-track="true"
|
||||
data-domains="fuel-alert.co.uk,www.fuel-alert.co.uk"
|
||||
></script>
|
||||
<script>
|
||||
window['FUEL_TYPES'] = @json(
|
||||
collect(App\Enums\FuelType::cases())
|
||||
|
||||
86
resources/views/components/layouts/legal.blade.php
Normal file
86
resources/views/components/layouts/legal.blade.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>{{ ($title ?? $heading ?? 'Legal').' - '.config('app.name') }}</title>
|
||||
@isset($metaDescription)
|
||||
<meta name="description" content="{{ $metaDescription }}">
|
||||
@endisset
|
||||
|
||||
<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=inter:400,500,600&family=manrope:600,700,800,900&display=swap" rel="stylesheet" />
|
||||
|
||||
{{-- CSS only — no @fluxAppearance: it applies Flux's `.dark` class from the OS
|
||||
preference, which overrides --color-accent to white. Legal pages render no
|
||||
Flux components, so they stay light like the rest of the public site. --}}
|
||||
@vite(['resources/css/app.css'])
|
||||
|
||||
<script src="https://code.iconify.design/iconify-icon/1.0.7/iconify-icon.min.js" defer></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-[#f5ede5] text-zinc-900 antialiased">
|
||||
<nav class="fixed top-0 w-full z-50 bg-zinc-50/90 backdrop-blur-sm border-b border-zinc-300 px-6 py-4 md:px-12">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-between gap-6">
|
||||
<a class="flex items-center gap-3 shrink-0" href="/">
|
||||
<div class="w-9 h-9 md:w-10 md:h-10 rounded-lg bg-accent flex items-center justify-center shadow-md">
|
||||
<iconify-icon class="text-white text-xl" icon="lucide:fuel"></iconify-icon>
|
||||
</div>
|
||||
<span class="text-xl md:text-2xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
|
||||
</a>
|
||||
|
||||
<div class="hidden lg:flex items-center gap-8 font-mono text-[11px] uppercase tracking-widest text-zinc-600">
|
||||
<a class="hover:text-accent transition-colors" href="/#how-it-works">How it works</a>
|
||||
<a class="hover:text-accent transition-colors" href="/#features">Why it works</a>
|
||||
<a class="hover:text-accent transition-colors" href="/#pricing">Pricing</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 md:gap-5">
|
||||
@auth
|
||||
<a class="bg-accent text-white px-5 py-2 rounded-full text-sm font-bold shadow-md hover:bg-primary-dark transition-all" href="/dashboard">
|
||||
Dashboard
|
||||
</a>
|
||||
@else
|
||||
<a class="text-sm font-semibold text-zinc-600 hover:text-zinc-900 transition-colors" href="/login">Login</a>
|
||||
<a class="hidden sm:inline-flex bg-accent text-white px-5 py-2 rounded-full text-sm font-bold shadow-md hover:bg-primary-dark transition-all" href="/register">
|
||||
Get started
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="mx-auto max-w-3xl px-6 pt-28 pb-12 md:pt-32">
|
||||
<article class="space-y-6 leading-relaxed text-zinc-800">
|
||||
<header class="space-y-3 border-b border-zinc-300 pb-6">
|
||||
<h1 class="font-display text-3xl font-black tracking-tight text-zinc-900 md:text-4xl">
|
||||
{{ $heading }}
|
||||
</h1>
|
||||
@isset($lastUpdated)
|
||||
<p class="text-sm text-zinc-500">Last updated: {{ $lastUpdated }}</p>
|
||||
@endisset
|
||||
</header>
|
||||
|
||||
{{ $slot }}
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer class="mt-16 border-t border-zinc-300 bg-white/50">
|
||||
<div class="mx-auto max-w-3xl px-6 py-10 text-sm text-zinc-600">
|
||||
<nav class="flex flex-wrap gap-x-6 gap-y-2">
|
||||
<a class="hover:text-accent" href="{{ route('legal.privacy') }}">Privacy Policy</a>
|
||||
<a class="hover:text-accent" href="{{ route('legal.terms') }}">Terms of Service</a>
|
||||
<a class="hover:text-accent" href="{{ route('legal.refund') }}">Refund & Cancellation</a>
|
||||
<a class="hover:text-accent" href="{{ route('legal.cookies') }}">Cookie Policy</a>
|
||||
</nav>
|
||||
<p class="mt-6 text-xs text-zinc-500">
|
||||
© {{ date('Y') }} FuelAlert. A trading name of Ovidiu Ungureanu, sole trader, Peterborough, United Kingdom.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,55 +0,0 @@
|
||||
@props([
|
||||
'name',
|
||||
'price',
|
||||
'buttonText',
|
||||
'perks' => [],
|
||||
'featured' => false,
|
||||
'dark' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$cardClass = match(true) {
|
||||
$dark => 'bg-primary border border-primary text-white',
|
||||
$featured => 'bg-white border-2 border-primary',
|
||||
default => 'bg-white border border-zinc-300',
|
||||
};
|
||||
|
||||
$buttonClass = $dark
|
||||
? 'bg-white text-primary hover:bg-zinc-100'
|
||||
: 'bg-primary text-white hover:bg-primary-dark';
|
||||
@endphp
|
||||
|
||||
<div {{ $attributes->merge(['class' => "p-8 rounded-3xl flex flex-col relative $cardClass"]) }}>
|
||||
|
||||
@if ($featured)
|
||||
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-primary text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">
|
||||
Most Popular
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-baseline justify-between mb-6">
|
||||
<h4 class="text-xl font-bold">{{ $name }}</h4>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span @class(['text-3xl font-black', 'text-primary' => $featured])>{{ $price }}</span>
|
||||
<span @class(['text-sm', 'text-zinc-500' => !$dark, 'text-zinc-400' => $dark])>/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2 mb-6 flex-1">
|
||||
@foreach ($perks as $perk)
|
||||
<li @class(['text-sm flex gap-2 items-center', 'text-zinc-500' => !$perk['included'] && !$dark])>
|
||||
@if ($perk['included'])
|
||||
<iconify-icon icon="lucide:check" class="text-primary shrink-0"></iconify-icon>
|
||||
@else
|
||||
<iconify-icon icon="lucide:x" class="text-zinc-300 shrink-0"></iconify-icon>
|
||||
@endif
|
||||
{{ $perk['text'] }}
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
|
||||
<a href="{{ route('register') }}" class="w-full py-2.5 px-6 {{ $buttonClass }} rounded-full text-sm text-center font-bold shadow-lg transition-all hover:scale-105 active:scale-95">
|
||||
{{ $buttonText }}
|
||||
</a>
|
||||
|
||||
</div>
|
||||
106
resources/views/legal/cookies.blade.php
Normal file
106
resources/views/legal/cookies.blade.php
Normal file
@@ -0,0 +1,106 @@
|
||||
{{-- DRAFT: Generated {{ date('Y-m-d') }}. Review by UK-qualified solicitor recommended before launch. --}}
|
||||
<x-layouts.legal
|
||||
title="Cookie Policy"
|
||||
heading="Cookie Policy"
|
||||
lastUpdated="{{ now()->format('j F Y') }}"
|
||||
metaDescription="The cookies and similar technologies FuelAlert uses, and how to manage them.">
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">1. What cookies are</h2>
|
||||
<p>
|
||||
Cookies are small text files placed on your device by websites you visit. They allow a
|
||||
site to remember things between visits (for example, that you're signed in) and to
|
||||
measure how the site is used. This policy explains how FuelAlert uses cookies and
|
||||
similar technologies, and how you can manage them.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">2. Cookies we use</h2>
|
||||
<p>
|
||||
FuelAlert uses only <strong>essential</strong> cookies — cookies that are strictly
|
||||
necessary to deliver the service you've asked for. Under the Privacy and Electronic
|
||||
Communications Regulations (PECR), these do not require your consent, but we list them
|
||||
here for transparency.
|
||||
</p>
|
||||
<p>
|
||||
For aggregated usage metrics we run our own self-hosted instance of
|
||||
<strong>Umami Analytics</strong>, which is <strong>cookieless</strong> — it does
|
||||
not set any cookies, does not use device fingerprinting, and does not track you across
|
||||
sites. It does not store information that identifies you as an individual, so no consent
|
||||
is required.
|
||||
</p>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full border-collapse text-left text-sm">
|
||||
<thead class="bg-zinc-100">
|
||||
<tr>
|
||||
<th class="border border-zinc-300 px-3 py-2 font-semibold">Name</th>
|
||||
<th class="border border-zinc-300 px-3 py-2 font-semibold">Purpose</th>
|
||||
<th class="border border-zinc-300 px-3 py-2 font-semibold">Duration</th>
|
||||
<th class="border border-zinc-300 px-3 py-2 font-semibold">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="border border-zinc-300 px-3 py-2 font-mono text-xs">fuel_alert_session</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">Keeps you signed in and maintains your session state.</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">Session</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">Essential</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border border-zinc-300 px-3 py-2 font-mono text-xs">XSRF-TOKEN</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">Protects against cross-site request forgery attacks on forms and account actions.</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">Session</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">Essential</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border border-zinc-300 px-3 py-2 font-mono text-xs">remember_web_*</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">"Remember me" — keeps you signed in across browser restarts if you tick the box at login.</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">30 days</td>
|
||||
<td class="border border-zinc-300 px-3 py-2">Essential</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-zinc-600">
|
||||
If we add a non-essential cookie in future (for example, a marketing or advertising
|
||||
tool), we will add it to the table above and request your consent before it loads.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">3. Your choices</h2>
|
||||
<p>
|
||||
Because we currently use only essential cookies, there is nothing to opt in or out of
|
||||
on FuelAlert at this time. If we introduce non-essential cookies in future (for example,
|
||||
analytics or marketing), we will ask for your consent first and give you a way to accept,
|
||||
reject, or customise your choice. We will not set non-essential cookies before you have
|
||||
given consent.
|
||||
</p>
|
||||
<p>
|
||||
All major browsers also let you view, block, or delete cookies. The ICO publishes
|
||||
guidance on managing cookies in your browser:
|
||||
<a class="text-accent underline" href="https://ico.org.uk/your-data-matters/online/cookies/" target="_blank" rel="noopener">ico.org.uk · managing cookies</a>.
|
||||
Note that blocking essential cookies will prevent you from signing in.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">4. Changes to this policy</h2>
|
||||
<p>
|
||||
We may update this policy if we add new cookies, change our providers, or in response to
|
||||
legal or guidance changes. Material changes will be highlighted by an updated
|
||||
"Last updated" date at the top of this page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">5. Contact</h2>
|
||||
<p>
|
||||
Questions about cookies? Email
|
||||
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
|
||||
</p>
|
||||
</section>
|
||||
</x-layouts.legal>
|
||||
282
resources/views/legal/privacy.blade.php
Normal file
282
resources/views/legal/privacy.blade.php
Normal file
@@ -0,0 +1,282 @@
|
||||
{{-- DRAFT: Generated {{ date('Y-m-d') }}. Review by UK-qualified solicitor recommended before launch. --}}
|
||||
<x-layouts.legal
|
||||
title="Privacy Policy"
|
||||
heading="Privacy Policy"
|
||||
lastUpdated="{{ now()->format('j F Y') }}"
|
||||
metaDescription="How FuelAlert collects, uses and protects your personal data under UK GDPR.">
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">1. Who we are</h2>
|
||||
<p>
|
||||
FuelAlert is a trading name of <strong>Ovidiu Ungureanu</strong>, a sole trader based in
|
||||
Peterborough, United Kingdom. For the purposes of UK data protection law, Ovidiu Ungureanu
|
||||
is the <strong>data controller</strong> for personal data collected through this service.
|
||||
</p>
|
||||
<p>
|
||||
Ovidiu Ungureanu is registered with the UK Information Commissioner's Office (ICO) as a
|
||||
data controller. <strong>ICO registration reference: 00014395133.</strong>
|
||||
</p>
|
||||
<p>
|
||||
If you have any questions about this policy or how we handle your personal data, contact us at
|
||||
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">2. What data we collect</h2>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Account data</h3>
|
||||
<p>Your email address, a hashed password, and the date you created your account.</p>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Contact data for alerts</h3>
|
||||
<p>
|
||||
If you opt in to WhatsApp or SMS alerts, your mobile phone number. We collect it only to
|
||||
send the alerts you have requested, and only after you verify the number through a
|
||||
one-time passcode (OTP) sent to that number.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Location data</h3>
|
||||
<p>
|
||||
We use location only to show you fuel prices near you, and only when you ask us to. We
|
||||
never track your location in the background. Location reaches us in the following ways:
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li>
|
||||
<strong>Searching nearby (everyone).</strong> When you use "find prices near me",
|
||||
your browser asks your permission to share your device location. We use the
|
||||
coordinates to find nearby stations. We do not store your precise coordinates. For
|
||||
anonymous usage statistics (for example, "stations checked this week") we record
|
||||
searches only at approximately 1 km precision, together with a one-way hashed
|
||||
version of your IP address that cannot be reversed to identify you.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Shareable search links.</strong> Search results can be shared or bookmarked
|
||||
as a web link. To make this work, your filters and an approximate location are
|
||||
included in the link's web address. Location in links is rounded to roughly
|
||||
street-level precision rather than your exact position. Anyone you share a link with
|
||||
can see the approximate location it contains.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Saved location (registered users).</strong> If you provide a postcode, we
|
||||
convert it to approximate coordinates and store this against your account so we can
|
||||
show your local prices without you re-entering it. You can change or remove it in
|
||||
your account settings, and it is deleted when you delete your account.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Search and query logs</h3>
|
||||
<p>
|
||||
When you search for stations or prices, we log the approximate search location, fuel
|
||||
type selected, result count, timestamp, a one-way hashed IP address, and basic device
|
||||
information (browser type, device type). We use these logs for abuse prevention,
|
||||
troubleshooting, and aggregate service statistics. We do not use them to build a profile
|
||||
of your individual behaviour. Logs are retained for a maximum of 24 months.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Payment data</h3>
|
||||
<p>
|
||||
Payment card details are collected and processed by <strong>Stripe</strong>, our payment
|
||||
processor. FuelAlert does not see, store, or otherwise have access to your card numbers.
|
||||
We retain only your Stripe customer ID and subscription metadata (plan, billing cycle,
|
||||
renewal date).
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Push notification data</h3>
|
||||
<p>
|
||||
If you opt in to push notifications via OneSignal, we store your push subscription
|
||||
endpoint (a browser-specific URL), the encryption keys needed for secure message
|
||||
delivery, and your notification preferences. This data is retained until you unsubscribe,
|
||||
revoke browser permission, or your subscription becomes stale.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Usage data</h3>
|
||||
<p>
|
||||
Features you use and alerts you configure — used to deliver the service and improve it.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Technical data</h3>
|
||||
<p>
|
||||
IP address, browser type and version, device type, and operating system. IP address is
|
||||
collected alongside account actions and searches for security, abuse prevention, and
|
||||
fraud detection (lawful basis: legitimate interests, Art. 6(1)(f)). We do not use IP
|
||||
addresses to identify you as an individual in any other context.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold text-zinc-900">Marketing preferences</h3>
|
||||
<p>Only collected if you opt in to marketing communications.</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">3. Lawful basis for processing</h2>
|
||||
<p>We process your personal data under the following bases of UK GDPR Article 6:</p>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li><strong>Account creation and service delivery</strong> — contract (Art. 6(1)(b)).</li>
|
||||
<li><strong>Sending the alerts you configure, including by email, WhatsApp, SMS or push</strong> — contract (Art. 6(1)(b)).</li>
|
||||
<li><strong>Finding stations near you on request (device location)</strong> — consent (Art. 6(1)(a)), given through your browser's location permission and withdrawable at any time.</li>
|
||||
<li><strong>Storing your saved location as a registered user</strong> — contract (Art. 6(1)(b)).</li>
|
||||
<li><strong>Payment processing</strong> — contract (Art. 6(1)(b)).</li>
|
||||
<li><strong>Security, abuse prevention, and fraud detection (including IP address logging)</strong> — legitimate interests (Art. 6(1)(f)).</li>
|
||||
<li><strong>Search and query logging for aggregate statistics and troubleshooting</strong> — legitimate interests (Art. 6(1)(f)).</li>
|
||||
<li><strong>Aggregated, non-identifying analytics and product improvement</strong> — legitimate interests (Art. 6(1)(f)).</li>
|
||||
<li><strong>Marketing emails</strong> — consent (Art. 6(1)(a)). You can withdraw consent at any time.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">4. How we use your data</h2>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li>To create and operate your account (contract).</li>
|
||||
<li>To deliver fuel price information and the alerts you have configured (contract).</li>
|
||||
<li>To find fuel stations near you when you request it (consent).</li>
|
||||
<li>To process subscription payments via Stripe (contract).</li>
|
||||
<li>To keep our service secure and prevent abuse (legitimate interests).</li>
|
||||
<li>To understand which features are used and improve the product, using aggregated, non-identifying data (legitimate interests).</li>
|
||||
<li>To respond to your support enquiries (contract / legitimate interests).</li>
|
||||
<li>To send marketing emails if you have opted in (consent).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">5. Automated recommendations</h2>
|
||||
<p>
|
||||
FuelAlert generates fill-up timing recommendations (for example, "fill up now" or "wait")
|
||||
using an algorithm that analyses local price trends, historical patterns, and market
|
||||
signals. These recommendations are <strong>informational only</strong> and are produced
|
||||
automatically without human review. They do not have legal or similarly significant
|
||||
effects on you, and we do not use them to make decisions that affect your rights or
|
||||
interests in any material way.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">6. Who we share data with</h2>
|
||||
<p>We use the following processors to deliver the service. We do not sell your data to anyone.</p>
|
||||
<ul class="list-disc space-y-2 pl-6">
|
||||
<li>
|
||||
<strong>Stripe</strong> — payment processing. Card details, billing address,
|
||||
and subscription events flow to Stripe. See
|
||||
<a class="text-accent underline" href="https://stripe.com/privacy" target="_blank" rel="noopener">Stripe's privacy policy</a>.
|
||||
</li>
|
||||
<li><strong>Ionos</strong> — infrastructure where our application and database run, and the mail servers (SMTP) that send account, billing and alert emails on our behalf.</li>
|
||||
<li>
|
||||
<strong>Umami Analytics</strong> — we run our own self-hosted Umami instance to
|
||||
collect aggregated, cookieless usage metrics (pages viewed, referrer, country, device
|
||||
type). It does not store data that identifies you as an individual, and no analytics
|
||||
data is shared with third parties. We periodically review our analytics setup to
|
||||
confirm it remains cookieless; if this changes we will update our Cookie Policy and
|
||||
request consent before setting any non-essential cookies.
|
||||
</li>
|
||||
<li><strong>Vonage</strong> — delivers WhatsApp and SMS alerts if you opt in to those channels. Your phone number is shared only to send messages you have requested. See <a class="text-accent underline" href="https://www.vonage.co.uk/legal/privacy-policy/" target="_blank" rel="noopener">Vonage's privacy policy</a>.</li>
|
||||
<li><strong>OneSignal</strong> — delivers web push notifications if you opt in to push alerts. Push subscription data (endpoint, encryption keys, device type) is processed by OneSignal on our behalf. See <a class="text-accent underline" href="https://onesignal.com/privacy_policy" target="_blank" rel="noopener">OneSignal's privacy policy</a>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">7. International transfers</h2>
|
||||
<p>
|
||||
Some of our processors — including Stripe, Vonage and OneSignal — operate
|
||||
outside the UK and EEA, including in the United States. Where personal data is transferred
|
||||
internationally, we rely on appropriate safeguards under UK GDPR: the UK International Data
|
||||
Transfer Addendum to the EU Standard Contractual Clauses, the UK Extension to the EU-US
|
||||
Data Privacy Framework, or an equivalent mechanism, depending on the processor.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">8. How long we keep data</h2>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li><strong>Active account data:</strong> for as long as your account is open, plus 12 months after closure.</li>
|
||||
<li><strong>Saved location (registered users):</strong> while your account is active; deleted when you delete your account.</li>
|
||||
<li><strong>Alert and notification preferences:</strong> while your account is active; deleted when you close your account or remove the preference.</li>
|
||||
<li><strong>Push notification subscriptions:</strong> until you unsubscribe, revoke browser permission, or the subscription becomes stale.</li>
|
||||
<li><strong>Payment records:</strong> 6 years, to meet HMRC requirements for self-employed traders.</li>
|
||||
<li><strong>Marketing data:</strong> until you withdraw consent.</li>
|
||||
<li><strong>Security and fraud logs (including IP records):</strong> a maximum of 12 months.</li>
|
||||
<li><strong>Search and query logs:</strong> a maximum of 24 months.</li>
|
||||
<li><strong>Aggregated analytics:</strong> retained indefinitely in anonymised, non-identifiable form only.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">9. Your rights under UK GDPR</h2>
|
||||
<p>You have the following rights in relation to your personal data:</p>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li><strong>Right of access</strong> — ask for a copy of the data we hold about you.</li>
|
||||
<li><strong>Right to rectification</strong> — ask us to correct inaccurate data.</li>
|
||||
<li><strong>Right to erasure</strong> ("right to be forgotten") — ask us to delete your data.</li>
|
||||
<li><strong>Right to restrict processing</strong> — ask us to pause processing in certain circumstances.</li>
|
||||
<li><strong>Right to data portability</strong> — receive your data in a machine-readable format.</li>
|
||||
<li><strong>Right to object</strong> — object to processing based on legitimate interests.</li>
|
||||
<li><strong>Rights related to automated decision-making</strong> — our fill-up timing recommendations are generated algorithmically but are informational only and do not have legal or similarly significant effects on you.</li>
|
||||
<li><strong>Right to withdraw consent</strong> — where we rely on consent (for example, device location or marketing).</li>
|
||||
</ul>
|
||||
<p>
|
||||
To exercise any of these rights, email
|
||||
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
|
||||
We will respond within one month.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">10. Cookies</h2>
|
||||
<p>
|
||||
We use only a small number of essential cookies to operate the service, and self-hosted,
|
||||
cookieless analytics. Full details are in our
|
||||
<a class="text-accent underline" href="{{ route('legal.cookies') }}">Cookie Policy</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">11. Security</h2>
|
||||
<p>
|
||||
All traffic between your device and our service is encrypted with HTTPS. Passwords are
|
||||
stored as one-way hashes — we never see your plaintext password. Sensitive fields in
|
||||
our database are protected by access controls, and our infrastructure receives regular
|
||||
security updates. No system is ever 100% secure; if a breach occurs that affects you, we
|
||||
will notify you and the ICO as required by law.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">12. Children</h2>
|
||||
<p>
|
||||
FuelAlert is not directed at children. We do not knowingly collect data from anyone under
|
||||
16. If you believe a child has provided us with personal data, contact us and we will
|
||||
delete it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">13. Complaints</h2>
|
||||
<p>
|
||||
We hope you'll contact us first if you have a complaint, so we can try to put it right.
|
||||
You also have the right to lodge a complaint with the UK Information Commissioner's Office
|
||||
at any time.
|
||||
</p>
|
||||
<p>
|
||||
ICO website: <a class="text-accent underline" href="https://ico.org.uk" target="_blank" rel="noopener">ico.org.uk</a>
|
||||
· ICO helpline: 0303 123 1113.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">14. Changes to this policy</h2>
|
||||
<p>
|
||||
We may update this policy from time to time. If we make material changes we will notify
|
||||
registered users by email. Non-material changes will be shown by an updated "Last updated"
|
||||
date at the top of this page.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">15. Contact</h2>
|
||||
<p>
|
||||
For any privacy or data protection queries, email
|
||||
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
|
||||
</p>
|
||||
<p class="text-sm text-zinc-600">
|
||||
Data controller: Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
||||
ICO registration reference: 00014395133.
|
||||
</p>
|
||||
</section>
|
||||
</x-layouts.legal>
|
||||
110
resources/views/legal/refund.blade.php
Normal file
110
resources/views/legal/refund.blade.php
Normal file
@@ -0,0 +1,110 @@
|
||||
{{-- DRAFT: Generated {{ date('Y-m-d') }}. Review by UK-qualified solicitor recommended before launch. --}}
|
||||
<x-layouts.legal
|
||||
title="Refund & Cancellation Policy"
|
||||
heading="Refund & Cancellation Policy"
|
||||
lastUpdated="{{ now()->format('j F Y') }}"
|
||||
metaDescription="Your right to cancel a FuelAlert subscription, including the 14-day cooling-off period under UK law.">
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">1. Your 14-day right to cancel</h2>
|
||||
<p>
|
||||
Under the <strong>Consumer Contracts (Information, Cancellation and Additional Charges)
|
||||
Regulations 2013</strong>, you have <strong>14 days</strong> from the date you subscribe
|
||||
to a paid plan to cancel without giving a reason. This is sometimes called the
|
||||
"cooling-off period".
|
||||
</p>
|
||||
<p>
|
||||
This 14-day right applies to <strong>new subscribers only</strong>. It does not apply to
|
||||
subsequent automatic renewals of an existing subscription.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3 rounded-lg border-l-4 border-accent bg-white/70 p-6">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">2. Express consent to start the service immediately</h2>
|
||||
<p>
|
||||
When you subscribe, we ask you to choose whether the paid features should be available
|
||||
to you immediately. If you tick the consent box and start using paid features within the
|
||||
14-day window, you expressly acknowledge that:
|
||||
</p>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li>The service is being supplied to you straight away;</li>
|
||||
<li>
|
||||
<strong>You will lose your right to cancel under the Consumer Contracts Regulations
|
||||
2013 once the service has been fully supplied</strong> (i.e. once you have used the
|
||||
paid features during the cooling-off period).
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
If you do <strong>not</strong> tick the express-consent box, your subscription is still
|
||||
created but paid features remain inactive until the cooling-off period ends, or until
|
||||
you change your mind and confirm consent.
|
||||
</p>
|
||||
<p>
|
||||
If you cancel within the 14-day window <strong>before</strong> using any paid features,
|
||||
you receive a <strong>full refund</strong>. If you cancel within the window after using
|
||||
some paid features, we may reduce the refund proportionally to reflect usage, as
|
||||
permitted by the Regulations.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">3. How to cancel</h2>
|
||||
<p>You can cancel a subscription in either of these ways:</p>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li>From your account: <strong>Settings → Subscription → Cancel</strong>.</li>
|
||||
<li>By emailing <a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a> from the address on your account.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Unless you are exercising the 14-day right above, cancellation takes effect at the end
|
||||
of the current billing period. You keep access to paid features until that date.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">4. Refunds outside the 14-day period</h2>
|
||||
<p>
|
||||
Outside the 14-day cooling-off window, subscription fees are <strong>non-refundable</strong>
|
||||
for the remainder of the period you have paid for. You keep access to paid features
|
||||
until the end of that period; the subscription simply does not renew.
|
||||
</p>
|
||||
<p>
|
||||
We may issue discretionary refunds where there has been a service failure on our side or
|
||||
where required by law.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">5. Annual subscriptions</h2>
|
||||
<p>
|
||||
The 14-day cooling-off right applies to annual subscriptions in the same way as monthly
|
||||
subscriptions. After the 14 days, annual fees are non-refundable; we do not issue
|
||||
pro-rata refunds for unused months of an annual plan.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">6. Failed payments and involuntary cancellation</h2>
|
||||
<p>
|
||||
If a renewal payment fails, we and Stripe will retry the payment over a short period and
|
||||
email you. Paid features are suspended after the final unsuccessful retry. Your account
|
||||
itself is <strong>not deleted</strong>; you can resume by updating your payment method.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">7. How long refunds take</h2>
|
||||
<p>
|
||||
Approved refunds are issued to the original payment method via Stripe and typically
|
||||
arrive in your account within 5–10 business days, depending on your bank or card
|
||||
provider.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">8. Contact</h2>
|
||||
<p>
|
||||
For refund or cancellation queries, email
|
||||
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
|
||||
</p>
|
||||
</section>
|
||||
</x-layouts.legal>
|
||||
248
resources/views/legal/terms.blade.php
Normal file
248
resources/views/legal/terms.blade.php
Normal file
@@ -0,0 +1,248 @@
|
||||
{{-- DRAFT: Generated {{ date('Y-m-d') }}. Review by UK-qualified solicitor recommended before launch. --}}
|
||||
<x-layouts.legal
|
||||
title="Terms of Service"
|
||||
heading="Terms of Service"
|
||||
lastUpdated="{{ now()->format('j F Y') }}"
|
||||
metaDescription="The terms that govern your use of FuelAlert's subscription service.">
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">1. About these terms</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
By creating an account or using the service, you confirm that you have read, understood
|
||||
and accepted these terms. If you do not accept them, please do not use the service.
|
||||
</p>
|
||||
<p>These terms are governed by the laws of England and Wales.</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">2. The service</h2>
|
||||
<p>
|
||||
FuelAlert provides UK fuel price comparison and fill-up timing recommendations. We act
|
||||
as a downstream consumer of publicly available UK government fuel price data feeds
|
||||
(including the UK Fuel Finder / Pump Watch transparency scheme) and surface that data
|
||||
through a web app, alerts, and forecasts. We do not control the prices submitted by fuel
|
||||
retailers to those upstream schemes and are not responsible for errors or delays in
|
||||
that data.
|
||||
</p>
|
||||
<p>
|
||||
We offer a free tier and one or more paid subscription plans. The current list of plans
|
||||
and prices is available on our <a class="text-accent underline" href="/#pricing">pricing page</a>.
|
||||
</p>
|
||||
<p>
|
||||
We may add, remove, or change features over time. Where changes materially reduce the
|
||||
paid service, we will give you reasonable notice and, where appropriate, a way to cancel.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">3. Your account</h2>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li>You must be at least 18 years old and resident in the United Kingdom to create an account.</li>
|
||||
<li>The information you provide must be accurate and kept up to date.</li>
|
||||
<li>One account per person. You are responsible for keeping your login credentials secure.</li>
|
||||
<li>You are responsible for activity that takes place under your account.</li>
|
||||
<li>We may suspend or close accounts where these terms are seriously or repeatedly breached.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">4. Subscriptions, billing and payment</h2>
|
||||
<p>
|
||||
Paid plans are billed monthly in advance. The current price for each plan is shown on
|
||||
the <a class="text-accent underline" href="/#pricing">pricing page</a> at the time you subscribe.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Auto-renewal.</strong> Subscriptions renew automatically at the end of each
|
||||
billing period at the then-current price, unless you cancel before the renewal date. By
|
||||
subscribing you authorise FuelAlert — through our payment processor Stripe —
|
||||
to charge your nominated payment method at each renewal.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Failed payments.</strong> If a payment fails, we and Stripe will retry the
|
||||
payment over the following days. We will email you when this happens. Persistent failure
|
||||
will cause your paid features to be suspended; your account itself is not deleted.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Price changes.</strong> If we change the price of your plan, we will give you at
|
||||
least 30 days' notice by email before the new price takes effect on your next renewal.
|
||||
You may cancel before the change takes effect.
|
||||
</p>
|
||||
<p>
|
||||
<strong>VAT.</strong> FuelAlert is currently below the UK VAT registration threshold and is
|
||||
not VAT-registered, so no VAT is charged on your subscription. The price shown is the total
|
||||
amount you pay. If our VAT status changes, we will update these terms and notify you before
|
||||
any price change takes effect.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Downgrade on cancellation.</strong> When a paid subscription ends or is cancelled,
|
||||
your account reverts to the free tier. Paid alert channels (WhatsApp, SMS) are deactivated,
|
||||
but your alert settings are retained and will reactivate if you resubscribe.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">5. Cancellation and refunds</h2>
|
||||
<p>
|
||||
You can cancel your subscription at any time from your account settings. Cancellation
|
||||
stops the next renewal; you keep access to paid features until the end of the current
|
||||
billing period.
|
||||
</p>
|
||||
<p>
|
||||
New subscribers have a <strong>14-day right to cancel</strong> under the Consumer
|
||||
Contracts Regulations 2013. Important details — including the express-consent
|
||||
mechanism that affects this right — are set out in our
|
||||
<a class="text-accent underline" href="{{ route('legal.refund') }}">Refund & Cancellation Policy</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">6. Acceptable use</h2>
|
||||
<p>You agree not to:</p>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li>Scrape, reverse-engineer, or bulk-extract data from the service without our written permission.</li>
|
||||
<li>Mirror, republish, or systematically reproduce our compiled price data, station rankings, scoring outputs, or any other value-added data derived from the service.</li>
|
||||
<li>Use the service or its outputs for commercial exploitation, resale, or competitor monitoring without our written consent.</li>
|
||||
<li>Resell or redistribute fuel price data taken from FuelAlert.</li>
|
||||
<li>Use the service for any unlawful purpose.</li>
|
||||
<li>Attempt to circumvent or compromise our security measures.</li>
|
||||
<li>Use automated tools to make queries beyond what a single human user would reasonably make.</li>
|
||||
<li>Use the service while operating a motor vehicle. You must not interact with the service while a vehicle is in motion. Compliance with the Road Traffic Act 1988, the Highway Code, and all applicable road traffic laws is your sole responsibility.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">7. Accuracy of price data</h2>
|
||||
<p>
|
||||
Fuel prices shown on FuelAlert are sourced from official UK government data feeds
|
||||
(including the Pump Watch / Fuel Finder transparency schemes) and refreshed
|
||||
periodically. FuelAlert acts as a downstream consumer of those feeds and does not
|
||||
control the data submitted by fuel retailers to the central aggregator. Stations can
|
||||
change prices at any time, and there is usually a delay between a forecourt change
|
||||
and the feed update.
|
||||
</p>
|
||||
<p>
|
||||
We make reasonable efforts to display accurate prices but <strong>we cannot guarantee
|
||||
that the price shown will match the price at the pump</strong> when you arrive.
|
||||
<strong>Always confirm the price at the pump before fuelling.</strong>
|
||||
</p>
|
||||
<p>
|
||||
We are not liable for any loss arising from inaccurate, delayed, or missing price data,
|
||||
including the cost of a wasted journey or any difference between the price shown and the
|
||||
price charged.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">8. Forecasts and recommendations</h2>
|
||||
<p>
|
||||
FuelAlert may show forecasts and recommendations (e.g. "fill up now" or "wait"). These
|
||||
are generated algorithmically based on local price trends, historical patterns, and
|
||||
market signals. They are <strong>informational only</strong>, are not financial advice,
|
||||
and should not be relied upon as a guarantee of future prices. Past trends do not
|
||||
guarantee future prices. We do not warrant the accuracy of any forecast or recommendation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">9. Intellectual property</h2>
|
||||
<p>
|
||||
The FuelAlert name, logo, software, scoring algorithms, and original content are owned
|
||||
by Ovidiu Ungureanu. You receive a limited, non-exclusive, revocable licence to use the
|
||||
service for personal, non-commercial purposes.
|
||||
</p>
|
||||
<p>
|
||||
Underlying fuel price data is owned by the respective fuel retailers and published under
|
||||
UK government open data schemes. Postcode and geographic data is sourced from the ONS
|
||||
Postcode Directory, © Crown Copyright, used under the Open Government Licence v3.0.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">10. Third-party services</h2>
|
||||
<p>
|
||||
We use <strong>Stripe</strong> to process payments. Your use of Stripe is also subject
|
||||
to Stripe's own terms and privacy policy. We may use other third-party processors to
|
||||
run the service; these are named in our
|
||||
<a class="text-accent underline" href="{{ route('legal.privacy') }}">Privacy Policy</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">11. Limitation of liability</h2>
|
||||
<p>
|
||||
Nothing in these terms excludes or limits our liability for death or personal injury
|
||||
caused by our negligence, fraud or fraudulent misrepresentation, or any other liability
|
||||
that cannot be excluded under UK consumer law. Your statutory rights as a consumer are
|
||||
not affected.
|
||||
</p>
|
||||
<p>Subject to the paragraph above:</p>
|
||||
<ul class="list-disc space-y-1 pl-6">
|
||||
<li>We exclude liability for indirect, consequential, or business losses.</li>
|
||||
<li>
|
||||
For paying subscribers, our total liability to you in any 12-month period is capped
|
||||
at the total amount you paid in subscription fees during that period.
|
||||
</li>
|
||||
<li>
|
||||
We do not accept liability for issues caused by third-party services we rely on,
|
||||
including but not limited to outages or errors at our payment processor, hosting
|
||||
provider, or upstream data sources.
|
||||
</li>
|
||||
<li>
|
||||
We are not responsible for the accuracy, completeness, or timeliness of data
|
||||
submitted by fuel retailers to the UK Fuel Finder scheme or any other upstream
|
||||
source we consume as a downstream aggregator.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">12. Termination</h2>
|
||||
<p>
|
||||
You may stop using the service and close your account at any time. We may terminate or
|
||||
suspend access for serious breach of these terms, with reasonable notice where the
|
||||
breach is capable of being put right.
|
||||
</p>
|
||||
<p>
|
||||
Sections that by their nature should survive termination (including sections 7 to 11)
|
||||
will continue to apply after your account is closed.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">13. Changes to these terms</h2>
|
||||
<p>
|
||||
We may update these terms. Material changes will be notified to registered users by
|
||||
email at least 14 days before they take effect. Continued use of the service after the
|
||||
change date means you accept the new terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">14. Disputes</h2>
|
||||
<p>
|
||||
Please contact us first if you have a complaint — we will try to resolve it
|
||||
directly. These terms are governed by the laws of England and Wales, and the courts of
|
||||
England and Wales have non-exclusive jurisdiction over any dispute. If you live
|
||||
elsewhere in the United Kingdom, you keep the right to bring proceedings in the courts
|
||||
of your country of residence.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h2 class="font-display text-2xl font-bold text-zinc-900">15. Contact</h2>
|
||||
<p>
|
||||
For questions about these terms, email
|
||||
<a href="mailto:hello@fuel-alert.co.uk" class="text-accent underline">hello@fuel-alert.co.uk</a>.
|
||||
</p>
|
||||
<p class="text-sm text-zinc-600">
|
||||
Ovidiu Ungureanu trading as FuelAlert, Peterborough, United Kingdom.
|
||||
ICO registration reference: 00014395133.
|
||||
</p>
|
||||
</section>
|
||||
</x-layouts.legal>
|
||||
@@ -24,5 +24,13 @@ Route::middleware(['auth'])->prefix('billing')->name('billing.')->group(function
|
||||
Route::get('/cancel', [BillingController::class, 'cancel'])->name('cancel');
|
||||
});
|
||||
|
||||
// Server-rendered legal pages — must be registered before the SPA catch-all
|
||||
Route::prefix('legal')->name('legal.')->group(function () {
|
||||
Route::view('/privacy', 'legal.privacy')->name('privacy');
|
||||
Route::view('/terms', 'legal.terms')->name('terms');
|
||||
Route::view('/refund', 'legal.refund')->name('refund');
|
||||
Route::view('/cookies', 'legal.cookies')->name('cookies');
|
||||
});
|
||||
|
||||
// SPA catch-all — must be last
|
||||
Route::get('/{any?}', fn () => view('app'))->where('any', '.*')->name('home');
|
||||
|
||||
68
tests/Feature/LegalPagesTest.php
Normal file
68
tests/Feature/LegalPagesTest.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
it('serves the privacy policy with required content', function (): void {
|
||||
$response = $this->get('/legal/privacy');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSeeText('Privacy Policy');
|
||||
$response->assertSeeText('data controller');
|
||||
$response->assertSeeText('UK GDPR');
|
||||
$response->assertSeeText('Last updated:');
|
||||
});
|
||||
|
||||
it('serves the terms of service with required content', function (): void {
|
||||
$response = $this->get('/legal/terms');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSeeText('Terms of Service');
|
||||
$response->assertSeeText('subscription');
|
||||
$response->assertSeeText('England and Wales');
|
||||
});
|
||||
|
||||
it('serves the refund policy with cooling-off content', function (): void {
|
||||
$response = $this->get('/legal/refund');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSeeText('Refund');
|
||||
$response->assertSeeText('14-day');
|
||||
$response->assertSeeText('Consumer Contracts');
|
||||
});
|
||||
|
||||
it('serves the cookie policy with essential-cookie content', function (): void {
|
||||
$response = $this->get('/legal/cookies');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSeeText('Cookie Policy');
|
||||
$response->assertSeeText('essential');
|
||||
});
|
||||
|
||||
it('does not render the SPA mount point on legal pages', function (string $path): void {
|
||||
$response = $this->get($path);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertDontSee('<div id="app"></div>', false);
|
||||
})->with([
|
||||
'/legal/privacy',
|
||||
'/legal/terms',
|
||||
'/legal/refund',
|
||||
'/legal/cookies',
|
||||
]);
|
||||
|
||||
it('cross-links between legal pages', function (): void {
|
||||
$this->get('/legal/privacy')->assertSee(route('legal.cookies'), false);
|
||||
$this->get('/legal/terms')->assertSee(route('legal.refund'), false);
|
||||
});
|
||||
|
||||
it('is launch-ready: no placeholders and the correct contact domain', function (string $path): void {
|
||||
$response = $this->get($path);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertDontSee('[PLACEHOLDER', false);
|
||||
$response->assertDontSee('hello@fuelalert.co.uk', false);
|
||||
$response->assertSee('hello@fuel-alert.co.uk', false);
|
||||
})->with([
|
||||
'/legal/privacy',
|
||||
'/legal/terms',
|
||||
'/legal/refund',
|
||||
'/legal/cookies',
|
||||
]);
|
||||
@@ -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',
|
||||
|
||||
@@ -53,7 +53,7 @@ it('canSendNow returns false when tier does not allow the channel', function ():
|
||||
});
|
||||
|
||||
it('canSendNow returns false when daily limit is reached', function (): void {
|
||||
$plan = Plan::where('name', 'plus')->first(); // sms_daily_limit = 1
|
||||
$plan = Plan::where('name', 'plus')->first(); // sms_daily_limit = 3
|
||||
$user = User::factory()->create();
|
||||
|
||||
UserNotificationPreference::factory()->create([
|
||||
@@ -70,7 +70,7 @@ it('canSendNow returns false when daily limit is reached', function (): void {
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
expect($plan->sms_daily_limit)->toBe(1);
|
||||
expect($plan->sms_daily_limit)->toBe(3);
|
||||
|
||||
$sentCount = NotificationLog::where('user_id', $user->id)
|
||||
->where('channel', 'sms')
|
||||
|
||||
@@ -119,3 +119,57 @@ it('captures response_body when an HTTP RequestException is thrown', function ()
|
||||
|
||||
expect(ApiLog::first()->response_body)->toBe('upstream details');
|
||||
});
|
||||
|
||||
it('captures Anthropic usage tokens from a successful response', function (): void {
|
||||
Http::fake(['https://api.anthropic.com/v1/messages' => Http::response([
|
||||
'content' => [],
|
||||
'usage' => [
|
||||
'input_tokens' => 1234,
|
||||
'output_tokens' => 56,
|
||||
'cache_creation_input_tokens' => 8000,
|
||||
'cache_read_input_tokens' => 12000,
|
||||
],
|
||||
])]);
|
||||
|
||||
$this->apiLogger->send('anthropic', 'POST', 'https://api.anthropic.com/v1/messages',
|
||||
fn () => Http::post('https://api.anthropic.com/v1/messages'));
|
||||
|
||||
$log = ApiLog::first();
|
||||
expect($log->input_tokens)->toBe(1234)
|
||||
->and($log->output_tokens)->toBe(56)
|
||||
->and($log->cache_write_tokens)->toBe(8000)
|
||||
->and($log->cache_read_tokens)->toBe(12000);
|
||||
});
|
||||
|
||||
it('captures rate-limit headers from any provider response', function (): void {
|
||||
Http::fake(['https://api.anthropic.com/v1/messages' => Http::response(
|
||||
['content' => [], 'usage' => ['input_tokens' => 100, 'output_tokens' => 10]],
|
||||
200,
|
||||
[
|
||||
'anthropic-ratelimit-input-tokens-remaining' => '38000',
|
||||
'anthropic-ratelimit-input-tokens-reset' => '2026-05-14T12:41:00Z',
|
||||
],
|
||||
)]);
|
||||
|
||||
$this->apiLogger->send('anthropic', 'POST', 'https://api.anthropic.com/v1/messages',
|
||||
fn () => Http::post('https://api.anthropic.com/v1/messages'));
|
||||
|
||||
$log = ApiLog::first();
|
||||
expect($log->ratelimit_remaining)->toBe(38000)
|
||||
->and($log->ratelimit_reset_at?->toIso8601String())->toBe('2026-05-14T12:41:00+00:00');
|
||||
});
|
||||
|
||||
it('leaves token columns null for services without usage data', function (): void {
|
||||
Http::fake(['https://example.com/x' => Http::response(['ok' => true])]);
|
||||
|
||||
$this->apiLogger->send('test_service', 'GET', 'https://example.com/x',
|
||||
fn () => Http::get('https://example.com/x'));
|
||||
|
||||
$log = ApiLog::first();
|
||||
expect($log->input_tokens)->toBeNull()
|
||||
->and($log->output_tokens)->toBeNull()
|
||||
->and($log->cache_read_tokens)->toBeNull()
|
||||
->and($log->cache_write_tokens)->toBeNull()
|
||||
->and($log->ratelimit_remaining)->toBeNull()
|
||||
->and($log->ratelimit_reset_at)->toBeNull();
|
||||
});
|
||||
|
||||
@@ -18,32 +18,63 @@ beforeEach(function (): void {
|
||||
Config::set('services.anthropic.api_key', 'test-key');
|
||||
});
|
||||
|
||||
function fakeAnthropicWithOverlay(string $direction, int $confidence, array $events, bool $major = false): void
|
||||
/**
|
||||
* Anthropic-shaped Phase 1 assistant turn that includes a real
|
||||
* web_search_tool_result block (the source of truth for harvested
|
||||
* citations).
|
||||
*
|
||||
* @param array<int, array{url: string, title: string}> $results
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function fakeSearchResultsTurn(array $results): array
|
||||
{
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push([
|
||||
'stop_reason' => 'end_turn',
|
||||
'content' => [['type' => 'text', 'text' => 'Search summary.']],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => [
|
||||
'direction' => $direction,
|
||||
'confidence' => $confidence,
|
||||
'reasoning_short' => 'Test reasoning.',
|
||||
'events_cited' => $events,
|
||||
'agrees_with_ridge' => true,
|
||||
'major_impact_event' => $major,
|
||||
],
|
||||
]],
|
||||
]),
|
||||
// URL HEAD verification probes — accept everything by default
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
$content = [['type' => 'text', 'text' => 'Searching...']];
|
||||
foreach ($results as $idx => $r) {
|
||||
$content[] = [
|
||||
'type' => 'server_tool_use',
|
||||
'id' => 'srvtoolu_'.$idx,
|
||||
'name' => 'web_search',
|
||||
'input' => ['query' => 'oil news'],
|
||||
];
|
||||
$content[] = [
|
||||
'type' => 'web_search_tool_result',
|
||||
'tool_use_id' => 'srvtoolu_'.$idx,
|
||||
'content' => [[
|
||||
'type' => 'web_search_result',
|
||||
'url' => $r['url'],
|
||||
'title' => $r['title'],
|
||||
'encrypted_content' => str_repeat('LONG_PAGE_TEXT_', 200),
|
||||
'page_age' => '1 day ago',
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
return ['stop_reason' => 'end_turn', 'content' => $content];
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $events */
|
||||
function fakeSubmitTurn(string $direction, int $confidence, array $events, bool $major = false): array
|
||||
{
|
||||
$input = [
|
||||
'direction' => $direction,
|
||||
'confidence' => $confidence,
|
||||
'reasoning_short' => 'Test reasoning.',
|
||||
'agrees_with_ridge' => true,
|
||||
'major_impact_event' => $major,
|
||||
];
|
||||
if ($events !== []) {
|
||||
$input['events_cited'] = $events;
|
||||
}
|
||||
|
||||
return [
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_submit',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => $input,
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
it('skips when ANTHROPIC_API_KEY is not set', function (): void {
|
||||
@@ -54,8 +85,13 @@ it('skips when ANTHROPIC_API_KEY is not set', function (): void {
|
||||
expect($service->run())->toBeNull();
|
||||
});
|
||||
|
||||
it('rejects the overlay when no events are cited', function (): void {
|
||||
fakeAnthropicWithOverlay('rising', 60, []);
|
||||
it('rejects only when neither web search nor model cited anything', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'no results']]])
|
||||
->push(fakeSubmitTurn('rising', 60, [])),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
@@ -66,30 +102,13 @@ it('rejects the overlay when no events are cited', function (): void {
|
||||
it('verifies a URL via GET fallback when HEAD returns 405', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push([
|
||||
'stop_reason' => 'end_turn',
|
||||
'content' => [['type' => 'text', 'text' => 'ok']],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => [
|
||||
'direction' => 'rising',
|
||||
'confidence' => 60,
|
||||
'reasoning_short' => 'Hostile-to-HEAD source.',
|
||||
'events_cited' => [
|
||||
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/x', 'impact' => 'rising'],
|
||||
],
|
||||
'agrees_with_ridge' => true,
|
||||
'major_impact_event' => false,
|
||||
],
|
||||
]],
|
||||
]),
|
||||
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
|
||||
->push(fakeSubmitTurn('rising', 60, [
|
||||
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/x', 'impact' => 'rising'],
|
||||
])),
|
||||
'reuters.com/*' => Http::sequence()
|
||||
->push('', 405) // HEAD → 405 Method Not Allowed
|
||||
->push('partial-body', 200), // GET fallback succeeds
|
||||
->push('', 405)
|
||||
->push('partial-body', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
@@ -99,65 +118,13 @@ it('verifies a URL via GET fallback when HEAD returns 405', function (): void {
|
||||
->and($row->events_json)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('rejects the overlay when both HEAD and GET fail', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push([
|
||||
'stop_reason' => 'end_turn',
|
||||
'content' => [['type' => 'text', 'text' => 'ok']],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => [
|
||||
'direction' => 'rising',
|
||||
'confidence' => 60,
|
||||
'reasoning_short' => 'Truly dead URL.',
|
||||
'events_cited' => [
|
||||
['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'],
|
||||
],
|
||||
'agrees_with_ridge' => true,
|
||||
'major_impact_event' => false,
|
||||
],
|
||||
]],
|
||||
]),
|
||||
'example.com/*' => Http::sequence()
|
||||
->push('', 404) // HEAD → 404
|
||||
->push('', 404), // GET → still 404
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
expect($service->run())->toBeNull()
|
||||
->and(LlmOverlay::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('rejects the overlay when every cited URL is unreachable', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push([
|
||||
'stop_reason' => 'end_turn',
|
||||
'content' => [['type' => 'text', 'text' => 'ok']],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => [
|
||||
'direction' => 'rising',
|
||||
'confidence' => 60,
|
||||
'reasoning_short' => 'Test.',
|
||||
'events_cited' => [
|
||||
['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'],
|
||||
],
|
||||
'agrees_with_ridge' => true,
|
||||
'major_impact_event' => false,
|
||||
],
|
||||
]],
|
||||
]),
|
||||
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
|
||||
->push(fakeSubmitTurn('rising', 60, [
|
||||
['headline' => 'X', 'source' => 'Reuters', 'url' => 'https://example.com/dead', 'impact' => 'rising'],
|
||||
])),
|
||||
'example.com/*' => Http::response('', 404),
|
||||
]);
|
||||
|
||||
@@ -168,14 +135,14 @@ it('rejects the overlay when every cited URL is unreachable', function (): void
|
||||
});
|
||||
|
||||
it('persists an overlay row with verified citations and capped confidence', function (): void {
|
||||
fakeAnthropicWithOverlay(
|
||||
direction: 'rising',
|
||||
confidence: 95, // above cap → expect capped to 75
|
||||
events: [
|
||||
['headline' => 'OPEC cuts output', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
|
||||
],
|
||||
major: true,
|
||||
);
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
|
||||
->push(fakeSubmitTurn('rising', 95, [
|
||||
['headline' => 'OPEC cuts output', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
|
||||
], major: true)),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
@@ -183,51 +150,20 @@ it('persists an overlay row with verified citations and capped confidence', func
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->direction)->toBe('rising')
|
||||
->and($row->confidence)->toBe(75) // capped
|
||||
->and($row->confidence)->toBe(75)
|
||||
->and($row->major_impact_event)->toBeTrue()
|
||||
->and($row->search_used)->toBeTrue()
|
||||
->and($row->events_json)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('retries the submit when the model omits events_cited', function (): void {
|
||||
it('harvests citations from web_search_tool_result when the model omits events_cited', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push([
|
||||
'stop_reason' => 'end_turn',
|
||||
'content' => [['type' => 'text', 'text' => 'Search done.']],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_first',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => [
|
||||
'direction' => 'rising',
|
||||
'confidence' => 70,
|
||||
'reasoning_short' => 'Forgot citations.',
|
||||
// events_cited omitted entirely — the bug we are guarding against
|
||||
],
|
||||
]],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_retry',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => [
|
||||
'direction' => 'rising',
|
||||
'confidence' => 70,
|
||||
'reasoning_short' => 'With citations now.',
|
||||
'events_cited' => [
|
||||
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
|
||||
],
|
||||
'agrees_with_ridge' => true,
|
||||
'major_impact_event' => false,
|
||||
],
|
||||
]],
|
||||
]),
|
||||
->push(fakeSearchResultsTurn([
|
||||
['url' => 'https://reuters.com/opec', 'title' => 'OPEC cuts output'],
|
||||
['url' => 'https://bloomberg.com/iran', 'title' => 'Iran tensions'],
|
||||
]))
|
||||
->push(fakeSubmitTurn('rising', 70, [])),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
@@ -236,42 +172,79 @@ it('retries the submit when the model omits events_cited', function (): void {
|
||||
$row = $service->run();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->events_json)->toHaveCount(1)
|
||||
->and(LlmOverlay::query()->count())->toBe(1);
|
||||
->and($row->events_json)->toHaveCount(2)
|
||||
->and(collect($row->events_json)->pluck('url')->all())
|
||||
->toEqualCanonicalizing(['https://reuters.com/opec', 'https://bloomberg.com/iran'])
|
||||
->and(collect($row->events_json)->pluck('impact')->unique()->all())
|
||||
->toBe(['neutral']);
|
||||
});
|
||||
|
||||
it('rejects when the retry also omits events_cited', function (): void {
|
||||
it('merges model events_cited with harvested URLs deduped by URL', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push([
|
||||
'stop_reason' => 'end_turn',
|
||||
'content' => [['type' => 'text', 'text' => 'Search done.']],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_first',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'No cites.'],
|
||||
]],
|
||||
])
|
||||
->push([
|
||||
'stop_reason' => 'tool_use',
|
||||
'content' => [[
|
||||
'type' => 'tool_use',
|
||||
'id' => 'toolu_retry',
|
||||
'name' => 'submit_overlay',
|
||||
'input' => ['direction' => 'rising', 'confidence' => 70, 'reasoning_short' => 'Still none.'],
|
||||
]],
|
||||
]),
|
||||
->push(fakeSearchResultsTurn([
|
||||
['url' => 'https://reuters.com/opec', 'title' => 'OPEC cuts output'],
|
||||
['url' => 'https://bloomberg.com/iran', 'title' => 'Iran tensions'],
|
||||
]))
|
||||
->push(fakeSubmitTurn('rising', 70, [
|
||||
['headline' => 'OPEC slashes output', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
|
||||
['headline' => 'Refinery fire', 'source' => 'CNBC', 'url' => 'https://cnbc.com/refinery', 'impact' => 'rising'],
|
||||
])),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
expect($service->run())->toBeNull()
|
||||
->and(LlmOverlay::query()->count())->toBe(0);
|
||||
$row = $service->run();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and(collect($row->events_json)->pluck('url')->all())
|
||||
->toEqualCanonicalizing([
|
||||
'https://reuters.com/opec',
|
||||
'https://bloomberg.com/iran',
|
||||
'https://cnbc.com/refinery',
|
||||
]);
|
||||
|
||||
$opec = collect($row->events_json)->firstWhere('url', 'https://reuters.com/opec');
|
||||
expect($opec['impact'])->toBe('rising')
|
||||
->and($opec['headline'])->toBe('OPEC slashes output');
|
||||
|
||||
$bloomberg = collect($row->events_json)->firstWhere('url', 'https://bloomberg.com/iran');
|
||||
expect($bloomberg['impact'])->toBe('neutral');
|
||||
});
|
||||
|
||||
it('does not resend Phase 1 web_search_tool_result blocks on the submit call', function (): void {
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push(fakeSearchResultsTurn([
|
||||
['url' => 'https://reuters.com/opec', 'title' => 'OPEC cuts output'],
|
||||
]))
|
||||
->push(fakeSubmitTurn('rising', 70, [
|
||||
['headline' => 'OPEC', 'source' => 'Reuters', 'url' => 'https://reuters.com/opec', 'impact' => 'rising'],
|
||||
])),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
$service->run();
|
||||
|
||||
$anthropicRequests = collect(Http::recorded())
|
||||
->filter(fn (array $pair): bool => str_contains($pair[0]->url(), 'api.anthropic.com'))
|
||||
->values();
|
||||
|
||||
expect($anthropicRequests)->toHaveCount(2);
|
||||
|
||||
$submitBody = $anthropicRequests[1][0]->data();
|
||||
$messagesJson = json_encode($submitBody['messages'], JSON_UNESCAPED_SLASHES);
|
||||
|
||||
expect($submitBody['messages'])->toHaveCount(1)
|
||||
->and($submitBody['messages'][0]['role'])->toBe('user');
|
||||
|
||||
expect($messagesJson)->not->toContain('web_search_tool_result')
|
||||
->and($messagesJson)->not->toContain('LONG_PAGE_TEXT_')
|
||||
->and($messagesJson)->not->toContain('server_tool_use')
|
||||
->and($messagesJson)->toContain('https://reuters.com/opec');
|
||||
});
|
||||
|
||||
it('honors the 4-hour cooldown for event-driven calls', function (): void {
|
||||
@@ -291,14 +264,19 @@ it('honors the 4-hour cooldown for event-driven calls', function (): void {
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
fakeAnthropicWithOverlay('falling', 40, [
|
||||
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'],
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
|
||||
->push(fakeSubmitTurn('falling', 40, [
|
||||
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'],
|
||||
])),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
expect($service->run(eventDriven: true))->toBeNull() // <4h since prior
|
||||
->and(LlmOverlay::query()->count())->toBe(1); // no new row inserted
|
||||
expect($service->run(eventDriven: true))->toBeNull()
|
||||
->and(LlmOverlay::query()->count())->toBe(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -320,8 +298,13 @@ it('always runs (ignores cooldown) when not event-driven', function (): void {
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
fakeAnthropicWithOverlay('falling', 40, [
|
||||
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'],
|
||||
Http::fake([
|
||||
'*api.anthropic.com/*' => Http::sequence()
|
||||
->push(['stop_reason' => 'end_turn', 'content' => [['type' => 'text', 'text' => 'ok']]])
|
||||
->push(fakeSubmitTurn('falling', 40, [
|
||||
['headline' => 'A', 'source' => 'X', 'url' => 'https://reuters.com/a', 'impact' => 'falling'],
|
||||
])),
|
||||
'*' => Http::response('', 200),
|
||||
]);
|
||||
|
||||
$service = new LlmOverlayService(new ApiLogger, app(WeeklyForecastService::class));
|
||||
|
||||
Reference in New Issue
Block a user