14 Commits

Author SHA1 Message Date
Ovidiu U
5ca7232029 Replace Laravel favicon with beer mug icon and regenerate touch/favicon assets 2026-06-11 14:05:51 +01:00
Ovidiu U
da0db012a0 Drop duplicate 'logout' route name so route:cache works
The SPA's GET /logout and Fortify's POST /logout both carried the name
'logout', which is fine at runtime (distinct verbs, same URL) but breaks
route:cache (names must be unique for serialization). The Blade auth forms
target route('logout') = Fortify's POST route, so the custom GET route — used
by the SPA via a literal /logout URL — no longer needs the name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 13:34:32 +01:00
Ovidiu U
5a6967dc01 Guard HandleStripeWebhook plan-cache bust against non-taggable cache stores
bustPlanCache() called Cache::tags() unconditionally, which throws on the
`database`/`file` cache drivers. Mirror the Cache::supportsTags() idiom used in
Plan.php so Stripe webhooks work regardless of the configured cache store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:56:48 +01:00
Ovidiu U
257c09d178 VPS deploy instructions 2026-06-11 10:10:48 +01:00
Ovidiu U
f14006dc28 Cleanup 2026-06-11 09:41:03 +01:00
Ovidiu U
8fe3461adf Update legal pages with ICO registration, query logging, push notifications, and automated recommendations disclosure
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Add ICO registration reference (00014395133) to privacy policy, terms of service, and refund policy
- Document search/query logging (hashed IP, location, fuel type, result count) with 24-month retention under legitimate interests
- Add push notification data section (OneSignal endpoints, encryption keys, preferences) to privacy policy
- Add new section on automated recommendations explaining fill-up timing algorithm is informational only without legal effects
- Clarify IP address collection context: security/abuse/fraud only, not individual profiling
- Update retention periods: security logs 12 months, query logs 24 months, push subscriptions until unsubscribe
- Expand data processor descriptions (OneSignal push data, Umami cookieless commitment)
- Add commercial use restrictions to terms: no scraping, mirroring, or republishing compiled data/rankings without permission
- Clarify downstream data aggregator role and upstream data feed limitations
- Add prohibition on using service while operating motor vehicle
- Remove annual billing references (monthly only)
- Add VAT status notice and downgrade-to-free-tier behavior on cancellation
- Add data controller contact details to privacy policy footer
2026-06-10 13:15:43 +01:00
Ovidiu U
ad2230728c Disable Daily and Smart pricing CTAs until features ship
Replace the basic/plus checkout links on /pricing with disabled
"Coming soon" buttons, gated by a COMING_SOON list so each tier's CTA
goes live again by removing it from that list. Free is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:15:16 +01:00
Ovidiu U
5bd909d227 Show site footer on dashboard pages and trim footer links
Render the shared SiteFooter in DashboardLayout so it appears on every
dashboard page (overview, saved stations, preferences, settings) from a
single source. Content area grows to pin the footer to the bottom on
short pages.

Trim the footer Product column to real anchors and drop the dead-link
Resources column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:40:51 +01:00
Ovidiu U
a841a7c900 Add dedicated /pricing page and lock launch tiers to Free/Daily/Smart
Consolidate pricing onto a single source. Pro is deferred from launch
(left dormant: no Stripe price, no card), so the offered set is 3 tiers.

- Extract the pricing grid and footer into shared components
  (PricingGrid.vue, landing/SiteFooter.vue); add a /pricing route
  rendering Pricing.vue; remove the pricing section from Home
- Repoint every upgrade link to the /pricing route (LandingNav and
  SiteFooter via RouterLink, UpsellBanner CTA) — no more #pricing anchors
- Bump Smart (plus) SMS daily limit 1 -> 3 (PlanSeeder + PlanFactory),
  update PlanFeaturesTest assertion
- Rewrite /pricing card bullets to match real entitlements (drop
  unbuilt promises: multi-location tracking, 14-day trend, supermarket anchor)
- Fix stale "1/day" SMS references in notifications.md, tiers.md, docs/tiers.md
- Delete unused resources/views/components/pricing-card.blade.php

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 12:22:19 +01:00
Ovidiu U
adf0b93a45 Coarsen GPS coordinates to 3 dp before API/URL and deduplicate runSearch triggers
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- HeroSearch: round lat/lng to 3 decimal places (~111m precision) before emit to prevent exact location leakage in shareable URLs and server logs
- Home: move duplicate-search guard into runSearch (single choke point) instead of watcher, eliminating race between route.query sync and direct onSearch call
- Add inline documentation referencing .claude/rules/frontend.md privacy guidance
2026-06-10 11:08:55 +01:00
Ovidiu U
1ae2f1396d Update legal pages with contact details, location handling, and alert channels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Expand privacy policy with detailed location-use explanations: search-only, shareable links, and saved location
2026-06-10 10:55:54 +01:00
Ovidiu U
df4ebdb7c6 Remove Livewire docs, add Vue SPA frontend docs, document log-masking runbook
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Delete `.claude/rules/livewire.md` (Livewire is vestigial – only starter-kit auth screens remain)
- Add `.claude/rules/frontend.md` documenting Vue 3 SPA stack, router, composables, search-URL mirroring, and Leaflet integration
- Add `docs/ops/nginx-log-masking.md` runbook for redacting lat/lng from Nginx access/error logs and PHP-FPM on IONOS VPS
- Update `CLAUDE.md` and `architecture.md` to reflect Vue SPA as primary frontend, REST API as backend contract, and Livewire's reduced role
- Note stale service names in legacy domain docs (`scoring.md`, `prediction.md`, `notifications.md`) now refactored into `Services/Forecasting/`, `Services/StationSearch/`, and `PlanFeatures`
2026-06-10 10:55:34 +01:00
Ovidiu U
7a8bd5c86a Remove placeholders and finalize contact details for launch
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Replace all `[PLACEHOLDER]` entries across legal pages with actual values
- Update privacy policy with ICO registration status and service provider details (Ionos, Vonage, OneSignal)
- Finalize contact email as `hello@fuel-alert.co.uk` throughout legal pages and footer
- Update legal layout to use full landing nav with auth/guest actions
- Add Umami analytics script to main app layout
- Remove "Fleet" nav link from landing navigation
- Add feature test to enforce no placeholders and correct contact domain remain in legal pages
2026-06-10 10:07:02 +01:00
Ovidiu U
ecd45588e9 Add legal policy pages and shared layout component
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Add Cookie Policy view documenting essential cookies (session, CSRF, remember_me, fa_location) and cookieless Umami analytics
- Add Privacy Policy view covering UK GDPR compliance, data categories, lawful bases, processors, retention, and user rights
- Add Refund & Cancellation Policy view explaining 14-day cooling-off period under Consumer Contracts Regulations 2013 and express-consent flow
- Add Terms of Service view defining account rules, subscription billing, and governing law
- Create shared legal layout component with FuelAlert header, footer with cross-links, and consistent typography
- Add feature tests covering all four legal pages and their cross-links
- All policies include placeholders for ICO registration number, email, and hosting/email providers pending production config
2026-05-14 17:43:53 +01:00
83 changed files with 2858 additions and 3276 deletions

View File

@@ -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
View 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"
---

View File

@@ -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"
---

View File

@@ -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

View File

@@ -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.

View File

@@ -1 +0,0 @@
../../.agents/skills/antfu

View File

@@ -1 +0,0 @@
../../.agents/skills/nuxt

View File

@@ -1 +0,0 @@
../../.agents/skills/pinia

View File

@@ -1 +0,0 @@
../../.agents/skills/pnpm

View File

@@ -1 +0,0 @@
../../.agents/skills/slidev

View File

@@ -1 +0,0 @@
../../.agents/skills/tsdown

View File

@@ -1 +0,0 @@
../../.agents/skills/turborepo

View File

@@ -1 +0,0 @@
../../.agents/skills/unocss

View File

@@ -1 +0,0 @@
../../.agents/skills/vite

View File

@@ -1 +0,0 @@
../../.agents/skills/vitepress

View File

@@ -1 +0,0 @@
../../.agents/skills/vitest

View File

@@ -1 +0,0 @@
../../.agents/skills/vue

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-router-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-testing-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vueuse-functions

View File

@@ -1 +0,0 @@
../../.agents/skills/web-design-guidelines

View File

@@ -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/*

View File

@@ -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

View File

@@ -1 +0,0 @@
../../.agents/skills/antfu

View File

@@ -1 +0,0 @@
../../.agents/skills/nuxt

View File

@@ -1 +0,0 @@
../../.agents/skills/pinia

View File

@@ -1 +0,0 @@
../../.agents/skills/pnpm

View File

@@ -1 +0,0 @@
../../.agents/skills/slidev

View File

@@ -1 +0,0 @@
../../.agents/skills/tsdown

View File

@@ -1 +0,0 @@
../../.agents/skills/turborepo

View File

@@ -1 +0,0 @@
../../.agents/skills/unocss

View File

@@ -1 +0,0 @@
../../.agents/skills/vite

View File

@@ -1 +0,0 @@
../../.agents/skills/vitepress

View File

@@ -1 +0,0 @@
../../.agents/skills/vitest

View File

@@ -1 +0,0 @@
../../.agents/skills/vue

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-router-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vue-testing-best-practices

View File

@@ -1 +0,0 @@
../../.agents/skills/vueuse-functions

View File

@@ -1 +0,0 @@
../../.agents/skills/web-design-guidelines

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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">&ldquo;{{ trim($message) }}&rdquo;</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

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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

View File

@@ -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}");
}
}

View File

@@ -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,

View File

@@ -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,

415
docs/ops/deployment.md Normal file
View 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.

View 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).

View 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 411 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 510 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

View File

@@ -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.

860
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 823 B

View File

@@ -1,3 +1,3 @@
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="166" height="166" viewBox="0 0 512 512">
<path fill="#bb5b3e" d="M32 64C32 28.7 60.7 0 96 0L256 0c35.3 0 64 28.7 64 64l0 192 8 0c48.6 0 88 39.4 88 88l0 32c0 13.3 10.7 24 24 24s24-10.7 24-24l0-154c-27.6-7.1-48-32.2-48-62l0-59.5-25.8-28.3c-8.9-9.8-8.2-25 1.6-33.9s25-8.2 33.9 1.6l71.7 78.8c9.4 10.3 14.6 23.7 14.6 37.7L512 376c0 39.8-32.2 72-72 72s-72-32.2-72-72l0-32c0-22.1-17.9-40-40-40l-8 0 0 161.4c9.3 3.3 16 12.2 16 22.6 0 13.3-10.7 24-24 24L40 512c-13.3 0-24-10.7-24-24 0-10.5 6.7-19.3 16-22.6L32 64zM96 80l0 96c0 8.8 7.2 16 16 16l128 0c8.8 0 16-7.2 16-16l0-96c0-8.8-7.2-16-16-16L112 64c-8.8 0-16 7.2-16 16z"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 672 B

View 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 &amp; WhatsApp</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Price-drop &amp; 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 &amp; 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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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">

View 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 &amp; 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 &amp; database right, Royal Mail data © Royal Mail copyright &amp; database right, and National Statistics data © Crown copyright &amp; database right.</p>
</div>
</footer>
</template>
<script setup>
import { RouterLink } from 'vue-router'
</script>

View File

@@ -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',

View File

@@ -225,88 +225,6 @@
</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">
<div class="max-w-7xl mx-auto">
@@ -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. 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>© 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 &amp; database right, Royal Mail data © Royal Mail copyright &amp; database right, and National Statistics data © Crown copyright &amp; 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>

View 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>

View File

@@ -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()

View File

@@ -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())

View 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 &amp; Cancellation</a>
<a class="hover:text-accent" href="{{ route('legal.cookies') }}">Cookie Policy</a>
</nav>
<p class="mt-6 text-xs text-zinc-500">
&copy; {{ date('Y') }} FuelAlert. A trading name of Ovidiu Ungureanu, sole trader, Peterborough, United Kingdom.
</p>
</div>
</footer>
</body>
</html>

View File

@@ -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>

View 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 &mdash; 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> &mdash; 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" &mdash; 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 &middot; 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>

View 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&nbsp;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> &mdash; contract (Art. 6(1)(b)).</li>
<li><strong>Sending the alerts you configure, including by email, WhatsApp, SMS or push</strong> &mdash; contract (Art. 6(1)(b)).</li>
<li><strong>Finding stations near you on request (device location)</strong> &mdash; 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> &mdash; contract (Art. 6(1)(b)).</li>
<li><strong>Payment processing</strong> &mdash; contract (Art. 6(1)(b)).</li>
<li><strong>Security, abuse prevention, and fraud detection (including IP address logging)</strong> &mdash; legitimate interests (Art. 6(1)(f)).</li>
<li><strong>Search and query logging for aggregate statistics and troubleshooting</strong> &mdash; legitimate interests (Art. 6(1)(f)).</li>
<li><strong>Aggregated, non-identifying analytics and product improvement</strong> &mdash; legitimate interests (Art. 6(1)(f)).</li>
<li><strong>Marketing emails</strong> &mdash; 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> &mdash; 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> &mdash; 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> &mdash; 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> &mdash; 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> &mdash; 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 &mdash; including Stripe, Vonage and OneSignal &mdash; 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> &mdash; ask for a copy of the data we hold about you.</li>
<li><strong>Right to rectification</strong> &mdash; ask us to correct inaccurate data.</li>
<li><strong>Right to erasure</strong> ("right to be forgotten") &mdash; ask us to delete your data.</li>
<li><strong>Right to restrict processing</strong> &mdash; ask us to pause processing in certain circumstances.</li>
<li><strong>Right to data portability</strong> &mdash; receive your data in a machine-readable format.</li>
<li><strong>Right to object</strong> &mdash; object to processing based on legitimate interests.</li>
<li><strong>Rights related to automated decision-making</strong> &mdash; 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> &mdash; 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 &mdash; 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>
&middot; 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>

View 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 &rarr; Subscription &rarr; 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&ndash;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>

View 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 &mdash; through our payment processor Stripe &mdash;
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 &mdash; including the express-consent
mechanism that affects this right &mdash; are set out in our
<a class="text-accent underline" href="{{ route('legal.refund') }}">Refund &amp; 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, &copy; 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 &mdash; 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>

View File

@@ -8,14 +8,17 @@ use Illuminate\Support\Facades\Route;
// Named dashboard route so route('dashboard') resolves; Vue Router handles rendering
Route::get('/dashboard', fn () => view('app'))->middleware(['auth', 'verified'])->name('dashboard');
// Server-side logout — handles hard navigation to /logout
// Server-side logout for the SPA's hard navigation (GET /logout).
// Intentionally unnamed: the `logout` route name belongs to Fortify's POST /logout,
// which the Blade auth forms target via route('logout'). Both can share the /logout
// URL (different verbs), but two routes cannot share a name — that breaks route:cache.
Route::get('/logout', function (Request $request) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
})->middleware('auth')->name('logout');
})->middleware('auth');
Route::middleware(['auth'])->prefix('billing')->name('billing.')->group(function () {
Route::get('/checkout/{tier}/{cadence}', [BillingController::class, 'checkout'])->name('checkout');
@@ -24,5 +27,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');

View 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',
]);

View File

@@ -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',

View File

@@ -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')