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