Compare commits
107 Commits
03b0bece2c
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a6967dc01 | ||
|
|
257c09d178 | ||
|
|
f14006dc28 | ||
|
|
8fe3461adf | ||
|
|
ad2230728c | ||
|
|
5bd909d227 | ||
|
|
a841a7c900 | ||
|
|
adf0b93a45 | ||
|
|
1ae2f1396d | ||
|
|
df4ebdb7c6 | ||
|
|
7a8bd5c86a | ||
|
|
ecd45588e9 | ||
|
|
598ef04645 | ||
|
|
07e0789044 | ||
|
|
97e27fc057 | ||
|
|
11a3b433ff | ||
|
|
8dad223d06 | ||
|
|
1c46667f56 | ||
|
|
203200acb9 | ||
|
|
ddd591ad47 | ||
|
|
d13a29df01 | ||
|
|
c2c237a1b3 | ||
|
|
25cf022964 | ||
|
|
e821a934a5 | ||
|
|
73de53994f | ||
|
|
df70e514e9 | ||
|
|
28061541d4 | ||
|
|
895d55439b | ||
|
|
aff6dd1e0f | ||
|
|
06f5f2035f | ||
|
|
69eb524e07 | ||
|
|
b4ef1177b2 | ||
|
|
8e29980dfe | ||
|
|
4ce5066596 | ||
|
|
c46b017b51 | ||
|
|
7f64c42a23 | ||
|
|
4d9df1ee19 | ||
|
|
5369b4a5a0 | ||
|
|
27c82ef103 | ||
|
|
e39618f5df | ||
|
|
00d0f7c8ec | ||
|
|
48af2083f3 | ||
|
|
783297694c | ||
|
|
775e076bb7 | ||
|
|
8695d5ec95 | ||
|
|
088fd11058 | ||
|
|
ee6de23709 | ||
|
|
2ff3aeba4d | ||
|
|
b8adb81c79 | ||
|
|
3224b186b2 | ||
|
|
36444cde05 | ||
|
|
b7175169f0 | ||
|
|
5b17f4cae4 | ||
|
|
c127cc379e | ||
|
|
de2499636f | ||
|
|
2078c4b83e | ||
|
|
b9d457578c | ||
|
|
25b79f095b | ||
|
|
a39d4b1b94 | ||
|
|
f1c1a1c572 | ||
|
|
bf013926c0 | ||
|
|
19fc61a0a3 | ||
|
|
13fc227619 | ||
|
|
d8f87f964d | ||
|
|
975a1522cf | ||
|
|
29ba2f3d86 | ||
|
|
3ec7cda790 | ||
|
|
d01a634f0b | ||
|
|
9ad62538b9 | ||
|
|
4a60298606 | ||
|
|
5426722c71 | ||
|
|
d460de1850 | ||
|
|
45bf1c0d24 | ||
|
|
1e3b246172 | ||
|
|
9fa9ea7835 | ||
|
|
55c81fab7b | ||
|
|
64a7cc3de5 | ||
|
|
7c114c72e4 | ||
|
|
2fe9c3ef77 | ||
|
|
b4bd78ab4c | ||
|
|
8335f49fd6 | ||
|
|
dd9bd95657 | ||
|
|
afe459f248 | ||
|
|
d822b77fb0 | ||
|
|
831637380c | ||
|
|
c2466e5a61 | ||
|
|
7dc41ba9ee | ||
|
|
d29f3e6487 | ||
|
|
5acb99c9e3 | ||
|
|
aec547cd86 | ||
|
|
486f0e689c | ||
|
|
1a0381265e | ||
|
|
a7ee9f4557 | ||
|
|
4220b1b86a | ||
|
|
3cd3467178 | ||
|
|
d25883ead4 | ||
|
|
a969c1b347 | ||
|
|
951bb0b98d | ||
|
|
b8adc98669 | ||
|
|
2747047f53 | ||
|
|
5fe9f9dc6d | ||
|
|
276f9bf612 | ||
|
|
6f52f3f0d7 | ||
|
|
d11d500a35 | ||
|
|
b5ee25db67 | ||
|
|
66c662f471 | ||
|
|
9f7b45751e |
BIN
.claude/.DS_Store
vendored
BIN
.claude/.DS_Store
vendored
Binary file not shown.
@@ -5,7 +5,7 @@
|
|||||||
- Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/`
|
- Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/`
|
||||||
- Returns: all UK station prices + station metadata (~14,500 stations)
|
- Returns: all UK station prices + station metadata (~14,500 stations)
|
||||||
- Update frequency: stations report within 30 minutes of price change
|
- Update frequency: stations report within 30 minutes of price change
|
||||||
- Our polling interval: every 15 minutes via scheduler (incremental), full refresh once daily
|
- Our polling interval: every 30 minutes via scheduler (incremental using `effective-start-timestamp`), station metadata auto-refreshed once per day on the first poll after midnight
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
@@ -35,24 +35,34 @@ Content-Type: application/json
|
|||||||
- Include token in every API request: `Authorization: Bearer {token}`
|
- Include token in every API request: `Authorization: Bearer {token}`
|
||||||
|
|
||||||
#### Endpoints
|
#### Endpoints
|
||||||
- `GET /api/v1/pfs/fuel-prices?batch-number` — all/incremental station prices
|
- `GET /api/v1/pfs/fuel-prices?batch-number={n}` — all station prices (500 stations per batch)
|
||||||
- `GET /api/v1/pfs?batch-number` — all/incremental station metadata
|
- `GET /api/v1/pfs/fuel-prices?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS` — incremental, only prices changed since timestamp
|
||||||
|
- `GET /api/v1/pfs?batch-number={n}` — all station metadata (500 per batch)
|
||||||
|
- `GET /api/v1/pfs?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS` — incremental station metadata
|
||||||
|
|
||||||
**Fuel prices response fields** (array of stations):
|
**Fuel prices response fields** (array of stations):
|
||||||
- `node_id` — station identifier
|
- `node_id`, `public_phone_number`, `trading_name` — station identifiers
|
||||||
- `trading_name` — station name
|
|
||||||
- `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}`
|
- `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}`
|
||||||
- Fuel types: `E5`, `E10`, `B7_STANDARD`, `B7_PREMIUM`, `B10`, `HVO`
|
- Fuel types (API casing): `E5`, `E10`, `B7_Standard`, `B7_Premium`, `B10`, `HVO` — lowercased on ingest via `FuelType::fromApiValue()`
|
||||||
- Price is a float (e.g. `159.9` = 159.9p) — multiply × 100 and store as integer pence
|
- Price is a float (e.g. `159.9` = 159.9p) — multiply × 100 and store as integer pence
|
||||||
|
|
||||||
**Station metadata response fields** (array of stations):
|
**Station metadata response fields** (array of stations):
|
||||||
- `node_id`, `trading_name`, `brand_name`
|
- `node_id`, `trading_name`, `brand_name`, `is_same_trading_and_brand_name`, `public_phone_number`
|
||||||
- `is_supermarket_service_station`, `is_motorway_service_station`
|
- `is_supermarket_service_station`, `is_motorway_service_station`
|
||||||
- `temporary_closure`, `permanent_closure`
|
- `temporary_closure`, `permanent_closure`, `permanent_closure_date`
|
||||||
- `location` — `{address_line_1, city, postcode, latitude, longitude}`
|
- `location` — `{address_line_1, address_line_2, city, county, country, postcode, latitude, longitude}`
|
||||||
- `amenities` — string array (e.g. `car_wash`, `adblue_pumps`)
|
- `amenities` — **OBJECT** with boolean flags: `{adblue_pumps, adblue_packaged, lpg_pumps, car_wash, air_pump_or_screenwash, water_filling, twenty_four_hour_fuel, customer_toilets}`. Normalised at ingest to a flat array of enabled keys.
|
||||||
- `fuel_types` — string array of available fuel types
|
- `fuel_types` — **OBJECT** with boolean flags: `{E10, E5, B7_Standard, B7_Premium, B10, HVO}`. Normalised at ingest to a flat array of enabled keys.
|
||||||
- `opening_times` — per-day open/close times (not used in scoring)
|
- `opening_times` — `usual_days.{monday..sunday}.{open, close, is_24_hours}` + `bank_holidays.type.{open_time, close_time, is_24_hours}`. Stored as raw JSON, not used in scoring.
|
||||||
|
|
||||||
|
### Required-field validation
|
||||||
|
Stations missing any of `node_id`, `trading_name`, `location.postcode`, `location.latitude`, `location.longitude` are dropped at ingest with a warning. Price rows missing any of `fuel_type`, `price`, `price_last_updated`, `price_change_effective_timestamp` are skipped silently.
|
||||||
|
|
||||||
|
### Incremental polling (FuelPriceService::pollPrices)
|
||||||
|
On each successful poll the wall-clock start time is cached under `fuel_finder_last_price_poll_at` (forever). The next poll sends this as `effective-start-timestamp`. Cold start (cache miss) performs a full fetch.
|
||||||
|
|
||||||
|
### FK safety
|
||||||
|
Price batches are filtered against the `stations` table before insert — any station not yet in `stations` is skipped and logged. This guards against new stations appearing in the prices endpoint before the next metadata refresh picks them up.
|
||||||
|
|
||||||
### FuelPriceService responsibilities
|
### FuelPriceService responsibilities
|
||||||
1. Fetch OAuth token (cache it)
|
1. Fetch OAuth token (cache it)
|
||||||
@@ -70,7 +80,7 @@ for that `(station_id, fuel_type)` combination. Avoids row explosion on unchange
|
|||||||
```
|
```
|
||||||
FUEL_FINDER_CLIENT_ID=
|
FUEL_FINDER_CLIENT_ID=
|
||||||
FUEL_FINDER_CLIENT_SECRET=
|
FUEL_FINDER_CLIENT_SECRET=
|
||||||
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk
|
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Postcodes.io — location resolution
|
## Postcodes.io — location resolution
|
||||||
|
|||||||
@@ -1,57 +1,118 @@
|
|||||||
# Architecture
|
# 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
|
## Core principle: fat Services, thin everything else
|
||||||
|
|
||||||
All business logic lives in Service classes. Controllers, Livewire components,
|
All business logic lives in Service classes. API controllers, console commands,
|
||||||
and console commands are thin orchestrators — they call Services and return results.
|
jobs, listeners, and Filament resources are thin orchestrators — they call
|
||||||
This keeps the app API-extractable later without a rewrite.
|
Services and return results. Never put domain logic in a controller or a Vue
|
||||||
|
component.
|
||||||
|
|
||||||
## Directory structure
|
## Directory structure (actual)
|
||||||
|
|
||||||
```
|
```
|
||||||
app/
|
app/
|
||||||
├── Console/Commands/ # Scheduler commands (PollFuelPrices, PredictOilPrices, RunScoringEngine)
|
├── Console/Commands/ # Scheduler: PollFuelPrices, FetchOilPrices, BackfillOilPrices,
|
||||||
├── Http/Controllers/ # Minimal — auth + Stripe webhook only
|
│ # RunLlmOverlay, EvaluateVolatilityRegime, ResolveForecastOutcomes,
|
||||||
├── Livewire/ # Classic two-file Livewire components
|
│ # ArchiveOldPricesCommand, ImportBeisFuelPrices, ImportPostcodes
|
||||||
├── Models/ # Eloquent models
|
├── Http/
|
||||||
├── Notifications/ # Laravel Notification classes (multi-channel)
|
│ ├── Controllers/Api/ # AuthController, StationController, StatsController, UserController
|
||||||
├── Services/ # ALL business logic lives here
|
│ ├── Controllers/ # BillingController (Stripe Checkout/Portal redirects)
|
||||||
│ ├── FuelPriceService.php # Fuel Finder API polling + storage
|
│ ├── Middleware/ # VerifyApiKey (API-key gate), RequiresFeature (tier gate)
|
||||||
│ ├── AlertScoringService.php # Fill-up timing recommendation engine
|
│ ├── Requests/ # Form requests
|
||||||
│ ├── StationTaggingService.php # Supermarket brand detection
|
│ └── Resources/Api/ # StationResource (API output shaping)
|
||||||
│ ├── NotificationDispatchService.php # Tier-aware notification routing
|
├── Actions/Fortify/ # Fortify auth actions (CreateNewUser, …)
|
||||||
│ ├── SubscriptionService.php # Cashier/tier helpers
|
├── Services/ # ALL business logic — see below
|
||||||
│ ├── PostcodeService.php # Resolves postcodes/outcodes/place names → lat/lng
|
├── Models/ # Eloquent models
|
||||||
│ ├── OilPriceService.php # FRED fetch + EWMA/LLM Brent crude prediction
|
├── Notifications/ # FuelPriceAlert + custom Channels (OneSignal, Vonage WA/SMS)
|
||||||
│ ├── LocationResult.php # DTO returned by PostcodeService
|
├── Jobs/ # DispatchUserNotificationJob, PollFuelPricesJob,
|
||||||
│ └── ApiLogger.php # Wraps all outbound HTTP calls, logs to api_logs
|
│ # SendScheduledWhatsAppJob, SendPaymentFailedReminderJob
|
||||||
└── Jobs/ # Queued jobs (dispatch notifications per user)
|
├── 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/
|
app/Services/
|
||||||
├── livewire/ # Livewire Blade templates
|
├── FuelPriceService.php # Fuel Finder API polling + storage
|
||||||
└── emails/ # Mailable templates
|
├── 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/
|
routes/
|
||||||
├── web.php # All web routes (Livewire pages)
|
├── web.php # SPA shell + catch-all, legal pages, billing redirects, server logout
|
||||||
└── api.php # Empty for now — API added later if needed
|
└── 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
|
## Service class conventions
|
||||||
|
|
||||||
- Constructor injection only — no facade usage inside Services
|
- 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
|
- Each Service has one responsibility — do not merge concerns
|
||||||
- Return typed DTOs or Eloquent collections — never raw arrays from Services
|
- 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
|
`routes/api.php` is the SPA's backend. Three access tiers:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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.
|
Conventions:
|
||||||
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/`.
|
- 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 | ✗ | ✗ | ✗ |
|
| Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ |
|
||||||
| Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ |
|
| 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 |
|
| Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
|
||||||
|
|
||||||
## NotificationDispatchService
|
## NotificationDispatchService
|
||||||
@@ -41,8 +41,8 @@ Subject line / message copy adapts based on `recommendation` and `confidence`.
|
|||||||
|
|
||||||
- Same Vonage client, SMS channel
|
- Same Vonage client, SMS channel
|
||||||
- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it
|
- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it
|
||||||
- Plus: max 8 SMS/month (enforced via alerts table count)
|
- Plus (Smart): max 3 SMS/day (enforced via notification_log count)
|
||||||
- Pro: max 30 SMS/month
|
- Pro: deferred from launch — see `tiers.md`
|
||||||
- Cost: ~3.5p per message UK
|
- Cost: ~3.5p per message UK
|
||||||
|
|
||||||
## Email
|
## Email
|
||||||
|
|||||||
@@ -4,51 +4,121 @@
|
|||||||
|
|
||||||
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
|
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
|
||||||
|
|
||||||
## Stripe products
|
## Source-of-truth spec
|
||||||
|
|
||||||
|
`docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md`
|
||||||
|
defines the full subscription lifecycle. This file is a quick-reference; the
|
||||||
|
spec document is authoritative on any contradiction.
|
||||||
|
|
||||||
|
## Stripe products & prices
|
||||||
|
|
||||||
|
Three recurring subscription products, each with monthly and annual prices:
|
||||||
|
|
||||||
Three recurring subscription products (monthly):
|
|
||||||
- `basic` — £0.99/mo
|
- `basic` — £0.99/mo
|
||||||
- `plus` — £2.49/mo
|
- `plus` — £2.49/mo
|
||||||
- `pro` — £3.99/mo
|
- `pro` — £3.99/mo
|
||||||
|
|
||||||
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env:
|
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from `.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
STRIPE_PRICES_BASIC=price_xxx
|
STRIPE_PRICE_BASIC_MONTHLY=price_xxx
|
||||||
STRIPE_PRICES_PLUS=price_xxx
|
STRIPE_PRICE_BASIC_ANNUAL=price_xxx
|
||||||
STRIPE_PRICES_PRO=price_xxx
|
STRIPE_PRICE_PLUS_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_PLUS_ANNUAL=price_xxx
|
||||||
|
STRIPE_PRICE_PRO_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_PRO_ANNUAL=price_xxx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tier helpers (SubscriptionService)
|
Resolution from a Cashier subscription's Stripe price ID to a plan row is done
|
||||||
|
in `Plan::resolveForUser()` — never hand-code tier lookups elsewhere.
|
||||||
|
|
||||||
```php
|
## Tier resolution
|
||||||
public function tier(User $user): string
|
|
||||||
// Returns 'free' | 'basic' | 'plus' | 'pro'
|
|
||||||
|
|
||||||
public function canReceiveSms(User $user): bool
|
Use `PlanFeatures::for($user)->tier()` — returns `'free' | 'basic' | 'plus' | 'pro'`.
|
||||||
// true if tier is plus or pro
|
Never inspect `$user->subscribed(...)` directly in components, notifications, or
|
||||||
|
jobs. `PlanFeatures` is the single source of entitlement truth.
|
||||||
public function smsRemainingThisMonth(User $user): int
|
|
||||||
// checks alerts table count for current month
|
|
||||||
```
|
|
||||||
|
|
||||||
Never check tier inline in components or notification classes — always use SubscriptionService.
|
|
||||||
|
|
||||||
## Cashier conventions
|
## Cashier conventions
|
||||||
|
|
||||||
- Billable model: `User` (add `use Billable` trait)
|
- Billable model: `User` (uses `Billable` trait)
|
||||||
- Webhook route: `POST /stripe/webhook` — handled by Cashier automatically
|
- Webhook route: `POST /stripe/webhook` — auto-registered by Cashier
|
||||||
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
|
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
|
||||||
- Always handle `customer.subscription.deleted` to downgrade user to free tier
|
- `STRIPE_KEY` and `STRIPE_SECRET` also required
|
||||||
- Trial: none for v1
|
- `CASHIER_CURRENCY=gbp`
|
||||||
|
- Trial period: none
|
||||||
|
|
||||||
## Upgrade / downgrade flow
|
## User-facing flows — all via Stripe Customer Portal
|
||||||
|
|
||||||
- User upgrades in account settings Livewire component
|
**The Stripe-hosted Customer Billing Portal handles every subscription
|
||||||
- Swap plan with `$user->subscription()->swap($newPriceId)`
|
management action.** Do not build custom Livewire upgrade/downgrade UIs.
|
||||||
- Cashier handles proration automatically
|
|
||||||
- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference
|
| Flow | Path |
|
||||||
|
|---|---|
|
||||||
|
| Sign up for paid tier | Pricing page → `GET /billing/checkout/{tier}/{cadence}` → Stripe Checkout |
|
||||||
|
| Upgrade | Pricing page → `GET /billing/portal` → Stripe Portal → pick higher plan → Stripe prorates, charges difference immediately |
|
||||||
|
| Downgrade | Stripe Portal → pick lower plan → Stripe schedules change at period end |
|
||||||
|
| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true`; features stay until period end |
|
||||||
|
| Update card | Stripe Portal, or hosted link in Stripe's transactional dunning email |
|
||||||
|
| Reactivate after cancel / post-grace | Pricing page → Checkout (new subscription) |
|
||||||
|
|
||||||
|
Annual downgrades take effect at the end of the year — no mid-term refunds.
|
||||||
|
|
||||||
|
## Webhook handling
|
||||||
|
|
||||||
|
Single consolidated listener `HandleStripeWebhook` bound to Cashier's
|
||||||
|
`WebhookReceived` event in `AppServiceProvider`. Routes on `$event->payload['type']`:
|
||||||
|
|
||||||
|
| Event | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `customer.subscription.created` | Bust `plan_for_user_{id}` cache |
|
||||||
|
| `customer.subscription.updated` | Bust cache |
|
||||||
|
| `customer.subscription.deleted` | Downgrade to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache |
|
||||||
|
| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache |
|
||||||
|
| `invoice.payment_failed` | Set `grace_period_until = now()->addDays(5)`, queue day-3 + day-5 branded reminder mailables |
|
||||||
|
|
||||||
|
All branches must be idempotent — Stripe retries failed webhook deliveries.
|
||||||
|
|
||||||
|
`invoice.upcoming` is intentionally not handled.
|
||||||
|
|
||||||
|
## Payment failure & grace period
|
||||||
|
|
||||||
|
5-day grace window. Stripe is configured (dashboard) to retry on days 1, 3, 5
|
||||||
|
and **cancel the subscription** after the final failure.
|
||||||
|
|
||||||
|
- Features stay ON during grace — `past_due` is treated as subscribed by
|
||||||
|
Cashier, so `PlanFeatures::tier()` keeps returning the paid tier.
|
||||||
|
- After day 5 Stripe cancels → `customer.subscription.deleted` → downgrade.
|
||||||
|
- User can pay at any time via Stripe's dunning email link or the Customer
|
||||||
|
Portal — on success, grace is cleared automatically by the webhook.
|
||||||
|
|
||||||
|
## Dunning emails
|
||||||
|
|
||||||
|
- **Stripe sends:** payment-failed "update your card", successful-payment
|
||||||
|
receipts, upcoming-renewal reminders. Configure in Stripe dashboard.
|
||||||
|
- **We send:** branded reminder mailables on day 3 and day 5 after a
|
||||||
|
payment failure. Both mailables self-cancel by checking
|
||||||
|
`$this->user->grace_period_until === null` before sending — simpler than
|
||||||
|
cancelling queued jobs when payment recovers.
|
||||||
|
|
||||||
|
## Data model additions
|
||||||
|
|
||||||
|
- `users.grace_period_until` — nullable timestamp. Set on
|
||||||
|
`invoice.payment_failed`, cleared on `invoice.payment_succeeded` or
|
||||||
|
`customer.subscription.deleted`. Drives the dashboard past-due banner.
|
||||||
|
|
||||||
|
No other schema additions. Cashier + Stripe are the source of truth for
|
||||||
|
subscription state.
|
||||||
|
|
||||||
|
## VAT / Stripe Tax
|
||||||
|
|
||||||
|
Not enabled for v1. Revisit before £90k/year turnover (~£1.88k/month at
|
||||||
|
£3.99 avg, or ~470 paying pro users).
|
||||||
|
|
||||||
## Stripe test mode
|
## Stripe test mode
|
||||||
|
|
||||||
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
|
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
|
||||||
Test cards: 4242424242424242 (success), 4000000000000002 (decline).
|
|
||||||
|
Test cards:
|
||||||
|
- `4242 4242 4242 4242` — success
|
||||||
|
- `4000 0000 0000 0002` — generic decline
|
||||||
|
- `4000 0000 0000 0341` — renewal charge fails (use to test dunning flow)
|
||||||
|
|||||||
211
.claude/rules/prediction.md
Normal file
211
.claude/rules/prediction.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Prediction Engine
|
||||||
|
|
||||||
|
The "should I fill up now or wait?" recommendation that drives the headline,
|
||||||
|
notifications, and the entire product. Lives in `app/Services/NationalFuelPredictionService.php`
|
||||||
|
and is called from `Api\PredictionController`.
|
||||||
|
|
||||||
|
> The prediction is the product's selling point. Confidence calibration matters
|
||||||
|
> as much as direction — a "Wait — prices falling" headline at 30% confidence is
|
||||||
|
> worse than no recommendation at all.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
`predict(?float $lat, ?float $lng): array` returns:
|
||||||
|
|
||||||
|
| Key | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `fuel_type` | string | currently always `'e10'` |
|
||||||
|
| `current_avg` | float | current price avg in pence (regional 50km if coords given, else national) |
|
||||||
|
| `predicted_direction` | `'up' | 'down' | 'stable'` | aggregated vote |
|
||||||
|
| `predicted_change_pence` | float | `slope × 7` — pence change projected over the prediction horizon |
|
||||||
|
| `confidence_score` | float (0–100) | see "Confidence" below |
|
||||||
|
| `confidence_label` | `'low' | 'medium' | 'high'` | bucketing of `confidence_score` |
|
||||||
|
| `action` | `'fill_now' | 'wait' | 'no_signal'` | UI action mapped from direction |
|
||||||
|
| `reasoning` | string | concatenation of enabled signal `detail` fields, or action-aware fallback |
|
||||||
|
| `prediction_horizon_days` | int | `7` |
|
||||||
|
| `region_key` | `'national' | 'regional'` | depends on whether coords were passed |
|
||||||
|
| `methodology` | string | identifier for backtesting/auditing |
|
||||||
|
| `weekly_summary` | object | yesterday/today/tomorrow + 7-day series (see below) |
|
||||||
|
| `signals` | object | per-signal breakdown (see below) |
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
Each signal returns `{score, confidence, direction, detail, data_points, enabled}`.
|
||||||
|
|
||||||
|
| Signal | Source | Enabled when | Score formula |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `trend` | regression on daily national avg, 5-day adaptive → 14-day | ≥2 daily averages and R² ≥ 0.5 | `min(1, |slope| / SLOPE_SATURATION_PENCE) × sign(slope)` (saturates at `0.5p/day`) |
|
||||||
|
| `day_of_week` | weekday averages over last 90 days | `unique_days ≥ DAY_OF_WEEK_MIN_DAYS` (21) | `±1` if today ≥1.5p above/below week avg, else `0`; confidence scales with `unique_days/90` |
|
||||||
|
| `brand_behaviour` | supermarket vs major regression slopes over 7 days | both groups have ≥2 data points and divergence ≥1.0p | `±1` if leader is up/down |
|
||||||
|
| `regional_momentum` | regression on stations within 50km, 14 days | coords provided + ≥3 daily averages within radius | `±0.7` |
|
||||||
|
| `price_stickiness` | mean station hold duration over 30 days | ≥10 stations with ≥2 changes | `±0.1` confidence modifier |
|
||||||
|
| `oil` | latest `price_predictions` row covering today or later | a row exists | `±1` if rising/falling, `0` if flat; confidence = stored `confidence/100` |
|
||||||
|
| `national_momentum` | reserved | always disabled today | n/a |
|
||||||
|
|
||||||
|
### Oil signal — source preference
|
||||||
|
|
||||||
|
`computeOilSignal()` picks the freshest row in this order:
|
||||||
|
|
||||||
|
1. `source = 'llm_with_context'`
|
||||||
|
2. `source = 'llm'`
|
||||||
|
3. `source = 'ewma'`
|
||||||
|
|
||||||
|
`OilPriceService` (in `app/Services/OilPriceService.php` and friends) populates
|
||||||
|
this table daily at 7am via the scheduler. Cap: LLM confidence is capped at 85,
|
||||||
|
EWMA at 65 (see `.claude/rules/api-data.md`).
|
||||||
|
|
||||||
|
The Brent oil signal is the **single biggest unlock** for confidence — it
|
||||||
|
captures world-news context (OPEC, geopolitical) that pure local price history
|
||||||
|
can't see.
|
||||||
|
|
||||||
|
### Day-of-week threshold
|
||||||
|
|
||||||
|
The original spec said 56 days. Lowered to 21 because:
|
||||||
|
- The signal's `confidence` is already `min(1, unique_days / 90)` — a 21-day
|
||||||
|
signal naturally contributes only `~0.23` confidence and lifts as more data
|
||||||
|
accumulates.
|
||||||
|
- 56 days delays the signal so long it might as well not exist for new users.
|
||||||
|
|
||||||
|
## Aggregator
|
||||||
|
|
||||||
|
`aggregateSignals(signals, hasCoordinates)` returns `[direction, confidence_score]`.
|
||||||
|
|
||||||
|
### Weights
|
||||||
|
|
||||||
|
```
|
||||||
|
National (no coords):
|
||||||
|
trend 0.30
|
||||||
|
oil 0.25
|
||||||
|
dayOfWeek 0.20
|
||||||
|
brandBehaviour 0.15
|
||||||
|
stickiness 0.10
|
||||||
|
----
|
||||||
|
1.00
|
||||||
|
|
||||||
|
Regional (with coords):
|
||||||
|
regionalMomentum 0.35
|
||||||
|
oil 0.20
|
||||||
|
trend 0.15
|
||||||
|
dayOfWeek 0.15
|
||||||
|
brandBehaviour 0.10
|
||||||
|
stickiness 0.05
|
||||||
|
----
|
||||||
|
1.00
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direction
|
||||||
|
|
||||||
|
```
|
||||||
|
directional_score = Σ(score × signal_confidence × weight) // only signals with direction ≠ stable
|
||||||
|
directional_weight = Σ(weight) // only signals with direction ≠ stable
|
||||||
|
|
||||||
|
normalised = directional_score / directional_weight (0 if directional_weight ≈ 0)
|
||||||
|
|
||||||
|
direction = 'up' if normalised >= 0.1
|
||||||
|
'down' if normalised <= -0.1
|
||||||
|
'stable' otherwise
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stable signals do not dilute the direction vote.** They are excluded from both
|
||||||
|
the numerator and denominator. This is a key fix — previously a single weak
|
||||||
|
trend signal could be cancelled out by three "stable" signals adding weight
|
||||||
|
without contributing direction.
|
||||||
|
|
||||||
|
### Confidence
|
||||||
|
|
||||||
|
```
|
||||||
|
avg_signal_confidence = Σ(signal_confidence × weight) / Σ(weight) // all enabled signals
|
||||||
|
agreement = computeAgreement(signals, weights, final_direction) // 0..1
|
||||||
|
|
||||||
|
confidence_score = avg_signal_confidence × agreement × 100 (capped at 100)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`avg_signal_confidence`** is how confident the individual signals are in
|
||||||
|
their own readings (R², sample size, model confidence). Stable signals DO
|
||||||
|
contribute here — knowing prices are stable is itself a confident answer.
|
||||||
|
|
||||||
|
**`agreement`** measures how the signals line up with the chosen direction:
|
||||||
|
- aligned signal: full credit (`signal_confidence × weight`)
|
||||||
|
- one side stable, other directional: half credit
|
||||||
|
- opposing signals: no credit
|
||||||
|
- final score: `Σ credit / Σ max_credit`
|
||||||
|
|
||||||
|
This separation is the second key fix. Previously `confidence = |normalised| × 100`
|
||||||
|
conflated "the signals point strongly somewhere" with "we're sure". Now:
|
||||||
|
- Strong signals all agreeing → high `confidence_score`
|
||||||
|
- Strong signals disagreeing → low `confidence_score`
|
||||||
|
- Weak signals → low `confidence_score` (via low individual confidences)
|
||||||
|
|
||||||
|
### Confidence labels
|
||||||
|
|
||||||
|
| `confidence_score` | `confidence_label` | UI behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| ≥ 70 | `high` | fire notification when allowed |
|
||||||
|
| 40–69 | `medium` | dashboard only |
|
||||||
|
| < 40 | `low` | dashboard only |
|
||||||
|
|
||||||
|
## Reasoning
|
||||||
|
|
||||||
|
`buildReasoning()` joins `detail` strings from enabled signals. If none have
|
||||||
|
material content, it falls back to an **action-aware** sentence:
|
||||||
|
|
||||||
|
| `direction` / `action` | Fallback |
|
||||||
|
|---|---|
|
||||||
|
| `up` / `fill_now` | "Mild upward signals — top up soon if you're nearby." |
|
||||||
|
| `down` / `wait` | "Mild downward signals — wait a day or two if your tank can hold." |
|
||||||
|
| `stable` / `no_signal` | "No clear pattern — fill up at the cheapest station near you now." |
|
||||||
|
|
||||||
|
The earlier hard-coded "fill up" fallback contradicted "Wait — prices falling"
|
||||||
|
headlines and is no longer used.
|
||||||
|
|
||||||
|
## Weekly summary
|
||||||
|
|
||||||
|
`computeWeeklySummary()` returns the Y/T/T strip + last-7-days numbers:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `yesterday_avg` / `today_avg` | regional (50km) → national fallback |
|
||||||
|
| `tomorrow_estimated_avg` | `today_avg + trend.slope` (slope is 0 if trend disabled) |
|
||||||
|
| `yesterday_today_delta_pence` | `today − yesterday`; sign tells you which was cheaper |
|
||||||
|
| `last_7_days_series` | array of `{date, avg}`, one entry per day with data |
|
||||||
|
| `last_7_days_change_pence` | `series[last].avg − series[0].avg` |
|
||||||
|
| `cheapest_day` / `priciest_day` | min/max of the series |
|
||||||
|
| `is_regional` | `true` only if regional data was actually used; `false` after national fallback |
|
||||||
|
|
||||||
|
## API gate
|
||||||
|
|
||||||
|
The prediction is **embedded in the `/api/stations` response** under the
|
||||||
|
`prediction` key — there is no standalone prediction endpoint. The same payload
|
||||||
|
shape ships back regardless of route, but the gate runs server-side:
|
||||||
|
`PlanFeatures::for($user)->can('ai_predictions')`.
|
||||||
|
|
||||||
|
- ai_predictions allowed (plus, pro): full multi-signal payload
|
||||||
|
(`fuel_type`, `current_avg`, `predicted_direction`, `confidence_score`,
|
||||||
|
`reasoning`, `weekly_summary`, `signals`, …)
|
||||||
|
- otherwise (free, basic, guest): stripped teaser
|
||||||
|
`{fuel_type, predicted_direction, tier_locked: true}` for the upsell card
|
||||||
|
|
||||||
|
Bundling into `/api/stations` ties prediction availability to a real station
|
||||||
|
search — there is no way to scrape the prediction independently. Don't add a
|
||||||
|
separate prediction route or accept a request body without coords; the
|
||||||
|
prediction is always computed alongside a search.
|
||||||
|
|
||||||
|
## What never to do
|
||||||
|
|
||||||
|
- Don't introduce a new signal without giving it `enabled`, `confidence`, and a
|
||||||
|
weight in both national + regional weight maps.
|
||||||
|
- Don't read `brent_prices` directly from the prediction service — go through
|
||||||
|
`price_predictions`. The prediction table is the source of truth for
|
||||||
|
oil-direction-as-a-signal.
|
||||||
|
- Don't reintroduce a confidence formula that uses `|directional_score|` — that
|
||||||
|
conflates magnitude with sureness.
|
||||||
|
- Don't add a stable-direction signal to `directional_weight` — stable signals
|
||||||
|
must not dilute direction.
|
||||||
|
|
||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "app/Services/NationalFuelPredictionService.php"
|
||||||
|
- "app/Http/Controllers/Api/StationController.php"
|
||||||
|
- "tests/Unit/Services/NationalFuelPredictionServiceTest.php"
|
||||||
|
- "tests/Feature/Api/StationControllerTest.php"
|
||||||
|
---
|
||||||
409
.claude/rules/tiers.md
Normal file
409
.claude/rules/tiers.md
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
# Tier & Entitlement System — Claude Code Rules
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FuelAlert uses a plan-based entitlement system. Every decision about what a user
|
||||||
|
can receive, on which channel, at what frequency, is resolved through a single
|
||||||
|
`PlanFeatures` service. Nothing else makes entitlement decisions.
|
||||||
|
|
||||||
|
Users subscribe via Stripe (Laravel Cashier). The active plan is resolved from
|
||||||
|
the Stripe subscription's price ID, mapped to a `Plan` model row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tiers
|
||||||
|
|
||||||
|
| Tier | Price | Stripe Price Env Key |
|
||||||
|
|-------|--------|-------------------------------|
|
||||||
|
| free | £0 | — |
|
||||||
|
| basic | £0.99 | `STRIPE_PRICE_BASIC` |
|
||||||
|
| plus | £2.49 | `STRIPE_PRICE_PLUS` |
|
||||||
|
| pro | £3.99 | `STRIPE_PRICE_PRO` |
|
||||||
|
|
||||||
|
A user with no active Cashier subscription is always resolved as `free`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fuel Types
|
||||||
|
|
||||||
|
Six fuel types exist across the app:
|
||||||
|
|
||||||
|
```
|
||||||
|
E10, E5, B7_STANDARD, B7_PREMIUM, B10, HVO
|
||||||
|
```
|
||||||
|
|
||||||
|
All six are available to all tiers. The restriction is quantity only:
|
||||||
|
|
||||||
|
| Tier | Max tracked fuel types |
|
||||||
|
|---------------|------------------------|
|
||||||
|
| free | 1 |
|
||||||
|
| basic | 1 |
|
||||||
|
| plus | 1 |
|
||||||
|
| pro | unlimited (null) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Channels
|
||||||
|
|
||||||
|
| Channel | free | basic | plus | pro |
|
||||||
|
|-----------|---------------|--------------|--------------|--------------|
|
||||||
|
| email | weekly digest | daily | ✓ triggered | ✓ triggered |
|
||||||
|
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
|
||||||
|
| whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
Channel daily limits (`sms_daily_limit`, `whatsapp_daily_limit`) are enforced
|
||||||
|
by counting rows in `notification_log` for `(user_id, channel, DATE(created_at))`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Triggers
|
||||||
|
|
||||||
|
| Trigger | Description |
|
||||||
|
|----------------------|----------------------------------------------------------|
|
||||||
|
| `price_threshold` | Price drops at or below the user's saved threshold |
|
||||||
|
| `score_change` | Fill-up score flips good↔bad for a fuel type |
|
||||||
|
| `scheduled_morning` | WhatsApp scheduled update — fired by scheduler |
|
||||||
|
| `scheduled_evening` | WhatsApp scheduled update — fired by scheduler |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enum
|
||||||
|
|
||||||
|
Use a `PlanTier` backed enum at `app/Enums/PlanTier.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
enum PlanTier: string
|
||||||
|
{
|
||||||
|
case Free = 'free';
|
||||||
|
case Basic = 'basic';
|
||||||
|
case Plus = 'plus';
|
||||||
|
case Pro = 'pro';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference `PlanTier::Free->value` everywhere, never raw strings.
|
||||||
|
|
||||||
|
|
||||||
|
## Stripe Price IDs
|
||||||
|
|
||||||
|
Each tier has two prices:
|
||||||
|
|
||||||
|
| Tier | Monthly Env Key | Annual Env Key |
|
||||||
|
|-------|--------------------------------|-------------------------------|
|
||||||
|
| basic | `STRIPE_PRICE_BASIC_MONTHLY` | `STRIPE_PRICE_BASIC_ANNUAL` |
|
||||||
|
| plus | `STRIPE_PRICE_PLUS_MONTHLY` | `STRIPE_PRICE_PLUS_ANNUAL` |
|
||||||
|
| pro | `STRIPE_PRICE_PRO_MONTHLY` | `STRIPE_PRICE_PRO_ANNUAL` |
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### `plans` table
|
||||||
|
|
||||||
|
```
|
||||||
|
id unsignedBigInteger PK
|
||||||
|
name string — free | basic | plus | pro
|
||||||
|
stripe_price_id_monthly string nullable — Cashier price ID for monthly billing
|
||||||
|
stripe_price_id_annual string nullable — Cashier price ID for annual billing
|
||||||
|
features json — see shape below
|
||||||
|
active boolean default true
|
||||||
|
timestamps
|
||||||
|
```
|
||||||
|
|
||||||
|
**`features` JSON shape:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fuel_types": {
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"enabled": true,
|
||||||
|
"frequency": "triggered"
|
||||||
|
},
|
||||||
|
"push": {
|
||||||
|
"enabled": true,
|
||||||
|
"frequency": "triggered"
|
||||||
|
},
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"daily_limit": 5,
|
||||||
|
"scheduled_updates": 2
|
||||||
|
},
|
||||||
|
"sms": {
|
||||||
|
"enabled": true,
|
||||||
|
"daily_limit": 3
|
||||||
|
},
|
||||||
|
"ai_predictions": true,
|
||||||
|
"price_threshold": true,
|
||||||
|
"score_alerts": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`fuel_types.max: null` means unlimited. `email.frequency` values: `weekly_digest`,
|
||||||
|
`daily`, `triggered`. `push.frequency` values: `none` (when disabled), `daily`,
|
||||||
|
`triggered`. All boolean features default `false` on free.
|
||||||
|
|
||||||
|
`database/seeders/PlanSeeder.php` is the source of truth. Per-tier reality:
|
||||||
|
|
||||||
|
- `price_threshold` and `score_alerts` are **enabled on basic, plus, and pro** (not plus-only).
|
||||||
|
- `ai_predictions` is **plus and pro only**.
|
||||||
|
- `whatsapp` and `sms` always carry `daily_limit` (and whatsapp carries `scheduled_updates`)
|
||||||
|
even when `enabled: false` — set to `0` on disabled tiers.
|
||||||
|
|
||||||
|
> Deeper per-tier feature flags (history window, prediction level, leaderboard size,
|
||||||
|
> saved stations, fuel log caps, brand comparison, route planner, family sharing) are
|
||||||
|
> defined in `docs/superpowers/specs/2026-04-15-tier-features-design.md` — that spec
|
||||||
|
> is the source of truth for entitlements beyond notification channels.
|
||||||
|
|
||||||
|
### `user_notification_preferences` table
|
||||||
|
|
||||||
|
```
|
||||||
|
id unsignedBigInteger PK
|
||||||
|
user_id unsignedBigInteger FK users.id cascadeDelete
|
||||||
|
channel string — email | push | whatsapp | sms
|
||||||
|
fuel_type string — E10 | E5 | B7_STANDARD | B7_PREMIUM | B10 | HVO
|
||||||
|
enabled boolean default true
|
||||||
|
timestamps
|
||||||
|
unique([user_id, channel, fuel_type])
|
||||||
|
```
|
||||||
|
|
||||||
|
The user opts channels in/out here. The tier is the ceiling — if the plan does
|
||||||
|
not allow a channel, this preference is ignored regardless of its value.
|
||||||
|
|
||||||
|
### `notification_log` table
|
||||||
|
|
||||||
|
```
|
||||||
|
id unsignedBigInteger PK
|
||||||
|
user_id unsignedBigInteger FK users.id cascadeDelete
|
||||||
|
channel string
|
||||||
|
trigger_type string — price_threshold | score_change | scheduled_morning | scheduled_evening
|
||||||
|
fuel_type string
|
||||||
|
price decimal(8,3) nullable
|
||||||
|
sent boolean
|
||||||
|
missed_reason string nullable — daily_limit | tier_restricted | user_disabled
|
||||||
|
created_at timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
No `updated_at` — this is append-only. Index on `(user_id, channel, created_at)`
|
||||||
|
for daily limit queries. Index on `(user_id, sent, created_at)` for dashboard
|
||||||
|
missed-count queries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### `Plan`
|
||||||
|
|
||||||
|
- Casts `features` to `array`.
|
||||||
|
- Has a static `resolveForUser(User $user): Plan` method — looks up the user's
|
||||||
|
active Cashier subscription price ID, matches to either `stripe_price_id_monthly`
|
||||||
|
or `stripe_price_id_annual`, falls back to the `free` plan row.
|
||||||
|
- Cache the resolved plan: `Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...)`.
|
||||||
|
- Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`.
|
||||||
|
|
||||||
|
### `UserNotificationPreference`
|
||||||
|
|
||||||
|
- `belongsTo(User::class)`
|
||||||
|
- Scope: `scopeEnabled($query)` — where enabled = true
|
||||||
|
- Scope: `scopeForChannel($query, string $channel)`
|
||||||
|
- Scope: `scopeForFuelType($query, string $fuelType)`
|
||||||
|
|
||||||
|
### `NotificationLog`
|
||||||
|
|
||||||
|
- `belongsTo(User::class)`
|
||||||
|
- Scope: `scopeSentToday($query, string $channel)` — counts today's sent rows
|
||||||
|
- Scope: `scopeMissed($query)` — where sent = false
|
||||||
|
- Never update rows — only insert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `PlanFeatures` Service
|
||||||
|
|
||||||
|
Located at `app/Services/PlanFeatures.php`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
PlanFeatures::for($user)->canUseChannel('sms') // bool — tier allows it
|
||||||
|
PlanFeatures::for($user)->canSendNow('sms') // bool — tier allows + under daily limit
|
||||||
|
PlanFeatures::for($user)->channelsFor('price_threshold') // string[] — allowed + user-enabled + under limit
|
||||||
|
PlanFeatures::for($user)->canTrackFuelType(string $type) // bool — under max
|
||||||
|
PlanFeatures::for($user)->trackedFuelTypeCount() // int
|
||||||
|
PlanFeatures::for($user)->fuelTypeLimit() // int|null
|
||||||
|
PlanFeatures::for($user)->can('ai_predictions') // bool — generic feature flag
|
||||||
|
PlanFeatures::for($user)->missedToday(string $channel) // int — for dashboard
|
||||||
|
PlanFeatures::for($user)->missedThisMonth(string $channel)// int — for digest email
|
||||||
|
PlanFeatures::for($user)->tier() // string — free|basic|plus|pro
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- `canSendNow` always checks both the plan cap AND the live `notification_log`
|
||||||
|
count for today. Never skip either check.
|
||||||
|
- `channelsFor($triggerType)` is the method used by the dispatch job. It returns
|
||||||
|
only channels that pass: tier allows → user has enabled → daily limit not hit.
|
||||||
|
- The service must never throw. If the plan cannot be resolved, treat as `free`.
|
||||||
|
- The service is the **only** place daily limit logic lives. Jobs and controllers
|
||||||
|
call `PlanFeatures`, never query `notification_log` directly for limit checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `RequiresFeature` Middleware
|
||||||
|
|
||||||
|
Registered as `feature` in `bootstrap/app.php`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Usage in routes
|
||||||
|
Route::get('/predictions', PredictionsController::class)
|
||||||
|
->middleware('feature:ai_predictions');
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `403 { "error": "upgrade_required", "feature": "ai_predictions" }` if
|
||||||
|
`PlanFeatures::for($request->user())->can($feature)` is false.
|
||||||
|
|
||||||
|
Only use for route-level feature gates. Channel-level logic stays in the job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Dispatch Flow
|
||||||
|
|
||||||
|
### Price update (every 15 min)
|
||||||
|
|
||||||
|
```
|
||||||
|
PriceUpdated event (fired by polling job)
|
||||||
|
└── ProcessPriceAlerts job (queued, single instance via WithoutOverlapping)
|
||||||
|
├── Find users whose threshold >= new price for this fuel type
|
||||||
|
├── Find users subscribed to score_change if score flipped
|
||||||
|
├── Chunk users → dispatch DispatchUserNotification job per user
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DispatchUserNotification` job
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Load plan via Plan::resolveForUser($user) — cached
|
||||||
|
2. Instantiate PlanFeatures::for($user)
|
||||||
|
3. $channels = $features->channelsFor($triggerType) — filtered list
|
||||||
|
4. foreach $channels as $channel:
|
||||||
|
a. Send via the appropriate Laravel Notification class
|
||||||
|
b. Log to notification_log (sent: true)
|
||||||
|
5. foreach skipped channels (tier allows but limit hit):
|
||||||
|
a. Log to notification_log (sent: false, missed_reason: daily_limit)
|
||||||
|
6. foreach tier-blocked channels the user had enabled in prefs:
|
||||||
|
a. Log to notification_log (sent: false, missed_reason: tier_restricted)
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not log channels the user has manually disabled (`user_disabled` would be
|
||||||
|
noise — those are intentional).
|
||||||
|
|
||||||
|
### Scheduled WhatsApp updates
|
||||||
|
|
||||||
|
Two scheduler entries:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schedule::job(SendScheduledWhatsApp::class, 'morning')->dailyAt('07:30');
|
||||||
|
Schedule::job(SendScheduledWhatsApp::class, 'evening')->dailyAt('18:00');
|
||||||
|
```
|
||||||
|
|
||||||
|
`SendScheduledWhatsApp` queries all users where:
|
||||||
|
- Plan has `whatsapp.scheduled_updates > 0`
|
||||||
|
- User has whatsapp preference enabled
|
||||||
|
- `canSendNow('whatsapp')` is true at dispatch time
|
||||||
|
|
||||||
|
Same logging rules apply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filament `PlanResource`
|
||||||
|
|
||||||
|
Located in the admin panel. Edits the `features` JSON column using explicit form
|
||||||
|
fields — never a raw key-value editor.
|
||||||
|
|
||||||
|
**Form fields:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Section: Fuel Types
|
||||||
|
- NumberInput fuel_types.max (null = unlimited, label: "Max fuel types — leave blank for unlimited")
|
||||||
|
|
||||||
|
Section: Email
|
||||||
|
- Toggle email.enabled
|
||||||
|
- Select email.frequency options: weekly_digest | daily | triggered
|
||||||
|
|
||||||
|
Section: Push
|
||||||
|
- Toggle push.enabled
|
||||||
|
|
||||||
|
Section: WhatsApp
|
||||||
|
- Toggle whatsapp.enabled
|
||||||
|
- NumberInput whatsapp.daily_limit
|
||||||
|
- NumberInput whatsapp.scheduled_updates
|
||||||
|
|
||||||
|
Section: SMS
|
||||||
|
- Toggle sms.enabled
|
||||||
|
- NumberInput sms.daily_limit
|
||||||
|
|
||||||
|
Section: Features
|
||||||
|
- Toggle ai_predictions
|
||||||
|
- Toggle price_threshold
|
||||||
|
- Toggle score_alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
On save, bust `Cache::tags(['plans'])`.
|
||||||
|
|
||||||
|
Do not allow deleting plan rows — disable the `DeleteAction` on the resource.
|
||||||
|
Do not allow creating new plan rows from the UI — the four tiers are seeded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filament Dashboard Widget — Missed Notifications
|
||||||
|
|
||||||
|
A `StatsOverviewWidget` on the user detail page (or a standalone widget) showing:
|
||||||
|
|
||||||
|
```
|
||||||
|
SMS missed today: 3 [Upgrade to Pro]
|
||||||
|
WhatsApp missed today: 0
|
||||||
|
Total missed this month: 12
|
||||||
|
```
|
||||||
|
|
||||||
|
Data sourced from `NotificationLog::scopeMissed()` queries. This data also feeds
|
||||||
|
the weekly/monthly digest email — the mailable receives the counts and renders
|
||||||
|
a "you missed X alerts — upgrade" block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seeder
|
||||||
|
|
||||||
|
A `PlanSeeder` must exist that creates or updates all four plan rows with correct
|
||||||
|
default feature values. It must be idempotent (`updateOrCreate` on `name`).
|
||||||
|
Run as part of `DatabaseSeeder` in production-safe seeders.
|
||||||
|
|
||||||
|
```php
|
||||||
|
php artisan db:seed --class=PlanSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Expectations
|
||||||
|
|
||||||
|
Every entitlement check must have a Pest feature test:
|
||||||
|
|
||||||
|
- `canUseChannel` returns false when tier doesn't allow it
|
||||||
|
- `canSendNow` returns false when daily limit is reached
|
||||||
|
- `channelsFor` returns correct filtered list for each tier
|
||||||
|
- `canTrackFuelType` enforces max correctly, null = unlimited
|
||||||
|
- Middleware returns 403 with correct JSON for missing feature
|
||||||
|
- `DispatchUserNotification` job logs missed_reason correctly
|
||||||
|
- `PlanSeeder` is idempotent
|
||||||
|
|
||||||
|
Use factories for `Plan`, `User`, `UserNotificationPreference`, `NotificationLog`.
|
||||||
|
The `Plan` factory should accept a `tier` state: `Plan::factory()->pro()->create()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Must Never Happen
|
||||||
|
|
||||||
|
- Never query `notification_log` for limit checks outside `PlanFeatures`
|
||||||
|
- Never hardcode tier names as strings outside `Plan::TIERS` constant or an Enum
|
||||||
|
- Never send a notification without logging it
|
||||||
|
- Never bypass `PlanFeatures` in a job or controller "just this once"
|
||||||
|
- Never allow the `features` JSON to be partially saved — always merge full shape
|
||||||
|
- Never add a new feature to the JSON without adding a corresponding method to
|
||||||
|
`PlanFeatures` and updating the `PlanSeeder`
|
||||||
30
.claude/settings.json
Normal file
30
.claude/settings.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"deny": [
|
||||||
|
"Read(./.env)",
|
||||||
|
"Read(.env)",
|
||||||
|
"Bash(cat .env)",
|
||||||
|
"Bash(cat ./.env)",
|
||||||
|
"Bash(head .env)",
|
||||||
|
"Bash(head ./.env)",
|
||||||
|
"Bash(tail .env)",
|
||||||
|
"Bash(tail ./.env)",
|
||||||
|
"Bash(less .env)",
|
||||||
|
"Bash(less ./.env)",
|
||||||
|
"Bash(more .env)",
|
||||||
|
"Bash(more ./.env)",
|
||||||
|
"Bash(grep * .env)",
|
||||||
|
"Bash(grep * ./.env)",
|
||||||
|
"Bash(rg * .env)",
|
||||||
|
"Bash(rg * ./.env)",
|
||||||
|
"Bash(awk * .env)",
|
||||||
|
"Bash(awk * ./.env)",
|
||||||
|
"Bash(php artisan migrate:fresh)",
|
||||||
|
"Bash(php artisan migrate:fresh *)",
|
||||||
|
"Bash(php artisan migrate:reset)",
|
||||||
|
"Bash(php artisan migrate:reset *)",
|
||||||
|
"Bash(php artisan db:wipe)",
|
||||||
|
"Bash(php artisan db:wipe *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/antfu
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/nuxt
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/pinia
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/pnpm
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/slidev
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/tsdown
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/turborepo
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/unocss
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vite
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vitepress
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vitest
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vue
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vue-best-practices
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vue-router-best-practices
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vue-testing-best-practices
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vueuse-functions
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/web-design-guidelines
|
|
||||||
53
.env.example
53
.env.example
@@ -1,8 +1,8 @@
|
|||||||
APP_NAME=Laravel
|
APP_NAME="Fuel Alert"
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://fuel-alert.test
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@@ -20,18 +20,18 @@ LOG_STACK=single
|
|||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=mysql
|
||||||
# DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
# DB_PORT=3306
|
DB_PORT=3306
|
||||||
# DB_DATABASE=laravel
|
DB_DATABASE=fuel-price
|
||||||
# DB_USERNAME=root
|
DB_USERNAME=fuel-price
|
||||||
# DB_PASSWORD=
|
DB_PASSWORD=password
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=.fuel-alert.test
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
@@ -64,4 +64,37 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
FUELALERT_API_KEY=
|
FUEL_FINDER_CLIENT_ID=
|
||||||
|
FUEL_FINDER_CLIENT_SECRET=
|
||||||
|
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=claude-haiku-4-5
|
||||||
|
|
||||||
|
FRED_API_KEY=
|
||||||
|
|
||||||
|
ONESIGNAL_APP_ID=
|
||||||
|
ONESIGNAL_API_KEY=
|
||||||
|
|
||||||
|
VONAGE_KEY=
|
||||||
|
VONAGE_SECRET=
|
||||||
|
VONAGE_WHATSAPP_FROM=
|
||||||
|
VONAGE_SMS_FROM=FuelAlert
|
||||||
|
API_SECRET_KEY=
|
||||||
|
EIA_API_KEY=
|
||||||
|
|
||||||
|
LLM_PREDICTION_PROVIDER=anthropic
|
||||||
|
|
||||||
|
STRIPE_KEY=
|
||||||
|
STRIPE_SECRET=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
CASHIER_CURRENCY=gbp
|
||||||
|
|
||||||
|
STRIPE_PRICE_BASIC_MONTHLY=price_1TM3cwJuhjW3IKHlJCHz0xmU
|
||||||
|
STRIPE_PRICE_BASIC_ANNUAL=price_1TM3nlJuhjW3IKHlwcHF5W9v
|
||||||
|
STRIPE_PRICE_PLUS_MONTHLY=price_1TM3oqJuhjW3IKHlbQUMhrnm
|
||||||
|
STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
|
||||||
|
STRIPE_PRICE_PRO_MONTHLY=
|
||||||
|
STRIPE_PRICE_PRO_ANNUAL=
|
||||||
|
|
||||||
|
SANCTUM_STATEFUL_DOMAINS=fuel-alert.test
|
||||||
50
.github/workflows/lint.yml
vendored
50
.github/workflows/lint.yml
vendored
@@ -1,50 +0,0 @@
|
|||||||
name: linter
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
- workos
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
- workos
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
quality:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: Testing
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: '8.4'
|
|
||||||
|
|
||||||
- name: Add Flux Credentials Loaded From ENV
|
|
||||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: |
|
|
||||||
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
|
||||||
npm install
|
|
||||||
|
|
||||||
- name: Run Pint
|
|
||||||
run: composer lint
|
|
||||||
|
|
||||||
# - name: Commit Changes
|
|
||||||
# uses: stefanzweifel/git-auto-commit-action@v7
|
|
||||||
# with:
|
|
||||||
# commit_message: fix code style
|
|
||||||
# commit_options: '--no-verify'
|
|
||||||
# file_pattern: |
|
|
||||||
# **/*
|
|
||||||
# !.github/workflows/*
|
|
||||||
60
.github/workflows/tests.yml
vendored
60
.github/workflows/tests.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
name: tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
- workos
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
- workos
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
ci:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
environment: Testing
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
php-version: ['8.3', '8.4', '8.5']
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Setup PHP
|
|
||||||
uses: shivammathur/setup-php@v2
|
|
||||||
with:
|
|
||||||
php-version: ${{ matrix.php-version }}
|
|
||||||
tools: composer:v2
|
|
||||||
coverage: xdebug
|
|
||||||
|
|
||||||
- name: Setup Node
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: '22'
|
|
||||||
|
|
||||||
- name: Install Node Dependencies
|
|
||||||
run: npm i
|
|
||||||
|
|
||||||
- name: Add Flux Credentials Loaded From ENV
|
|
||||||
run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_USERNAME }}" "${{ secrets.FLUX_LICENSE_KEY }}"
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
|
||||||
|
|
||||||
- name: Copy Environment File
|
|
||||||
run: cp .env.example .env
|
|
||||||
|
|
||||||
- name: Generate Application Key
|
|
||||||
run: php artisan key:generate
|
|
||||||
|
|
||||||
- name: Build Assets
|
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Run Tests
|
|
||||||
run: ./vendor/bin/pest
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
@@ -22,3 +23,5 @@ yarn-error.log
|
|||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/.tmp/
|
/.tmp/
|
||||||
|
/.worktrees/
|
||||||
|
/ONSPD_Online_Latest_Centroids_*.csv
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/antfu
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/nuxt
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/pinia
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/pnpm
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/slidev
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/tsdown
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/turborepo
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/unocss
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vite
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vitepress
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vitest
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vue
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vue-best-practices
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vue-router-best-practices
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vue-testing-best-practices
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/vueuse-functions
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../.agents/skills/web-design-guidelines
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
# SuperDesign Context Files for Fuel Price Application
|
|
||||||
|
|
||||||
This directory contains comprehensive design system documentation for the Fuel Price (Laravel Starter Kit) application.
|
|
||||||
|
|
||||||
## Generated Files
|
|
||||||
|
|
||||||
### 1. `init/components.md` (274 lines)
|
|
||||||
**Shared UI Primitives & Components**
|
|
||||||
|
|
||||||
Lists all reusable Blade components (7 total) with full source code:
|
|
||||||
- x-action-message: Temporary status messages
|
|
||||||
- x-app-logo: Branding component
|
|
||||||
- x-app-logo-icon: SVG logo icon
|
|
||||||
- x-auth-header: Centered auth page header
|
|
||||||
- x-auth-session-status: Session status display
|
|
||||||
- x-desktop-user-menu: User profile dropdown
|
|
||||||
- x-placeholder-pattern: Loading state pattern
|
|
||||||
|
|
||||||
Also documents Flux v2 components used throughout the application.
|
|
||||||
|
|
||||||
### 2. `init/layouts.md` (347 lines)
|
|
||||||
**Complete Layout Hierarchy**
|
|
||||||
|
|
||||||
Full content of 7 layout files:
|
|
||||||
- layouts/app.blade.php: Main app wrapper
|
|
||||||
- layouts/app/sidebar.blade.php: Authenticated layout with header/sidebar
|
|
||||||
- layouts/auth.blade.php: Auth wrapper
|
|
||||||
- layouts/auth/simple.blade.php: Centered auth layout
|
|
||||||
- layouts/auth/card.blade.php: Card-based auth layout
|
|
||||||
- layouts/auth/split.blade.php: Split-screen auth layout
|
|
||||||
- partials/head.blade.php: Shared head section
|
|
||||||
|
|
||||||
Includes usage patterns and color scheme documentation.
|
|
||||||
|
|
||||||
### 3. `init/routes.md` (120 lines)
|
|
||||||
**Route Configuration & Summary**
|
|
||||||
|
|
||||||
Complete files:
|
|
||||||
- routes/web.php: Public and authenticated routes
|
|
||||||
- routes/settings.php: Settings page routes
|
|
||||||
|
|
||||||
Plus detailed route summary table covering:
|
|
||||||
- 19 total routes (web + Fortify)
|
|
||||||
- Route grouping by protection level
|
|
||||||
- Livewire component routes mapping
|
|
||||||
|
|
||||||
### 4. `init/theme.md` (298 lines)
|
|
||||||
**Design Tokens & Configuration**
|
|
||||||
|
|
||||||
Complete content:
|
|
||||||
- resources/css/app.css: Tailwind imports and custom theme
|
|
||||||
- Color palette: Zinc neutral scale + semantic colors
|
|
||||||
- Typography: Instrument Sans font configuration
|
|
||||||
- Spacing system: 4px base unit scale
|
|
||||||
- Dark mode implementation
|
|
||||||
- Vite build configuration
|
|
||||||
- Flux theme setup
|
|
||||||
|
|
||||||
### 5. `init/pages.md` (291 lines)
|
|
||||||
**Page Dependency Trees**
|
|
||||||
|
|
||||||
Full-page component documentation with dependency trees:
|
|
||||||
1. StationSearch Livewire component (public)
|
|
||||||
2. Dashboard
|
|
||||||
3. Welcome/Home page
|
|
||||||
4. Settings pages (Profile, Security, Appearance)
|
|
||||||
5. Authentication pages (Login, Register, Password Reset, Email Verification, 2FA)
|
|
||||||
|
|
||||||
Includes data flow documentation and page transition patterns.
|
|
||||||
|
|
||||||
### 6. `init/extractable-components.md` (386 lines)
|
|
||||||
**Reusable Component Analysis**
|
|
||||||
|
|
||||||
16 components identified by category:
|
|
||||||
- 3 Form components (Search, Select, Loading Button)
|
|
||||||
- 6 Data Display components (Card, Stats, Legend)
|
|
||||||
- 2 Navigation components (Sidebar Nav, User Menu)
|
|
||||||
- 3 Layout components (Auth layouts)
|
|
||||||
- 2 Feedback components (Messages)
|
|
||||||
- 1 Map/Visualization component
|
|
||||||
- 2 Typography components
|
|
||||||
|
|
||||||
Includes reusability scoring table and extraction recommendations.
|
|
||||||
|
|
||||||
### 7. `init/design-system.md` (494 lines)
|
|
||||||
**Complete Design System**
|
|
||||||
|
|
||||||
Comprehensive design documentation:
|
|
||||||
- Brand identity & philosophy
|
|
||||||
- Color system: Zinc palette + semantic colors
|
|
||||||
- Typography: Typeface, hierarchy, weights
|
|
||||||
- Spacing & layout: Grid system, breakpoints
|
|
||||||
- Borders & radius: Border weight, color, radius scales
|
|
||||||
- Component patterns: Forms, Buttons, Cards, Navigation
|
|
||||||
- Data visualization: Map colors, marker styles
|
|
||||||
- Animations & transitions: Page transitions, component effects
|
|
||||||
- Accessibility: Contrast, interactive elements, typography
|
|
||||||
- Dark mode: Colors, images, implementation
|
|
||||||
- Responsive behavior: Breakpoints, layout adjustments
|
|
||||||
|
|
||||||
## Project Stack
|
|
||||||
|
|
||||||
- **Framework:** Laravel 11
|
|
||||||
- **UI Library:** Livewire 3 (classic components)
|
|
||||||
- **Styling:** Tailwind CSS v4
|
|
||||||
- **UI Components:** Flux UI v2
|
|
||||||
- **JavaScript:** Alpine.js
|
|
||||||
- **Font:** Instrument Sans (400, 500, 600 weights)
|
|
||||||
- **Map Library:** Leaflet
|
|
||||||
- **Map Tiles:** OpenStreetMap
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
- Dark-mode-first design with light mode support
|
|
||||||
- Responsive mobile-first architecture
|
|
||||||
- Comprehensive authentication UI (Fortify-based)
|
|
||||||
- Interactive fuel station search with Leaflet maps
|
|
||||||
- Settings/profile management
|
|
||||||
- Accessibility-focused component design
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
### Primary
|
|
||||||
- Zinc neutral scale (50-950)
|
|
||||||
|
|
||||||
### Semantic
|
|
||||||
- Success: Green-500 (#22c55e)
|
|
||||||
- Warning: Amber-500 (#f59e0b)
|
|
||||||
- Error: Red-500 (#ef4444)
|
|
||||||
- Info: Slate-500 (#64748b)
|
|
||||||
|
|
||||||
## File Organization
|
|
||||||
|
|
||||||
All context files are in `init/` subdirectory for SuperDesign import.
|
|
||||||
|
|
||||||
Total: ~2,210 lines of comprehensive design documentation
|
|
||||||
|
|
||||||
Generated: April 6, 2026
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# Shared UI Components
|
|
||||||
|
|
||||||
## Blade Components (Reusable)
|
|
||||||
|
|
||||||
### 1. `x-action-message`
|
|
||||||
**Path:** `resources/views/components/action-message.blade.php`
|
|
||||||
|
|
||||||
Displays a temporary status message that auto-hides after 2 seconds using Alpine.js.
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `on`: Event name to listen for (Livewire event)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Transition animation with fade-out
|
|
||||||
- Auto-hide timeout (2000ms)
|
|
||||||
- Default text: "Saved."
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@props([
|
|
||||||
'on',
|
|
||||||
])
|
|
||||||
|
|
||||||
<div
|
|
||||||
x-data="{ shown: false, timeout: null }"
|
|
||||||
x-init="@this.on('{{ $on }}', () => { clearTimeout(timeout); shown = true; timeout = setTimeout(() => { shown = false }, 2000); })"
|
|
||||||
x-show.transition.out.opacity.duration.1500ms="shown"
|
|
||||||
x-transition:leave.opacity.duration.1500ms
|
|
||||||
style="display: none"
|
|
||||||
{{ $attributes->merge(['class' => 'text-sm']) }}
|
|
||||||
>
|
|
||||||
{{ $slot->isEmpty() ? __('Saved.') : $slot }}
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `x-app-logo`
|
|
||||||
**Path:** `resources/views/components/app-logo.blade.php`
|
|
||||||
|
|
||||||
Main app branding component. Renders as `flux:brand` (header) or `flux:sidebar.brand` (sidebar variant).
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `sidebar` (bool): If true, renders sidebar variant
|
|
||||||
- Inherits Flux component attributes
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Dynamic logo rendering based on context (header vs sidebar)
|
|
||||||
- Uses `x-app-logo-icon` for icon
|
|
||||||
- Passes through attributes to Flux components
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@props([
|
|
||||||
'sidebar' => false,
|
|
||||||
])
|
|
||||||
|
|
||||||
@if($sidebar)
|
|
||||||
<flux:sidebar.brand name="Laravel Starter Kit" {{ $attributes }}>
|
|
||||||
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
|
|
||||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
|
||||||
</x-slot>
|
|
||||||
</flux:sidebar.brand>
|
|
||||||
@else
|
|
||||||
<flux:brand name="Laravel Starter Kit" {{ $attributes }}>
|
|
||||||
<x-slot name="logo" class="flex aspect-square size-8 items-center justify-center rounded-md bg-accent-content text-accent-foreground">
|
|
||||||
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
|
|
||||||
</x-slot>
|
|
||||||
</flux:brand>
|
|
||||||
@endif
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. `x-app-logo-icon`
|
|
||||||
**Path:** `resources/views/components/app-logo-icon.blade.php`
|
|
||||||
|
|
||||||
SVG icon for the application logo. Geometric design with fills.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Scalable SVG
|
|
||||||
- Uses `currentColor` for dynamic styling
|
|
||||||
- Can be sized with Tailwind classes
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 42" {{ $attributes }}>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M17.2 5.633 8.6.855 0 5.633v26.51l16.2 9 16.2-9v-8.442l7.6-4.223V9.856l-8.6-4.777-8.6 4.777V18.3l-5.6 3.111V5.633ZM38 18.301l-5.6 3.11v-6.157l5.6-3.11V18.3Zm-1.06-7.856-5.54 3.078-5.54-3.079 5.54-3.078 5.54 3.079ZM24.8 18.3v-6.157l5.6 3.111v6.158L24.8 18.3Zm-1 1.732 5.54 3.078-13.14 7.302-5.54-3.078 13.14-7.3v-.002Zm-16.2 7.89 7.6 4.222V38.3L2 30.966V7.92l5.6 3.111v16.892ZM8.6 9.3 3.06 6.222 8.6 3.143l5.54 3.08L8.6 9.3Zm21.8 15.51-13.2 7.334V38.3l13.2-7.334v-6.156ZM9.6 11.034l5.6-3.11v14.6l-5.6 3.11v-14.6Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `x-auth-header`
|
|
||||||
**Path:** `resources/views/components/auth-header.blade.php`
|
|
||||||
|
|
||||||
Centered header for authentication pages with title and description.
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `title`: Main heading text
|
|
||||||
- `description`: Subheading/description text
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Centered layout
|
|
||||||
- Uses Flux typography components
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@props([
|
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
])
|
|
||||||
|
|
||||||
<div class="flex w-full flex-col text-center">
|
|
||||||
<flux:heading size="xl">{{ $title }}</flux:heading>
|
|
||||||
<flux:subheading>{{ $description }}</flux:subheading>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. `x-auth-session-status`
|
|
||||||
**Path:** `resources/views/components/auth-session-status.blade.php`
|
|
||||||
|
|
||||||
Displays session status messages (typically success messages after redirect).
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `status`: Status message text (if present)
|
|
||||||
- Passes through attributes
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Conditional rendering (only shows if status exists)
|
|
||||||
- Green success styling
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@props([
|
|
||||||
'status',
|
|
||||||
])
|
|
||||||
|
|
||||||
@if ($status)
|
|
||||||
<div {{ $attributes->merge(['class' => 'font-medium text-sm text-green-600']) }}>
|
|
||||||
{{ $status }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. `x-desktop-user-menu`
|
|
||||||
**Path:** `resources/views/components/desktop-user-menu.blade.php`
|
|
||||||
|
|
||||||
User profile dropdown menu with settings and logout options. Uses Flux components.
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Sidebar profile with dropdown
|
|
||||||
- Shows user avatar, name, and email
|
|
||||||
- Menu items: Settings (cog icon), Logout (arrow icon)
|
|
||||||
- Logout form with CSRF protection
|
|
||||||
- Data test attributes for testing
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<flux:dropdown position="bottom" align="start">
|
|
||||||
<flux:sidebar.profile
|
|
||||||
:name="auth()->user()->name"
|
|
||||||
:initials="auth()->user()->initials()"
|
|
||||||
icon:trailing="chevrons-up-down"
|
|
||||||
data-test="sidebar-menu-button"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<flux:menu>
|
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-start text-sm">
|
|
||||||
<flux:avatar
|
|
||||||
:name="auth()->user()->name"
|
|
||||||
:initials="auth()->user()->initials()"
|
|
||||||
/>
|
|
||||||
<div class="grid flex-1 text-start text-sm leading-tight">
|
|
||||||
<flux:heading class="truncate">{{ auth()->user()->name }}</flux:heading>
|
|
||||||
<flux:text class="truncate">{{ auth()->user()->email }}</flux:text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<flux:menu.separator />
|
|
||||||
<flux:menu.radio.group>
|
|
||||||
<flux:menu.item :href="route('profile.edit')" icon="cog" wire:navigate>
|
|
||||||
{{ __('Settings') }}
|
|
||||||
</flux:menu.item>
|
|
||||||
<form method="POST" action="{{ route('logout') }}" class="w-full">
|
|
||||||
@csrf
|
|
||||||
<flux:menu.item
|
|
||||||
as="button"
|
|
||||||
type="submit"
|
|
||||||
icon="arrow-right-start-on-rectangle"
|
|
||||||
class="w-full cursor-pointer"
|
|
||||||
data-test="logout-button"
|
|
||||||
>
|
|
||||||
{{ __('Log out') }}
|
|
||||||
</flux:menu.item>
|
|
||||||
</form>
|
|
||||||
</flux:menu.radio.group>
|
|
||||||
</flux:menu>
|
|
||||||
</flux:dropdown>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. `x-placeholder-pattern`
|
|
||||||
**Path:** `resources/views/components/placeholder-pattern.blade.php`
|
|
||||||
|
|
||||||
SVG placeholder pattern for skeleton/loading states. Diagonal line pattern.
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `id`: Unique pattern ID (auto-generated)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Reusable SVG pattern definition
|
|
||||||
- Can be styled with Tailwind stroke classes
|
|
||||||
- Used in dashboard placeholder cards
|
|
||||||
|
|
||||||
```blade
|
|
||||||
@props([
|
|
||||||
'id' => uniqid(),
|
|
||||||
])
|
|
||||||
|
|
||||||
<svg {{ $attributes }} fill="none">
|
|
||||||
<defs>
|
|
||||||
<pattern id="pattern-{{ $id }}" x="0" y="0" width="8" height="8" patternUnits="userSpaceOnUse">
|
|
||||||
<path d="M-1 5L5 -1M3 9L8.5 3.5" stroke-width="0.5"></path>
|
|
||||||
</pattern>
|
|
||||||
</defs>
|
|
||||||
<rect stroke="none" fill="url(#pattern-{{ $id }})" width="100%" height="100%"></rect>
|
|
||||||
</svg>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flux UI Components Used
|
|
||||||
|
|
||||||
Flux v2 components extensively used throughout the application:
|
|
||||||
|
|
||||||
- **Layout:** `flux:header`, `flux:sidebar`, `flux:main`, `flux:spacer`
|
|
||||||
- **Navigation:** `flux:navbar`, `flux:navbar.item`, `flux:sidebar.nav`, `flux:sidebar.item`, `flux:sidebar.group`
|
|
||||||
- **Forms:** `flux:input`, `flux:select`, `flux:checkbox`, `flux:button`
|
|
||||||
- **Typography:** `flux:heading`, `flux:subheading`, `flux:text`
|
|
||||||
- **Dropdowns/Menus:** `flux:dropdown`, `flux:menu`, `flux:menu.item`, `flux:menu.separator`
|
|
||||||
- **UI Elements:** `flux:badge`, `flux:avatar`, `flux:brand`, `flux:profile`
|
|
||||||
- **Other:** `flux:tooltip`, `flux:separator`, `flux:link`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flux Custom Icons
|
|
||||||
|
|
||||||
Located in `resources/views/flux/icon/`:
|
|
||||||
- `layout-grid.blade.php`
|
|
||||||
- `folder-git-2.blade.php`
|
|
||||||
- `chevrons-up-down.blade.php`
|
|
||||||
- `book-open-text.blade.php`
|
|
||||||
|
|
||||||
These extend Flux's default icon library.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Alpine.js Data Objects
|
|
||||||
|
|
||||||
### `stationMap`
|
|
||||||
**Path:** `resources/js/maps/station-map.js`
|
|
||||||
|
|
||||||
Leaflet map integration for displaying fuel stations:
|
|
||||||
- Initializes map centered on UK
|
|
||||||
- Plots colored circle markers for stations
|
|
||||||
- Color coding: green (current), slate (recent), amber (stale), red (outdated)
|
|
||||||
- Popup display with station details
|
|
||||||
- Responsive bounds fitting
|
|
||||||
- Watches for data changes via Alpine
|
|
||||||
|
|
||||||
@@ -1,494 +0,0 @@
|
|||||||
# Design System
|
|
||||||
|
|
||||||
Comprehensive documentation of the design system, visual language, and brand identity for the Fuel Price application.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Brand Identity
|
|
||||||
|
|
||||||
### App Name
|
|
||||||
"Fuel Price" (Laravel Starter Kit internally)
|
|
||||||
|
|
||||||
### Logo
|
|
||||||
- **Style:** Geometric, modern
|
|
||||||
- **Colors:** Adapts to light/dark mode
|
|
||||||
- **Variants:** Header logo (wider), Sidebar logo (square icon)
|
|
||||||
- **Usage:** All pages in navigation
|
|
||||||
|
|
||||||
### Design Philosophy
|
|
||||||
- Clean, minimal aesthetic
|
|
||||||
- Dark-first design (dark mode as primary)
|
|
||||||
- Accessibility-focused
|
|
||||||
- Data visualization emphasis (maps, charts)
|
|
||||||
- Utility-oriented interface
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Color System
|
|
||||||
|
|
||||||
### Primary Palette - Zinc (Neutral)
|
|
||||||
|
|
||||||
Used as base colors for UI, text, and borders.
|
|
||||||
|
|
||||||
```
|
|
||||||
Zinc-50: #fafafa (Lightest backgrounds)
|
|
||||||
Zinc-100: #f5f5f5 (Light backgrounds)
|
|
||||||
Zinc-200: #e5e5e5 (Light borders, dividers)
|
|
||||||
Zinc-300: #d4d4d4 (Subtle borders)
|
|
||||||
Zinc-400: #a3a3a3 (Placeholder text)
|
|
||||||
Zinc-500: #737373 (Secondary text)
|
|
||||||
Zinc-600: #525252 (Body text)
|
|
||||||
Zinc-700: #404040 (Dark text)
|
|
||||||
Zinc-800: #262626 (Page backgrounds dark)
|
|
||||||
Zinc-900: #171717 (Header/sidebar backgrounds)
|
|
||||||
Zinc-950: #0a0a0a (Darkest)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Semantic Colors
|
|
||||||
|
|
||||||
#### Success
|
|
||||||
- **Primary:** `#22c55e` (Green-500)
|
|
||||||
- **Dark:** `#16a34a` (Green-600)
|
|
||||||
- **Light:** `#86efac` (Green-300)
|
|
||||||
- **Usage:** Success messages, positive indicators, current/fresh data
|
|
||||||
|
|
||||||
#### Warning
|
|
||||||
- **Primary:** `#f59e0b` (Amber-500)
|
|
||||||
- **Light:** `#fbbf24` (Amber-400)
|
|
||||||
- **Usage:** Stale data, deprecation notices
|
|
||||||
|
|
||||||
#### Error/Danger
|
|
||||||
- **Primary:** `#ef4444` (Red-500)
|
|
||||||
- **Dark:** `#dc2626` (Red-600)
|
|
||||||
- **Light:** `#fca5a5` (Red-300)
|
|
||||||
- **Usage:** Outdated data, error messages, destructive actions
|
|
||||||
|
|
||||||
#### Info
|
|
||||||
- **Primary:** `#64748b` (Slate-500)
|
|
||||||
- **Usage:** Neutral information, recent data
|
|
||||||
|
|
||||||
### Accent Colors
|
|
||||||
|
|
||||||
**Light Mode:**
|
|
||||||
- Accent: `--color-neutral-800` (dark gray)
|
|
||||||
- Accent Content: `--color-neutral-800`
|
|
||||||
- Accent Foreground: `--color-white`
|
|
||||||
|
|
||||||
**Dark Mode:**
|
|
||||||
- Accent: `--color-white`
|
|
||||||
- Accent Content: `--color-white`
|
|
||||||
- Accent Foreground: `--color-neutral-800`
|
|
||||||
|
|
||||||
Used for:
|
|
||||||
- Primary buttons
|
|
||||||
- Active navigation items
|
|
||||||
- Focus states
|
|
||||||
- Primary CTAs
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Typography System
|
|
||||||
|
|
||||||
### Typeface
|
|
||||||
|
|
||||||
**Primary Font:** Instrument Sans
|
|
||||||
- **Source:** Google Fonts (fonts.bunny.net)
|
|
||||||
- **Weights Available:** 400 (Regular), 500 (Medium), 600 (Semibold)
|
|
||||||
- **Category:** Sans-serif, Humanist
|
|
||||||
- **Use Case:** All body text, headings, UI labels
|
|
||||||
|
|
||||||
**Fallback Stack:**
|
|
||||||
```
|
|
||||||
'Instrument Sans',
|
|
||||||
ui-sans-serif,
|
|
||||||
system-ui,
|
|
||||||
sans-serif,
|
|
||||||
'Apple Color Emoji',
|
|
||||||
'Segoe UI Emoji',
|
|
||||||
'Segoe UI Symbol',
|
|
||||||
'Noto Color Emoji'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Font Sizes & Hierarchy
|
|
||||||
|
|
||||||
Via Flux components (sizes managed by Flux):
|
|
||||||
|
|
||||||
- **flux:heading size="xl":** Page titles (largest)
|
|
||||||
- **flux:heading size="lg":** Section titles
|
|
||||||
- **flux:heading:** Standard headings
|
|
||||||
- **flux:subheading:** Secondary headings, descriptions
|
|
||||||
- **flux:text:** Body text
|
|
||||||
- **flux:label:** Form labels, captions
|
|
||||||
- **flux:badge:** Small labels, tags
|
|
||||||
|
|
||||||
### Line Heights
|
|
||||||
|
|
||||||
- **Tight:** Labels, badges (`leading-tight`)
|
|
||||||
- **Normal:** Body text, headings (default)
|
|
||||||
- **Relaxed:** Long-form content (when needed)
|
|
||||||
|
|
||||||
### Weight Distribution
|
|
||||||
|
|
||||||
- **400 (Regular):** Body text, regular content
|
|
||||||
- **500 (Medium):** Secondary headings, strong emphasis, labels
|
|
||||||
- **600 (Semibold):** Primary headings, buttons, strong emphasis
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spacing & Layout
|
|
||||||
|
|
||||||
### Spacing Scale
|
|
||||||
|
|
||||||
Based on 4px base unit:
|
|
||||||
|
|
||||||
```
|
|
||||||
0: 0px
|
|
||||||
0.5: 2px
|
|
||||||
1: 4px
|
|
||||||
1.5: 6px
|
|
||||||
2: 8px
|
|
||||||
2.5: 10px
|
|
||||||
3: 12px
|
|
||||||
3.5: 14px
|
|
||||||
4: 16px
|
|
||||||
5: 20px
|
|
||||||
6: 24px
|
|
||||||
7: 28px
|
|
||||||
8: 32px
|
|
||||||
9: 36px
|
|
||||||
10: 40px
|
|
||||||
11: 44px
|
|
||||||
12: 48px
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Spacing Patterns
|
|
||||||
|
|
||||||
- **Gap between elements:** `gap-3` (12px), `gap-4` (16px), `gap-6` (24px)
|
|
||||||
- **Form field spacing:** `gap-2` (8px) for label + input
|
|
||||||
- **Card padding:** `p-4` (16px) for compact, `p-6` (24px) for default, `p-10` (40px) for large
|
|
||||||
- **Horizontal padding:** `px-4` (16px) standard
|
|
||||||
- **Vertical padding:** `py-3` (12px) standard
|
|
||||||
|
|
||||||
### Grid System
|
|
||||||
|
|
||||||
- Flex-based layouts (no CSS Grid for most components)
|
|
||||||
- Default gap: `gap-4` (16px)
|
|
||||||
- Settings page: 2-column on desktop (220px sidebar + flex content)
|
|
||||||
- Station results: 1-column list, single-level nesting
|
|
||||||
|
|
||||||
### Responsive Breakpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
Default (mobile): 0px
|
|
||||||
sm: 640px (@media max-width: 639px = mobile)
|
|
||||||
md: 768px (tablet)
|
|
||||||
lg: 1024px (desktop)
|
|
||||||
xl: 1280px (large desktop)
|
|
||||||
2xl: 1536px (extra large)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Breakpoint Patterns:**
|
|
||||||
- `max-lg:` - Mobile and tablet
|
|
||||||
- `lg:` - Desktop and larger
|
|
||||||
- `max-md:` - Mobile only
|
|
||||||
- `md:` - Tablet and larger
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Border & Radius
|
|
||||||
|
|
||||||
### Border Weight
|
|
||||||
|
|
||||||
- **Default:** 1px (all borders)
|
|
||||||
- **Emphasis:** None (single weight used)
|
|
||||||
|
|
||||||
### Border Radius
|
|
||||||
|
|
||||||
- **Small:** `rounded-md` (6-8px) - Icons, small buttons, badges
|
|
||||||
- **Medium:** `rounded-lg` (8-10px) - Inputs, dropdowns
|
|
||||||
- **Large:** `rounded-xl` (12-16px) - Cards, modals, large components
|
|
||||||
- **None:** `rounded-none` - Rarely used
|
|
||||||
|
|
||||||
### Border Colors
|
|
||||||
|
|
||||||
**Light Mode:**
|
|
||||||
- Default border: `border-zinc-200`
|
|
||||||
- Focus ring: `ring-accent` (accent color)
|
|
||||||
|
|
||||||
**Dark Mode:**
|
|
||||||
- Default border: `border-zinc-700`
|
|
||||||
- Card/modal: `border-stone-800` or `border-neutral-800`
|
|
||||||
- Focus ring: `ring-accent` with `ring-offset-2` and `ring-offset-accent-foreground`
|
|
||||||
|
|
||||||
### Focus States
|
|
||||||
|
|
||||||
All interactive elements have:
|
|
||||||
- `ring-2 ring-accent` (focus ring)
|
|
||||||
- `ring-offset-2` (space between element and ring)
|
|
||||||
- `ring-offset-accent-foreground` (offset background color)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Patterns
|
|
||||||
|
|
||||||
### Form Patterns
|
|
||||||
|
|
||||||
All form elements follow consistent structure:
|
|
||||||
1. Label (top)
|
|
||||||
2. Input/Select/Textarea
|
|
||||||
3. Error message (bottom, conditional)
|
|
||||||
4. Helper text (optional)
|
|
||||||
|
|
||||||
**Spacing:** `gap-2` between label and input
|
|
||||||
|
|
||||||
**States:**
|
|
||||||
- Normal: default appearance
|
|
||||||
- Focus: ring around element
|
|
||||||
- Disabled: opacity/pointer-events disabled
|
|
||||||
- Error: red text below
|
|
||||||
|
|
||||||
### Button Patterns
|
|
||||||
|
|
||||||
**Variants:**
|
|
||||||
- `variant="primary"` - Primary action (accent color background)
|
|
||||||
- `variant="secondary"` - Secondary action
|
|
||||||
- `variant="danger"` - Destructive action (red)
|
|
||||||
|
|
||||||
**States:**
|
|
||||||
- Normal: clickable
|
|
||||||
- Hover: slight opacity increase
|
|
||||||
- Disabled: `disabled` attribute via `wire:loading.attr="disabled"`
|
|
||||||
- Loading: show spinner or text change
|
|
||||||
|
|
||||||
### Card Patterns
|
|
||||||
|
|
||||||
Standard card styling:
|
|
||||||
- Border: `border border-zinc-200 dark:border-zinc-700`
|
|
||||||
- Radius: `rounded-xl`
|
|
||||||
- Background: white (light) / dark (dark mode)
|
|
||||||
- Padding: `p-4` to `p-10` depending on content density
|
|
||||||
- Shadow: `shadow-xs` for subtle lift (optional)
|
|
||||||
|
|
||||||
### Navigation Patterns
|
|
||||||
|
|
||||||
**Sidebar Navigation:**
|
|
||||||
- `flux:sidebar.nav` wrapper
|
|
||||||
- `flux:sidebar.group` for sections (with heading)
|
|
||||||
- `flux:sidebar.item` for links
|
|
||||||
- Active state: current attribute
|
|
||||||
- Icon on left, text on right
|
|
||||||
|
|
||||||
**Navbar Navigation:**
|
|
||||||
- `flux:navbar` wrapper
|
|
||||||
- `flux:navbar.item` for links
|
|
||||||
- Icons only or icon + text
|
|
||||||
- Horizontal layout
|
|
||||||
- Tooltip support
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Visualization
|
|
||||||
|
|
||||||
### Station Search Map Colors (Leaflet)
|
|
||||||
|
|
||||||
Classification-based color coding for fuel price data freshness:
|
|
||||||
|
|
||||||
- **Current (< 24h):** `#22c55e` (Green-500)
|
|
||||||
- **Recent (24-48h):** `#64748b` (Slate-500)
|
|
||||||
- **Stale (2-5 days):** `#f59e0b` (Amber-500)
|
|
||||||
- **Outdated (5+ days):** `#ef4444` (Red-500)
|
|
||||||
|
|
||||||
### Map Markers
|
|
||||||
|
|
||||||
- **Shape:** Circle
|
|
||||||
- **Radius:** 9px
|
|
||||||
- **Stroke:** White, 2px weight
|
|
||||||
- **Fill:** Classification color
|
|
||||||
- **Opacity:** 85% (0.85)
|
|
||||||
- **Click:** Shows popup with station details
|
|
||||||
|
|
||||||
### Data Table Patterns
|
|
||||||
|
|
||||||
(No explicit tables in current design, but pattern would be):
|
|
||||||
- Striped rows (alternate bg)
|
|
||||||
- Hover states for interactivity
|
|
||||||
- Clear column alignment
|
|
||||||
- Sortable headers
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Animations & Transitions
|
|
||||||
|
|
||||||
### Page Transitions
|
|
||||||
|
|
||||||
All navigation via `wire:navigate` (no full page reload):
|
|
||||||
- Seamless SPA-like experience
|
|
||||||
- Preserves scroll position
|
|
||||||
- No loading screen between pages
|
|
||||||
|
|
||||||
### Component Transitions
|
|
||||||
|
|
||||||
**Alpine.js Transitions:**
|
|
||||||
- Fade on message dismiss: `x-show.transition.out.opacity.duration.1500ms`
|
|
||||||
- Duration: 1500ms (1.5 seconds)
|
|
||||||
- Effect: Opacity fade
|
|
||||||
|
|
||||||
**Flux Built-in:**
|
|
||||||
- Dropdown/menu opens: instant or quick fade
|
|
||||||
- Sidebar collapse: smooth width transition
|
|
||||||
- Modal appears: fade + scale (typical Flux defaults)
|
|
||||||
|
|
||||||
### Hover & Active States
|
|
||||||
|
|
||||||
- Links: `hover:underline` (default Flux)
|
|
||||||
- Buttons: Opacity change on hover
|
|
||||||
- Navigation items: Background highlight on hover/active
|
|
||||||
- Interactive elements: Subtle color shift
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Accessibility
|
|
||||||
|
|
||||||
### Color Contrast
|
|
||||||
|
|
||||||
- Text on backgrounds meets WCAG AA standards (4.5:1 for normal text)
|
|
||||||
- Semantic colors (green/red) supplemented with icons/patterns
|
|
||||||
- No color-only indicators
|
|
||||||
|
|
||||||
### Interactive Elements
|
|
||||||
|
|
||||||
- Keyboard navigable (all Flux components)
|
|
||||||
- Focus indicators visible (`ring-2 ring-accent`)
|
|
||||||
- Touch-friendly sizing (minimum 44x44px recommended)
|
|
||||||
- ARIA labels where needed
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
|
|
||||||
- Font sizes readable at standard distances
|
|
||||||
- Line height adequate for readability
|
|
||||||
- High contrast between text and background
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dark Mode
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
- **Method:** `class="dark"` on HTML root
|
|
||||||
- **Toggle:** Managed by `@fluxAppearance` directive
|
|
||||||
- **Detection:** Optional prefers-color-scheme integration
|
|
||||||
|
|
||||||
### Dark Mode Colors
|
|
||||||
|
|
||||||
**Text:**
|
|
||||||
- Primary: `text-zinc-100` (light gray)
|
|
||||||
- Secondary: `text-zinc-400` (medium gray)
|
|
||||||
- Disabled: `text-zinc-500` (darker gray)
|
|
||||||
|
|
||||||
**Backgrounds:**
|
|
||||||
- Page: `bg-zinc-800`
|
|
||||||
- Header/Sidebar: `bg-zinc-900`
|
|
||||||
- Cards: `bg-stone-950` (darker variant)
|
|
||||||
- Inputs: `bg-zinc-900`
|
|
||||||
|
|
||||||
**Borders:**
|
|
||||||
- Primary: `border-zinc-700`
|
|
||||||
- Secondary: `border-stone-800`
|
|
||||||
- Subtle: `border-neutral-800`
|
|
||||||
|
|
||||||
**Accents:**
|
|
||||||
- Primary accent inverts: white (instead of dark gray)
|
|
||||||
|
|
||||||
### Image/SVG Handling in Dark
|
|
||||||
|
|
||||||
- Logo icon color inverts via `fill-current` and `text-*` classes
|
|
||||||
- Patterns use opacity: `stroke-neutral-100/20` (dark) vs `stroke-gray-900/20` (light)
|
|
||||||
- Leaflet map: Default OSM colors (already dark-friendly)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Responsive Behavior
|
|
||||||
|
|
||||||
### Mobile-First Approach
|
|
||||||
|
|
||||||
Design starts at mobile (smallest), enhances at larger breakpoints.
|
|
||||||
|
|
||||||
### Key Breakpoints
|
|
||||||
|
|
||||||
**Mobile (0-639px):**
|
|
||||||
- Single column layouts
|
|
||||||
- Hamburger menu (sidebar collapses)
|
|
||||||
- Stacked form fields
|
|
||||||
- Full-width cards
|
|
||||||
|
|
||||||
**Tablet (640-1023px):**
|
|
||||||
- Narrower multi-column (if applicable)
|
|
||||||
- Touch-friendly spacing
|
|
||||||
- Condensed headers
|
|
||||||
|
|
||||||
**Desktop (1024px+):**
|
|
||||||
- Multi-column layouts
|
|
||||||
- Horizontal navigation
|
|
||||||
- Sidebar persistent
|
|
||||||
- Full feature set visible
|
|
||||||
|
|
||||||
### Layout Adjustments
|
|
||||||
|
|
||||||
- Header: Hidden navbar items on mobile (`max-lg:hidden`)
|
|
||||||
- Sidebar: Becomes mobile hamburger at lg breakpoint
|
|
||||||
- Forms: Stack vertically on mobile, horizontal on desktop
|
|
||||||
- Grids: 1 column mobile, 2-3 columns desktop
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Consistency Patterns
|
|
||||||
|
|
||||||
### Consistent Component Usage
|
|
||||||
|
|
||||||
- All buttons: `flux:button` (never raw `<button>`)
|
|
||||||
- All inputs: `flux:input` (with validation/errors)
|
|
||||||
- All selects: `flux:select`
|
|
||||||
- All headings: `flux:heading` (sizes: xl, lg, etc.)
|
|
||||||
|
|
||||||
### Consistent Spacing
|
|
||||||
|
|
||||||
- Inter-element gaps: multiples of 4px
|
|
||||||
- Card padding: consistent with gap sizes
|
|
||||||
- Consistent use of flexbox for alignment
|
|
||||||
|
|
||||||
### Consistent States
|
|
||||||
|
|
||||||
- Forms show errors below input in red
|
|
||||||
- Success messages fade out after 2 seconds
|
|
||||||
- Disabled states use opacity + pointer-events
|
|
||||||
- Loading states shown via spinner or text change
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Notes
|
|
||||||
|
|
||||||
### CSS Custom Properties
|
|
||||||
|
|
||||||
```css
|
|
||||||
--font-sans: 'Instrument Sans', ...
|
|
||||||
--color-accent: (theme-dependent)
|
|
||||||
--color-accent-content: (theme-dependent)
|
|
||||||
--color-accent-foreground: (theme-dependent)
|
|
||||||
--color-zinc-{50-950}: (full palette)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tailwind Integration
|
|
||||||
|
|
||||||
- Uses Tailwind CSS v4 with `@tailwindcss/vite`
|
|
||||||
- `@theme` block defines custom colors and fonts
|
|
||||||
- `@custom-variant` for dark mode
|
|
||||||
- `@layer` for CSS overrides
|
|
||||||
|
|
||||||
### Flux Integration
|
|
||||||
|
|
||||||
- Heavy reliance on Flux v2 components
|
|
||||||
- Flux provides theming, dark mode, layout components
|
|
||||||
- Custom CSS only overrides specific Flux defaults
|
|
||||||
- No Flux config file needed (uses defaults + CSS)
|
|
||||||
|
|
||||||
@@ -1,386 +0,0 @@
|
|||||||
# Extractable UI Components
|
|
||||||
|
|
||||||
This document identifies reusable UI components that could be extracted, packaged, and reused across other projects.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Form Components
|
|
||||||
|
|
||||||
### 1. Search Input with Debounce
|
|
||||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 8-16)
|
|
||||||
**Category:** Forms
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `name`: Input field name
|
|
||||||
- `wire:model`: Livewire property binding
|
|
||||||
- `label`: Display label
|
|
||||||
- `placeholder`: Placeholder text
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Flux form styling
|
|
||||||
- Wire model binding support
|
|
||||||
- Error message display
|
|
||||||
|
|
||||||
**Extractable:** Yes - Generic search input wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Multi-Option Select Dropdown
|
|
||||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 20-28, 34-42, 44-52)
|
|
||||||
**Category:** Forms
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `wire:model.live` or `wire:model`: Binding
|
|
||||||
- `name`: Field name
|
|
||||||
- `label`: Label text
|
|
||||||
- Options as slot
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Reactive updates via `wire:model.live`
|
|
||||||
- Error display
|
|
||||||
- Multiple select options
|
|
||||||
|
|
||||||
**Variants:**
|
|
||||||
- Fuel type selector (6 options)
|
|
||||||
- Radius selector (5 options)
|
|
||||||
- Sort selector (5 options)
|
|
||||||
|
|
||||||
**Extractable:** Yes - Reusable select component wrapper
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Form Submission Button with Loading State
|
|
||||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 54-59)
|
|
||||||
**Category:** Forms
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `type`: button type
|
|
||||||
- `variant`: "primary", etc.
|
|
||||||
- `wire:loading.attr`: Disabled state during loading
|
|
||||||
- `wire:target`: Target action
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Dynamic text based on loading state
|
|
||||||
- Wire loading indicators
|
|
||||||
- Primary variant styling
|
|
||||||
|
|
||||||
**Extractable:** Yes - Generic loading button component
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Data Display Components
|
|
||||||
|
|
||||||
### 4. Station Result Card
|
|
||||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 94-128)
|
|
||||||
**Category:** Data Display
|
|
||||||
**Complexity:** Medium
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `station`: Object with station data
|
|
||||||
- `name`: Station name
|
|
||||||
- `address`: Street address
|
|
||||||
- `postcode`: Postcode
|
|
||||||
- `distance_km`: Distance in kilometers
|
|
||||||
- `price`: Fuel price in pence
|
|
||||||
- `price_classification`: One of (current, recent, stale, outdated)
|
|
||||||
- `price_classification_label`: Human label
|
|
||||||
- `price_updated_at`: ISO date string
|
|
||||||
- `is_supermarket`: Boolean
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Responsive layout (flex items-center justify-between)
|
|
||||||
- Color-coded price based on freshness
|
|
||||||
- Supermarket badge display
|
|
||||||
- Distance/address display
|
|
||||||
- Time-ago formatting
|
|
||||||
|
|
||||||
**Extractable:** Yes - Could be standalone component for displaying station data
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Results Count Summary
|
|
||||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 72-76)
|
|
||||||
**Category:** Data Display
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `count`: Total stations found
|
|
||||||
- `lowest_pence`: Lowest price in pence
|
|
||||||
- `avg_pence`: Average price in pence
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Formatted currency display
|
|
||||||
- Singular/plural handling
|
|
||||||
- Stats display on one line
|
|
||||||
|
|
||||||
**Extractable:** Yes - Generic stats summary component
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Classification Legend/Color Code Guide
|
|
||||||
**Source:** `resources/views/livewire/public/station-search.blade.php` (lines 84-90)
|
|
||||||
**Category:** Data Display
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- Array of classification items with colors and labels
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Inline color swatches
|
|
||||||
- Legend item labels
|
|
||||||
- Responsive flex layout
|
|
||||||
|
|
||||||
**Extractable:** Yes - Reusable legend component
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Navigation Components
|
|
||||||
|
|
||||||
### 7. Settings Sidebar Navigation
|
|
||||||
**Source:** `resources/views/pages/settings/layout.blade.php` (lines 3-7)
|
|
||||||
**Category:** Navigation
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- Navigation items array with:
|
|
||||||
- `label`: Display text
|
|
||||||
- `route`: Route name
|
|
||||||
- `icon`: Optional icon name
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- flux:navlist component
|
|
||||||
- wire:navigate support
|
|
||||||
- Active state detection
|
|
||||||
- Mobile/desktop responsive
|
|
||||||
|
|
||||||
**Extractable:** Yes - Generic sidebar nav for multi-section pages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. User Profile Dropdown Menu
|
|
||||||
**Source:** `resources/views/components/desktop-user-menu.blade.php`
|
|
||||||
**Category:** Navigation
|
|
||||||
**Complexity:** Medium
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- User object with:
|
|
||||||
- `name`: User name
|
|
||||||
- `email`: User email
|
|
||||||
- `initials()`: Method to get initials
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Flux dropdown + menu
|
|
||||||
- Avatar display with initials
|
|
||||||
- Settings link
|
|
||||||
- Logout form with CSRF
|
|
||||||
- Test attributes for QA
|
|
||||||
|
|
||||||
**Extractable:** Yes - Standalone user menu component for authenticated apps
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Layout Components
|
|
||||||
|
|
||||||
### 9. Centered Authentication Layout Container
|
|
||||||
**Source:** `resources/views/layouts/auth/simple.blade.php`
|
|
||||||
**Category:** Layouts
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- None (slot-based)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Centered flex layout
|
|
||||||
- Max-width constraint (sm)
|
|
||||||
- Dark gradient background
|
|
||||||
- Logo link at top
|
|
||||||
- Responsive padding
|
|
||||||
|
|
||||||
**Extractable:** Yes - Generic centered auth layout
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. Card-Based Form Container
|
|
||||||
**Source:** `resources/views/layouts/auth/card.blade.php`
|
|
||||||
**Category:** Layouts
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- None (slot-based)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- White card on dark background
|
|
||||||
- Rounded borders with shadow
|
|
||||||
- Dark mode support
|
|
||||||
- Centered with max-width
|
|
||||||
- Padding inside card
|
|
||||||
|
|
||||||
**Extractable:** Yes - Modal/card wrapper component
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 11. Split-Screen Auth Layout
|
|
||||||
**Source:** `resources/views/layouts/auth/split.blade.php`
|
|
||||||
**Category:** Layouts
|
|
||||||
**Complexity:** Medium
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- Quote display (generated from Inspiring::quotes)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Two-column grid (desktop only)
|
|
||||||
- Dark sidebar with quote/branding
|
|
||||||
- Form content on right
|
|
||||||
- Mobile-friendly (single column)
|
|
||||||
- Absolute background fill
|
|
||||||
|
|
||||||
**Extractable:** Yes - Premium auth layout component
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status/Feedback Components
|
|
||||||
|
|
||||||
### 12. Temporary Action Message
|
|
||||||
**Source:** `resources/views/components/action-message.blade.php`
|
|
||||||
**Category:** Feedback
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `on`: Livewire event to listen to
|
|
||||||
- Slot for custom message (defaults to "Saved.")
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Alpine.js event listener
|
|
||||||
- Auto-hide after 2 seconds
|
|
||||||
- Fade transition
|
|
||||||
- Livewire integration
|
|
||||||
|
|
||||||
**Extractable:** Yes - Generic toast/message component
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13. Session Status Message
|
|
||||||
**Source:** `resources/views/components/auth-session-status.blade.php`
|
|
||||||
**Category:** Feedback
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `status`: Status message text
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Conditional rendering
|
|
||||||
- Green success styling
|
|
||||||
- Used in auth forms
|
|
||||||
|
|
||||||
**Extractable:** Yes - Simple status display component
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Map/Visualization Components
|
|
||||||
|
|
||||||
### 14. Leaflet Map Integration
|
|
||||||
**Source:** `resources/js/maps/station-map.js`
|
|
||||||
**Category:** Map/Visualization
|
|
||||||
**Complexity:** High
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `results`: Array of station data with lat/lng/classifications
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Leaflet map initialization
|
|
||||||
- OpenStreetMap tiles
|
|
||||||
- Custom circle markers with colors
|
|
||||||
- Popup on marker click
|
|
||||||
- Auto-fit bounds
|
|
||||||
- Reactive to data changes
|
|
||||||
|
|
||||||
**Extractable:** Yes - Could be abstracted into reusable Leaflet component
|
|
||||||
|
|
||||||
**Note:** Currently tightly coupled to Alpine.js and station data structure. Would require prop mapping for reuse.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Typography/Header Components
|
|
||||||
|
|
||||||
### 15. Auth Page Header
|
|
||||||
**Source:** `resources/views/components/auth-header.blade.php`
|
|
||||||
**Category:** Typography
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- `title`: Main heading
|
|
||||||
- `description`: Subheading
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Centered text
|
|
||||||
- Flux typography (heading + subheading)
|
|
||||||
- Used on all auth pages
|
|
||||||
|
|
||||||
**Extractable:** Yes - Generic header for centered pages
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 16. Settings Page Header
|
|
||||||
**Source:** `resources/views/partials/settings-heading.blade.php`
|
|
||||||
**Category:** Typography
|
|
||||||
**Complexity:** Low
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
- None (hardcoded for settings)
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Page title "Settings"
|
|
||||||
- Subheading text
|
|
||||||
- Separator line
|
|
||||||
|
|
||||||
**Extractable:** Maybe - Specific to settings but pattern is reusable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reusability Summary Table
|
|
||||||
|
|
||||||
| Component | Type | Complexity | Reusable | Dependencies | Extraction Cost |
|
|
||||||
|-----------|------|-----------|----------|---|---|
|
|
||||||
| Search Input | Form | Low | High | Flux | Very Low |
|
|
||||||
| Select Dropdown | Form | Low | High | Flux | Very Low |
|
|
||||||
| Loading Button | Form | Low | High | Flux, Livewire | Very Low |
|
|
||||||
| Station Card | Display | Medium | High | Flux | Low |
|
|
||||||
| Stats Summary | Display | Low | High | None | Very Low |
|
|
||||||
| Legend | Display | Low | High | Tailwind | Very Low |
|
|
||||||
| Settings Nav | Nav | Low | High | Flux | Very Low |
|
|
||||||
| User Menu | Nav | Medium | High | Flux, Auth | Low |
|
|
||||||
| Simple Auth Layout | Layout | Low | High | Tailwind | Very Low |
|
|
||||||
| Card Container | Layout | Low | High | Tailwind | Very Low |
|
|
||||||
| Split Layout | Layout | Medium | High | Tailwind | Low |
|
|
||||||
| Action Message | Feedback | Low | High | Alpine, Livewire | Very Low |
|
|
||||||
| Status Message | Feedback | Low | High | None | Very Low |
|
|
||||||
| Leaflet Map | Visualization | High | Medium | Leaflet, Alpine | Medium |
|
|
||||||
| Auth Header | Typography | Low | High | Flux | Very Low |
|
|
||||||
| Settings Header | Typography | Low | Medium | Flux | Very Low |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Top Candidates for Extraction
|
|
||||||
|
|
||||||
1. **Search Input Component** - Generic, simple, widely useful
|
|
||||||
2. **Station Card Component** - Good showcase of complex data display
|
|
||||||
3. **User Menu Component** - Authentication pattern, widely needed
|
|
||||||
4. **Loading Button Component** - Form UX pattern, commonly needed
|
|
||||||
5. **Simple Auth Layout** - Authentication flows are common
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Framework Package Recommendations
|
|
||||||
|
|
||||||
If extracting these components into a package:
|
|
||||||
|
|
||||||
1. Make them framework-agnostic (or have adapters)
|
|
||||||
2. Ensure Flux/Livewire are peer dependencies
|
|
||||||
3. Document required Alpine.js versions
|
|
||||||
4. Provide TypeScript declarations for props
|
|
||||||
5. Include Storybook examples
|
|
||||||
6. Consider CSS-in-JS vs Tailwind integration
|
|
||||||
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# Layout Files
|
|
||||||
|
|
||||||
## App Layouts (For Authenticated Users)
|
|
||||||
|
|
||||||
### 1. `layouts/app.blade.php` (Main App Layout)
|
|
||||||
**Path:** `resources/views/layouts/app.blade.php`
|
|
||||||
|
|
||||||
Wrapper layout that delegates to sidebar layout. Used by authenticated routes.
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<x-layouts::app.sidebar :title="$title ?? null">
|
|
||||||
<flux:main>
|
|
||||||
{{ $slot }}
|
|
||||||
</flux:main>
|
|
||||||
</x-layouts::app.sidebar>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. `layouts/app/sidebar.blade.php` (Sidebar Layout with Header/Navigation)
|
|
||||||
**Path:** `resources/views/layouts/app/sidebar.blade.php`
|
|
||||||
|
|
||||||
Core authenticated layout with sidebar navigation and header. Renders app header on desktop, mobile sidebar.
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Dark mode support (HTML class="dark")
|
|
||||||
- Responsive: Sidebar on desktop, hamburger on mobile
|
|
||||||
- Desktop navbar with Dashboard link
|
|
||||||
- Mobile header with sidebar toggle
|
|
||||||
- Search, Repository, and Documentation links
|
|
||||||
- User menu in header (desktop) and mobile sidebar
|
|
||||||
- Uses Flux components extensively
|
|
||||||
|
|
||||||
**Structure:**
|
|
||||||
- HTML/head with partials.head include
|
|
||||||
- Body with min-h-screen, dark mode bg colors
|
|
||||||
- flux:header (desktop) with:
|
|
||||||
- Sidebar toggle (mobile only)
|
|
||||||
- App logo
|
|
||||||
- Desktop navbar with links
|
|
||||||
- Spacer
|
|
||||||
- Secondary navbar (Search, Repo, Docs icons)
|
|
||||||
- Desktop user menu
|
|
||||||
- flux:sidebar (mobile) with:
|
|
||||||
- App logo
|
|
||||||
- Sidebar collapse button
|
|
||||||
- Navigation group (Platform section)
|
|
||||||
- External links (Repo, Docs)
|
|
||||||
- flux:main slot for page content
|
|
||||||
- @fluxScripts directive at end
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
|
||||||
<head>
|
|
||||||
@include('partials.head')
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-white dark:bg-zinc-800">
|
|
||||||
<flux:header container class="border-b border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<flux:sidebar.toggle class="lg:hidden mr-2" icon="bars-2" inset="left" />
|
|
||||||
|
|
||||||
<x-app-logo href="{{ route('dashboard') }}" wire:navigate />
|
|
||||||
|
|
||||||
<flux:navbar class="-mb-px max-lg:hidden">
|
|
||||||
<flux:navbar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
|
|
||||||
{{ __('Dashboard') }}
|
|
||||||
</flux:navbar.item>
|
|
||||||
</flux:navbar>
|
|
||||||
|
|
||||||
<flux:spacer />
|
|
||||||
|
|
||||||
<flux:navbar class="me-1.5 space-x-0.5 rtl:space-x-reverse py-0!">
|
|
||||||
<flux:tooltip :content="__('Search')" position="bottom">
|
|
||||||
<flux:navbar.item class="!h-10 [&>div>svg]:size-5" icon="magnifying-glass" href="#" :label="__('Search')" />
|
|
||||||
</flux:tooltip>
|
|
||||||
<flux:tooltip :content="__('Repository')" position="bottom">
|
|
||||||
<flux:navbar.item
|
|
||||||
class="h-10 max-lg:hidden [&>div>svg]:size-5"
|
|
||||||
icon="folder-git-2"
|
|
||||||
href="https://github.com/laravel/livewire-starter-kit"
|
|
||||||
target="_blank"
|
|
||||||
:label="__('Repository')"
|
|
||||||
/>
|
|
||||||
</flux:tooltip>
|
|
||||||
<flux:tooltip :content="__('Documentation')" position="bottom">
|
|
||||||
<flux:navbar.item
|
|
||||||
class="h-10 max-lg:hidden [&>div>svg]:size-5"
|
|
||||||
icon="book-open-text"
|
|
||||||
href="https://laravel.com/docs/starter-kits#livewire"
|
|
||||||
target="_blank"
|
|
||||||
:label="__('Documentation')"
|
|
||||||
/>
|
|
||||||
</flux:tooltip>
|
|
||||||
</flux:navbar>
|
|
||||||
|
|
||||||
<x-desktop-user-menu />
|
|
||||||
</flux:header>
|
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
|
||||||
<flux:sidebar collapsible="mobile" sticky class="lg:hidden border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
|
||||||
<flux:sidebar.header>
|
|
||||||
<x-app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate />
|
|
||||||
<flux:sidebar.collapse class="in-data-flux-sidebar-on-desktop:not-in-data-flux-sidebar-collapsed-desktop:-mr-2" />
|
|
||||||
</flux:sidebar.header>
|
|
||||||
|
|
||||||
<flux:sidebar.nav>
|
|
||||||
<flux:sidebar.group :heading="__('Platform')">
|
|
||||||
<flux:sidebar.item icon="layout-grid" :href="route('dashboard')" :current="request()->routeIs('dashboard')" wire:navigate>
|
|
||||||
{{ __('Dashboard') }}
|
|
||||||
</flux:sidebar.item>
|
|
||||||
</flux:sidebar.group>
|
|
||||||
</flux:sidebar.nav>
|
|
||||||
|
|
||||||
<flux:spacer />
|
|
||||||
|
|
||||||
<flux:sidebar.nav>
|
|
||||||
<flux:sidebar.item icon="folder-git-2" href="https://github.com/laravel/livewire-starter-kit" target="_blank">
|
|
||||||
{{ __('Repository') }}
|
|
||||||
</flux:sidebar.item>
|
|
||||||
<flux:sidebar.item icon="book-open-text" href="https://laravel.com/docs/starter-kits#livewire" target="_blank">
|
|
||||||
{{ __('Documentation') }}
|
|
||||||
</flux:sidebar.item>
|
|
||||||
</flux:sidebar.nav>
|
|
||||||
</flux:sidebar>
|
|
||||||
|
|
||||||
{{ $slot }}
|
|
||||||
|
|
||||||
@fluxScripts
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Auth Layouts (For Unauthenticated Users)
|
|
||||||
|
|
||||||
### 3. `layouts/auth.blade.php` (Auth Wrapper)
|
|
||||||
**Path:** `resources/views/layouts/auth.blade.php`
|
|
||||||
|
|
||||||
Simple wrapper that delegates to simple layout. Used for auth pages (login, register, password reset, etc.).
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<x-layouts::auth.simple :title="$title ?? null">
|
|
||||||
{{ $slot }}
|
|
||||||
</x-layouts::auth.simple>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `layouts/auth/simple.blade.php` (Simple Auth Layout - Centered)
|
|
||||||
**Path:** `resources/views/layouts/auth/simple.blade.php`
|
|
||||||
|
|
||||||
Minimalist centered layout for authentication. Used for login, register, etc.
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Dark mode with gradient background (dark:from-neutral-950 to-neutral-900)
|
|
||||||
- Centered content with max-width constraint
|
|
||||||
- App logo centered with link to home
|
|
||||||
- Dark mode gradient background
|
|
||||||
- Flex column centered layout
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
|
||||||
<head>
|
|
||||||
@include('partials.head')
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
|
||||||
<div class="bg-background flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
|
||||||
<div class="flex w-full max-w-sm flex-col gap-2">
|
|
||||||
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
|
||||||
<span class="flex h-9 w-9 mb-1 items-center justify-center rounded-md">
|
|
||||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
|
||||||
</span>
|
|
||||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
|
||||||
</a>
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@fluxScripts
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. `layouts/auth/card.blade.php` (Card-based Auth Layout)
|
|
||||||
**Path:** `resources/views/layouts/auth/card.blade.php`
|
|
||||||
|
|
||||||
Card-based authentication layout with rounded borders and shadow. Alternative to simple layout.
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Centered card with white background and dark border
|
|
||||||
- Max-width constraint
|
|
||||||
- Padding inside card (px-10 py-8)
|
|
||||||
- Light neutral-100 background
|
|
||||||
- Dark mode: stone-950 card bg with stone-800 border
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
|
||||||
<head>
|
|
||||||
@include('partials.head')
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-neutral-100 antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
|
||||||
<div class="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
|
|
||||||
<div class="flex w-full max-w-md flex-col gap-6">
|
|
||||||
<a href="{{ route('home') }}" class="flex flex-col items-center gap-2 font-medium" wire:navigate>
|
|
||||||
<span class="flex h-9 w-9 items-center justify-center rounded-md">
|
|
||||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
|
||||||
<div class="rounded-xl border bg-white dark:bg-stone-950 dark:border-stone-800 text-stone-800 shadow-xs">
|
|
||||||
<div class="px-10 py-8">{{ $slot }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@fluxScripts
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. `layouts/auth/split.blade.php` (Split-screen Auth Layout)
|
|
||||||
**Path:** `resources/views/layouts/auth/split.blade.php`
|
|
||||||
|
|
||||||
Split-screen layout with marketing content on left (desktop only) and form on right.
|
|
||||||
|
|
||||||
**Key Features:**
|
|
||||||
- Two-column layout on desktop (lg:grid-cols-2)
|
|
||||||
- Left side: Dark background (neutral-900) with motivational quote (hidden on mobile)
|
|
||||||
- Right side: Form content
|
|
||||||
- Logo centered on mobile, top-left on desktop (left side)
|
|
||||||
- Quote display with blockquote
|
|
||||||
- Uses Flux heading component
|
|
||||||
- Full viewport height (h-dvh)
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
|
|
||||||
<head>
|
|
||||||
@include('partials.head')
|
|
||||||
</head>
|
|
||||||
<body class="min-h-screen bg-white antialiased dark:bg-linear-to-b dark:from-neutral-950 dark:to-neutral-900">
|
|
||||||
<div class="relative grid h-dvh flex-col items-center justify-center px-8 sm:px-0 lg:max-w-none lg:grid-cols-2 lg:px-0">
|
|
||||||
<div class="bg-muted relative hidden h-full flex-col p-10 text-white lg:flex dark:border-e dark:border-neutral-800">
|
|
||||||
<div class="absolute inset-0 bg-neutral-900"></div>
|
|
||||||
<a href="{{ route('home') }}" class="relative z-20 flex items-center text-lg font-medium" wire:navigate>
|
|
||||||
<span class="flex h-10 w-10 items-center justify-center rounded-md">
|
|
||||||
<x-app-logo-icon class="me-2 h-7 fill-current text-white" />
|
|
||||||
</span>
|
|
||||||
{{ config('app.name', 'Laravel') }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
@php
|
|
||||||
[$message, $author] = str(Illuminate\Foundation\Inspiring::quotes()->random())->explode('-');
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<div class="relative z-20 mt-auto">
|
|
||||||
<blockquote class="space-y-2">
|
|
||||||
<flux:heading size="lg">“{{ trim($message) }}”</flux:heading>
|
|
||||||
<footer><flux:heading>{{ trim($author) }}</flux:heading></footer>
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="w-full lg:p-8">
|
|
||||||
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
|
||||||
<a href="{{ route('home') }}" class="z-20 flex flex-col items-center gap-2 font-medium lg:hidden" wire:navigate>
|
|
||||||
<span class="flex h-9 w-9 items-center justify-center rounded-md">
|
|
||||||
<x-app-logo-icon class="size-9 fill-current text-black dark:text-white" />
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="sr-only">{{ config('app.name', 'Laravel') }}</span>
|
|
||||||
</a>
|
|
||||||
{{ $slot }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@fluxScripts
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Head/Partial Layouts
|
|
||||||
|
|
||||||
### 7. `partials/head.blade.php`
|
|
||||||
**Path:** `resources/views/partials/head.blade.php`
|
|
||||||
|
|
||||||
Shared head section included in all layouts.
|
|
||||||
|
|
||||||
**Content:**
|
|
||||||
- Meta charset and viewport
|
|
||||||
- Dynamic title generation
|
|
||||||
- Favicon setup (ico, svg, apple-touch-icon)
|
|
||||||
- Fonts: Instrument Sans from fonts.bunny.net (weights 400, 500, 600)
|
|
||||||
- Vite asset loading (CSS and JS)
|
|
||||||
- Flux appearance directive (dark mode toggle support)
|
|
||||||
|
|
||||||
```blade
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
|
|
||||||
<title>
|
|
||||||
{{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }}
|
|
||||||
</title>
|
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
|
||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
|
||||||
|
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
|
||||||
@fluxAppearance
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Usage Pattern
|
|
||||||
|
|
||||||
| Route | Layout Chain | Use Case |
|
|
||||||
|-------|-----------|----------|
|
|
||||||
| `/` (home) | Custom (not using these layouts) | Landing page |
|
|
||||||
| `/dashboard` | `layouts/app` → `layouts/app/sidebar` | Authenticated user dashboard |
|
|
||||||
| `/login`, `/register` | `layouts/auth` → `layouts/auth/simple` | Simple auth forms |
|
|
||||||
| `/stations` | StationSearch Livewire component (no layout wrapper) | Public search page |
|
|
||||||
| `/settings/*` | `layouts/app` → `layouts/app/sidebar` | Settings pages |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Color Scheme in Layouts
|
|
||||||
|
|
||||||
- **Light Mode:** white background, zinc borders
|
|
||||||
- **Dark Mode:** zinc-800 body, zinc-900 header/sidebar, zinc-700 borders
|
|
||||||
- **Auth Pages (Dark):** linear gradient from neutral-950 to neutral-900
|
|
||||||
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
# Pages & Full-Page Components
|
|
||||||
|
|
||||||
## Page Dependency Trees
|
|
||||||
|
|
||||||
Pages are organized by their view location and dependencies. Livewire components with full-page routes are documented here.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. StationSearch (Public Livewire Component)
|
|
||||||
**Route:** `/stations`
|
|
||||||
**Component:** `App\Livewire\Public\StationSearch`
|
|
||||||
**View:** `resources/views/livewire/public/station-search.blade.php`
|
|
||||||
|
|
||||||
### Dependency Tree
|
|
||||||
|
|
||||||
```
|
|
||||||
StationSearch (Livewire Component)
|
|
||||||
├── resources/views/livewire/public/station-search.blade.php
|
|
||||||
│ ├── flux:heading (component)
|
|
||||||
│ ├── flux:subheading (component)
|
|
||||||
│ ├── form > flux:input (Fuel location input)
|
|
||||||
│ ├── form > flux:select (Fuel type selector)
|
|
||||||
│ │ └── Options: Petrol, E5, Diesel, Premium Diesel, B10, HVO
|
|
||||||
│ ├── form > flux:select (Radius selector)
|
|
||||||
│ │ └── Options: 1, 2, 5, 10, 20 miles
|
|
||||||
│ ├── form > flux:select (Sort selector)
|
|
||||||
│ │ └── Options: Price, Distance, Updated, Brand, Reliable
|
|
||||||
│ ├── form > flux:button (Search button)
|
|
||||||
│ ├── Error display (conditional)
|
|
||||||
│ ├── Results meta (count, cheapest, average)
|
|
||||||
│ ├── x-data="stationMap(...)" (Alpine.js map)
|
|
||||||
│ │ └── resources/js/maps/station-map.js
|
|
||||||
│ │ ├── Leaflet map initialization
|
|
||||||
│ │ ├── OpenStreetMap tiles
|
|
||||||
│ │ └── Circle markers (station data)
|
|
||||||
│ ├── Legend (color codes for data age)
|
|
||||||
│ └── Results list
|
|
||||||
│ └── Station cards (name, address, price, distance, age classification)
|
|
||||||
│ └── flux:badge (Supermarket tag, conditional)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
1. **PHP Class** (`App\Livewire\Public\StationSearch`):
|
|
||||||
- Properties: `$search`, `$fuelType`, `$radius`, `$sort`
|
|
||||||
- Properties: `$results[]`, `$meta[]`, `$apiError`
|
|
||||||
- Methods:
|
|
||||||
- `updatedFuelType()`: Refetch on fuel type change
|
|
||||||
- `updatedRadius()`: Refetch on radius change
|
|
||||||
- `updatedSort()`: Refetch on sort change
|
|
||||||
- `findStations()`: HTTP request to `/api/stations`
|
|
||||||
- `render()`: Returns view
|
|
||||||
|
|
||||||
2. **View Interactions**:
|
|
||||||
- `wire:model` on search input (two-way binding)
|
|
||||||
- `wire:model.live` on selectors (reactive updates)
|
|
||||||
- `wire:submit="findStations"` on form submit
|
|
||||||
- `wire:loading` for button state
|
|
||||||
- `wire:target="findStations"` for loading indicator
|
|
||||||
- `@entangle('results')` for Alpine.js map data
|
|
||||||
|
|
||||||
3. **JavaScript**:
|
|
||||||
- `stationMap(results)` Alpine data object
|
|
||||||
- Watches for `results` property changes
|
|
||||||
- Renders/updates Leaflet markers dynamically
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Dashboard
|
|
||||||
**Route:** `/dashboard`
|
|
||||||
**View:** `resources/views/dashboard.blade.php`
|
|
||||||
**Middleware:** `auth`, `verified`
|
|
||||||
|
|
||||||
### Dependency Tree
|
|
||||||
|
|
||||||
```
|
|
||||||
dashboard.blade.php
|
|
||||||
├── Layout: x-layouts::app (authenticated layout)
|
|
||||||
│ └── layouts/app.blade.php
|
|
||||||
│ └── x-layouts::app.sidebar (sidebar layout)
|
|
||||||
│ └── layouts/app/sidebar.blade.php
|
|
||||||
│ ├── partials/head.blade.php
|
|
||||||
│ ├── flux:header (navigation)
|
|
||||||
│ │ ├── x-app-logo
|
|
||||||
│ │ ├── flux:navbar with Dashboard link
|
|
||||||
│ │ ├── Secondary icons (Search, Repo, Docs)
|
|
||||||
│ │ └── x-desktop-user-menu
|
|
||||||
│ └── flux:sidebar (mobile navigation)
|
|
||||||
│
|
|
||||||
├── flux:main (page content wrapper)
|
|
||||||
│
|
|
||||||
└── Content: Grid of placeholder cards
|
|
||||||
└── x-placeholder-pattern (diagonal line SVG pattern)
|
|
||||||
└── 3 aspect-video cards (top row)
|
|
||||||
└── 1 flex-1 card (bottom, full height)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- Placeholder cards for future dashboard widgets
|
|
||||||
- Responsive grid: 3 columns on desktop, stacked on mobile
|
|
||||||
- Dark mode support with adjusted stroke colors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Welcome/Home Page
|
|
||||||
**Route:** `/`
|
|
||||||
**View:** `resources/views/welcome.blade.php`
|
|
||||||
|
|
||||||
### Note
|
|
||||||
|
|
||||||
The welcome view is large (>15KB) and contains:
|
|
||||||
- Full HTML with embedded Tailwind CSS
|
|
||||||
- Hero section with Fuel Price branding
|
|
||||||
- CTA buttons (Search Stations, View Source)
|
|
||||||
- Feature cards
|
|
||||||
- Dark mode support
|
|
||||||
- No layout wrapper (standalone)
|
|
||||||
|
|
||||||
(Full content available in actual file due to size)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Settings Pages
|
|
||||||
**Route:** `/settings/*`
|
|
||||||
**Layout:** `x-layouts::app` → `layouts/app/sidebar.blade.php`
|
|
||||||
|
|
||||||
### 4.1 Profile Settings
|
|
||||||
**Route:** `/settings/profile`
|
|
||||||
**Livewire Component:** `pages::settings.profile`
|
|
||||||
|
|
||||||
### Dependency Tree
|
|
||||||
|
|
||||||
```
|
|
||||||
settings/profile (Livewire Component)
|
|
||||||
├── Layout: x-layouts::app
|
|
||||||
│ └── layouts/app/sidebar.blade.php
|
|
||||||
│
|
|
||||||
├── partials/settings-heading.blade.php
|
|
||||||
│ ├── flux:heading ("Settings")
|
|
||||||
│ ├── flux:subheading ("Manage your profile...")
|
|
||||||
│ └── flux:separator
|
|
||||||
│
|
|
||||||
├── pages/settings/layout.blade.php (settings sidebar layout)
|
|
||||||
│ ├── flux:navlist (navigation)
|
|
||||||
│ │ ├── flux:navlist.item (Profile - current)
|
|
||||||
│ │ ├── flux:navlist.item (Security)
|
|
||||||
│ │ └── flux:navlist.item (Appearance)
|
|
||||||
│ │
|
|
||||||
│ └── Slot content (form fields)
|
|
||||||
│ └── Profile edit form (Livewire form)
|
|
||||||
│ ├── flux:input (Name)
|
|
||||||
│ ├── flux:input (Email)
|
|
||||||
│ ├── flux:button (Save)
|
|
||||||
│ └── Action messages on update
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Security Settings
|
|
||||||
**Route:** `/settings/security`
|
|
||||||
**Livewire Component:** `pages::settings.security`
|
|
||||||
|
|
||||||
- Two-factor authentication setup/management
|
|
||||||
- Recovery codes display
|
|
||||||
- Password confirmation (optional middleware)
|
|
||||||
|
|
||||||
### 4.3 Appearance Settings
|
|
||||||
**Route:** `/settings/appearance`
|
|
||||||
**Livewire Component:** `pages::settings.appearance`
|
|
||||||
|
|
||||||
- Theme selection (light/dark mode)
|
|
||||||
- Preference persistence
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Authentication Pages
|
|
||||||
**Layout:** `x-layouts::auth` → Various auth layouts
|
|
||||||
|
|
||||||
### 5.1 Login
|
|
||||||
**Route:** `/login`
|
|
||||||
**View:** `resources/views/pages/auth/login.blade.php`
|
|
||||||
**Layout:** `layouts/auth/simple.blade.php`
|
|
||||||
|
|
||||||
### Dependency Tree
|
|
||||||
|
|
||||||
```
|
|
||||||
login.blade.php
|
|
||||||
├── Layout: x-layouts::auth
|
|
||||||
│ └── layouts/auth/simple.blade.php
|
|
||||||
│ ├── partials/head.blade.php
|
|
||||||
│ └── Centered login form
|
|
||||||
│
|
|
||||||
├── x-auth-header (title + description)
|
|
||||||
├── x-auth-session-status (success message display)
|
|
||||||
├── form (POST to login.store)
|
|
||||||
│ ├── csrf token
|
|
||||||
│ ├── flux:input (Email)
|
|
||||||
│ ├── flux:input (Password, viewable)
|
|
||||||
│ │ └── flux:link to password.request (forgot password)
|
|
||||||
│ ├── flux:checkbox (Remember me)
|
|
||||||
│ └── flux:button (Log in, primary)
|
|
||||||
│
|
|
||||||
└── Sign up link (flux:link to register)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 Register
|
|
||||||
**Route:** `/register`
|
|
||||||
**View:** `resources/views/pages/auth/register.blade.php`
|
|
||||||
**Layout:** `layouts/auth/simple.blade.php`
|
|
||||||
|
|
||||||
```
|
|
||||||
register.blade.php
|
|
||||||
├── Layout: layouts/auth/simple.blade.php
|
|
||||||
├── x-auth-header
|
|
||||||
├── x-auth-session-status
|
|
||||||
├── form (POST to register.store)
|
|
||||||
│ ├── flux:input (Name)
|
|
||||||
│ ├── flux:input (Email)
|
|
||||||
│ ├── flux:input (Password, viewable)
|
|
||||||
│ ├── flux:input (Password Confirmation, viewable)
|
|
||||||
│ └── flux:button (Create account, primary)
|
|
||||||
└── Log in link
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 Password Reset
|
|
||||||
**Route:** `/forgot-password` and `/reset-password/{token}`
|
|
||||||
**Views:** `pages/auth/forgot-password.blade.php`, `pages/auth/reset-password.blade.php`
|
|
||||||
**Layout:** `layouts/auth/simple.blade.php`
|
|
||||||
|
|
||||||
### 5.4 Email Verification
|
|
||||||
**Route:** `/verify-email`
|
|
||||||
**View:** `pages/auth/verify-email.blade.php`
|
|
||||||
**Layout:** `layouts/auth/simple.blade.php`
|
|
||||||
|
|
||||||
### 5.5 Two-Factor Challenge
|
|
||||||
**Route:** `/two-factor-challenge`
|
|
||||||
**View:** `pages/auth/two-factor-challenge.blade.php`
|
|
||||||
**Layout:** `layouts/auth/simple.blade.php`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Include Hierarchy
|
|
||||||
|
|
||||||
### Layout Chain (All Authenticated Pages)
|
|
||||||
|
|
||||||
```
|
|
||||||
Page View
|
|
||||||
└── x-layouts::app (wrapper)
|
|
||||||
└── x-layouts::app.sidebar (main layout)
|
|
||||||
├── partials/head (in <head>)
|
|
||||||
│ ├── CSS imports (Vite)
|
|
||||||
│ ├── @fluxAppearance
|
|
||||||
│ └── Font preload
|
|
||||||
│
|
|
||||||
├── flux:header (desktop navigation)
|
|
||||||
│ ├── x-app-logo
|
|
||||||
│ ├── x-desktop-user-menu
|
|
||||||
│ └── flux:navbar / flux:tooltip components
|
|
||||||
│
|
|
||||||
├── flux:sidebar (mobile navigation)
|
|
||||||
│ ├── x-app-logo (:sidebar="true")
|
|
||||||
│ └── Navigation items
|
|
||||||
│
|
|
||||||
└── flux:main (page content slot)
|
|
||||||
└── Page-specific content
|
|
||||||
```
|
|
||||||
|
|
||||||
### Layout Chain (Auth Pages)
|
|
||||||
|
|
||||||
```
|
|
||||||
Auth Page View
|
|
||||||
└── x-layouts::auth (wrapper)
|
|
||||||
└── x-layouts::auth.simple (centered layout)
|
|
||||||
├── partials/head (in <head>)
|
|
||||||
└── Centered card with form
|
|
||||||
├── x-app-logo-icon
|
|
||||||
└── x-auth-header or form content
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Page Transitions
|
|
||||||
|
|
||||||
All pages use `wire:navigate` for Livewire navigation (no full page reload).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
- `wire:navigate` on `<flux:brand>` links
|
|
||||||
- `wire:navigate` on `<flux:link>` components
|
|
||||||
- `wire:navigate` on navigation items
|
|
||||||
|
|
||||||
This enables seamless SPA-like experience.
|
|
||||||
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
# Routes
|
|
||||||
|
|
||||||
## Public Routes
|
|
||||||
|
|
||||||
### `routes/web.php`
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Livewire\Public\StationSearch;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::view('/', 'homepage')->name('home');
|
|
||||||
|
|
||||||
Route::get('/stations', StationSearch::class)->name('stations.search');
|
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
|
||||||
Route::view('dashboard', 'dashboard')->name('dashboard');
|
|
||||||
});
|
|
||||||
|
|
||||||
require __DIR__.'/settings.php';
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Settings Routes
|
|
||||||
|
|
||||||
### `routes/settings.php`
|
|
||||||
|
|
||||||
```php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
use Laravel\Fortify\Features;
|
|
||||||
|
|
||||||
Route::middleware(['auth'])->group(function () {
|
|
||||||
Route::redirect('settings', 'settings/profile');
|
|
||||||
|
|
||||||
Route::livewire('settings/profile', 'pages::settings.profile')->name('profile.edit');
|
|
||||||
});
|
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
|
||||||
Route::livewire('settings/appearance', 'pages::settings.appearance')->name('appearance.edit');
|
|
||||||
|
|
||||||
Route::livewire('settings/security', 'pages::settings.security')
|
|
||||||
->middleware(
|
|
||||||
when(
|
|
||||||
Features::canManageTwoFactorAuthentication()
|
|
||||||
&& Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
|
|
||||||
['password.confirm'],
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
->name('security.edit');
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** Auth routes (login, register, password reset, etc.) are provided by Laravel Fortify. See `config/fortify.php` for route definitions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Route Summary Table
|
|
||||||
|
|
||||||
| Method | Path | Name | Component/View | Middleware | Purpose |
|
|
||||||
|--------|------|------|---|---|---|
|
|
||||||
| GET | `/` | `home` | `welcome` view | public | Landing page |
|
|
||||||
| GET | `/stations` | `stations.search` | `StationSearch` Livewire | public | Fuel station search page |
|
|
||||||
| GET | `/dashboard` | `dashboard` | `dashboard` view | `auth`, `verified` | Authenticated user dashboard |
|
|
||||||
| GET/POST | `/login` | `login` | Fortify auth | public | User login |
|
|
||||||
| GET/POST | `/register` | `register` | Fortify auth | public | User registration |
|
|
||||||
| POST | `/logout` | `logout` | Fortify action | `auth` | Logout action |
|
|
||||||
| GET/POST | `/forgot-password` | `password.request` | Fortify auth | public | Password reset request |
|
|
||||||
| GET/POST | `/reset-password/{token}` | `password.reset` | Fortify auth | public | Password reset form |
|
|
||||||
| GET/POST | `/confirm-password` | `password.confirm` | Fortify auth | `auth` | Confirm password (before sensitive actions) |
|
|
||||||
| GET/POST | `/verify-email` | `verification.notice` | Fortify auth | `auth` | Email verification |
|
|
||||||
| GET | `/verify-email/{id}/{hash}` | `verification.verify` | Fortify action | `auth`, `signed` | Verify email action |
|
|
||||||
| GET/POST | `/two-factor-challenge` | `two-factor.login` | Fortify auth | `guest` | Two-factor challenge |
|
|
||||||
| GET | `/settings` | (redirect) | → `settings.profile` | `auth` | Redirect to profile |
|
|
||||||
| GET | `/settings/profile` | `profile.edit` | Livewire `pages::settings.profile` | `auth` | Edit user profile |
|
|
||||||
| GET | `/settings/appearance` | `appearance.edit` | Livewire `pages::settings.appearance` | `auth`, `verified` | Edit appearance preferences |
|
|
||||||
| GET | `/settings/security` | `security.edit` | Livewire `pages::settings.security` | `auth`, `verified`, (optional) `password.confirm` | Edit security settings |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Route Grouping
|
|
||||||
|
|
||||||
### Public Routes
|
|
||||||
- Landing page (`/`)
|
|
||||||
- Station search (`/stations`)
|
|
||||||
- Fortify auth routes (login, register, password reset, etc.)
|
|
||||||
|
|
||||||
### Authenticated Routes
|
|
||||||
- Dashboard (`/dashboard`)
|
|
||||||
- Settings pages (`/settings/*`)
|
|
||||||
|
|
||||||
### Settings Routes (Special Handling)
|
|
||||||
- Profile editing: `auth` only
|
|
||||||
- Appearance: `auth`, `verified`
|
|
||||||
- Security: `auth`, `verified`, optional password confirmation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Livewire Component Routes
|
|
||||||
|
|
||||||
| Route | Livewire Component | View | Purpose |
|
|
||||||
|-------|---|---|---|
|
|
||||||
| `/stations` | `App\Livewire\Public\StationSearch` | `livewire.public.station-search` | Interactive fuel station search with map |
|
|
||||||
| `/settings/profile` | `pages::settings.profile` | `pages.settings.profile` | User profile management |
|
|
||||||
| `/settings/appearance` | `pages::settings.appearance` | `pages.settings.appearance` | Theme/appearance preferences |
|
|
||||||
| `/settings/security` | `pages::settings.security` | `pages.settings.security` | Security and 2FA settings |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- **Fortify**: The application uses Laravel Fortify for authentication scaffolding. Auth routes are auto-registered by Fortify package.
|
|
||||||
- **Livewire Routes**: Routes using `Route::livewire()` expect corresponding Livewire components in `app/Livewire/`.
|
|
||||||
- **Middleware Chain**: Routes are protected with `auth` and/or `verified` middleware as appropriate.
|
|
||||||
- **Settings Redirect**: Accessing `/settings` redirects to `/settings/profile` (first settings tab).
|
|
||||||
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
# Design System & Theme
|
|
||||||
|
|
||||||
## Tailwind Configuration
|
|
||||||
|
|
||||||
### `tailwind.config.js`
|
|
||||||
|
|
||||||
File doesn't exist. The project uses Tailwind CSS v4 with `@tailwindcss/vite` plugin for compilation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CSS Configuration
|
|
||||||
|
|
||||||
### `resources/css/app.css`
|
|
||||||
|
|
||||||
```css
|
|
||||||
@import 'leaflet/dist/leaflet.css';
|
|
||||||
@import 'tailwindcss';
|
|
||||||
@import '../../vendor/livewire/flux/dist/flux.css';
|
|
||||||
|
|
||||||
@source '../views';
|
|
||||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
|
||||||
@source '../../vendor/livewire/flux-pro/stubs/**/*.blade.php';
|
|
||||||
@source '../../vendor/livewire/flux/stubs/**/*.blade.php';
|
|
||||||
|
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
||||||
|
|
||||||
--color-zinc-50: #fafafa;
|
|
||||||
--color-zinc-100: #f5f5f5;
|
|
||||||
--color-zinc-200: #e5e5e5;
|
|
||||||
--color-zinc-300: #d4d4d4;
|
|
||||||
--color-zinc-400: #a3a3a3;
|
|
||||||
--color-zinc-500: #737373;
|
|
||||||
--color-zinc-600: #525252;
|
|
||||||
--color-zinc-700: #404040;
|
|
||||||
--color-zinc-800: #262626;
|
|
||||||
--color-zinc-900: #171717;
|
|
||||||
--color-zinc-950: #0a0a0a;
|
|
||||||
|
|
||||||
--color-accent: var(--color-neutral-800);
|
|
||||||
--color-accent-content: var(--color-neutral-800);
|
|
||||||
--color-accent-foreground: var(--color-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer theme {
|
|
||||||
.dark {
|
|
||||||
--color-accent: var(--color-white);
|
|
||||||
--color-accent-content: var(--color-white);
|
|
||||||
--color-accent-foreground: var(--color-neutral-800);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
|
|
||||||
*,
|
|
||||||
::after,
|
|
||||||
::before,
|
|
||||||
::backdrop,
|
|
||||||
::file-selector-button {
|
|
||||||
border-color: var(--color-gray-200, currentColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-flux-field]:not(ui-radio, ui-checkbox) {
|
|
||||||
@apply grid gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-flux-label] {
|
|
||||||
@apply !mb-0 !leading-tight;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus[data-flux-control],
|
|
||||||
textarea:focus[data-flux-control],
|
|
||||||
select:focus[data-flux-control] {
|
|
||||||
@apply outline-hidden ring-2 ring-accent ring-offset-2 ring-offset-accent-foreground;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Color Palette
|
|
||||||
|
|
||||||
### Primary Colors
|
|
||||||
- **Zinc Palette** (grays): 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950
|
|
||||||
- Used for backgrounds, borders, text
|
|
||||||
|
|
||||||
### Accent Colors
|
|
||||||
- **Dark Mode:** White/neutral-800 depending on context
|
|
||||||
- **Light Mode:** neutral-800
|
|
||||||
- **Accent Foreground:** White (light mode), neutral-800 (dark mode)
|
|
||||||
- **Accent Content:** neutral-800 (light), white (dark)
|
|
||||||
|
|
||||||
### Status Colors
|
|
||||||
- **Success:** green-500, green-600
|
|
||||||
- **Warning:** amber-500, amber-400
|
|
||||||
- **Error:** red-500, red-400, red-600
|
|
||||||
- **Info:** slate-500
|
|
||||||
|
|
||||||
### Semantic Colors (from Station Search)
|
|
||||||
- **Current Price (fresh):** green-500 (#22c55e)
|
|
||||||
- **Recent (24-48h):** slate-500 (#64748b)
|
|
||||||
- **Stale (2-5 days):** amber-500 (#f59e0b)
|
|
||||||
- **Outdated (5+ days):** red-500 (#ef4444)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
### Font Family
|
|
||||||
- **Primary:** 'Instrument Sans' (Weights: 400, 500, 600)
|
|
||||||
- **Fallback:** ui-sans-serif, system-ui, sans-serif
|
|
||||||
- **Emoji:** Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji
|
|
||||||
|
|
||||||
### Font Sizes & Weights (via Flux)
|
|
||||||
- **Heading (XL):** Used for page titles
|
|
||||||
- **Heading (LG):** Used for section titles
|
|
||||||
- **Subheading:** Used for descriptions
|
|
||||||
- **Text:** Standard body text
|
|
||||||
- **Label:** Form labels
|
|
||||||
- **Badge:** Small text labels
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Spacing System
|
|
||||||
|
|
||||||
Based on Tailwind default scale:
|
|
||||||
- **Base Unit:** 4px (1 = 4px)
|
|
||||||
- **Common Spacing:**
|
|
||||||
- `gap-2`: 8px (0.5rem)
|
|
||||||
- `gap-3`: 12px (0.75rem)
|
|
||||||
- `gap-4`: 16px (1rem)
|
|
||||||
- `gap-6`: 24px (1.5rem)
|
|
||||||
- `px-4`: 16px horizontal padding
|
|
||||||
- `py-3`: 12px vertical padding
|
|
||||||
- `p-6`: 24px all sides padding
|
|
||||||
- `p-10`: 40px all sides padding
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Border & Radius
|
|
||||||
|
|
||||||
### Border Colors
|
|
||||||
- **Light:** `border-zinc-200`
|
|
||||||
- **Dark:** `border-zinc-700` (primary), `border-stone-800` (cards), `border-neutral-800` (split layout)
|
|
||||||
|
|
||||||
### Border Radius
|
|
||||||
- **Input/buttons:** Default Flux radius
|
|
||||||
- **Cards:** `rounded-xl` (larger radius)
|
|
||||||
- **Icons/small:** `rounded-md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dark Mode
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
- Uses `class="dark"` on `<html>` root
|
|
||||||
- Custom variant: `@custom-variant dark (&:where(.dark, .dark *))`
|
|
||||||
- Flux `@fluxAppearance` directive manages theme toggle
|
|
||||||
|
|
||||||
### Dark Mode Colors
|
|
||||||
- **Body BG:** `dark:bg-zinc-800`
|
|
||||||
- **Header/Sidebar BG:** `dark:bg-zinc-900`
|
|
||||||
- **Borders:** `dark:border-zinc-700`
|
|
||||||
- **Text:** `dark:text-zinc-100` (headings), `dark:text-zinc-400` (secondary)
|
|
||||||
|
|
||||||
### Accent in Dark Mode
|
|
||||||
- `--color-accent: var(--color-white)`
|
|
||||||
- `--color-accent-foreground: var(--color-neutral-800)`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Responsive Design
|
|
||||||
|
|
||||||
### Breakpoints (Tailwind defaults)
|
|
||||||
- **sm:** 640px
|
|
||||||
- **md:** 768px
|
|
||||||
- **lg:** 1024px
|
|
||||||
- **xl:** 1280px
|
|
||||||
- **2xl:** 1536px
|
|
||||||
|
|
||||||
### Usage Patterns
|
|
||||||
- `max-lg:hidden` - Hide on mobile/tablet
|
|
||||||
- `lg:hidden` - Hide on desktop
|
|
||||||
- `max-md:flex-col` - Stack on small screens
|
|
||||||
- `sm:flex-row` - Row layout on small+ screens
|
|
||||||
- `md:w-[220px]` - Fixed width on medium+
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component-Specific Theming
|
|
||||||
|
|
||||||
### Forms (Flux)
|
|
||||||
- `[data-flux-field]`: grid gap-2 layout
|
|
||||||
- `[data-flux-label]`: No margin-bottom, tight line-height
|
|
||||||
- Focus states: ring-2 ring-accent with ring-offset
|
|
||||||
|
|
||||||
### Flux Appearance
|
|
||||||
- Automatically applied via `@fluxAppearance` directive
|
|
||||||
- Manages light/dark mode toggle
|
|
||||||
- Integrates with browser's prefers-color-scheme
|
|
||||||
|
|
||||||
### Leaflet Map
|
|
||||||
- Uses default OSM tiles
|
|
||||||
- Custom marker colors: green (current), slate (recent), amber (stale), red (outdated)
|
|
||||||
- Markers: 9px radius, white stroke, 2px weight, 85% fill opacity
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Brand Identity
|
|
||||||
|
|
||||||
### Logo
|
|
||||||
- **Primary Icon:** Custom SVG (geometric design)
|
|
||||||
- **Color:** Adapts to theme (black light mode, white dark mode)
|
|
||||||
- **Name:** "Laravel Starter Kit"
|
|
||||||
- **Contexts:**
|
|
||||||
- Header/nav: `flux:brand`
|
|
||||||
- Sidebar: `flux:sidebar.brand`
|
|
||||||
|
|
||||||
### Loading/Placeholder States
|
|
||||||
- Pattern SVG with diagonal lines
|
|
||||||
- Color: `stroke-gray-900/20` (light) or `stroke-neutral-100/20` (dark)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Animations & Transitions
|
|
||||||
|
|
||||||
### Alpine.js Transitions
|
|
||||||
- **Message Dismiss:** `x-show.transition.out.opacity.duration.1500ms`
|
|
||||||
- **Fade effects:** opacity transitions over 1500ms
|
|
||||||
|
|
||||||
### Flux Components
|
|
||||||
- Built-in animations for:
|
|
||||||
- Dropdown menus
|
|
||||||
- Sidebar collapse/expand
|
|
||||||
- Modal opens/closes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CSS Custom Properties (Tokens)
|
|
||||||
|
|
||||||
```css
|
|
||||||
--font-sans: 'Instrument Sans', ...
|
|
||||||
--color-zinc-50 through --color-zinc-950
|
|
||||||
--color-accent
|
|
||||||
--color-accent-content
|
|
||||||
--color-accent-foreground
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Vite Build Configuration
|
|
||||||
|
|
||||||
**File:** `vite.config.js`
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import {
|
|
||||||
defineConfig
|
|
||||||
} from 'vite';
|
|
||||||
import laravel from 'laravel-vite-plugin';
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
laravel({
|
|
||||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
|
||||||
refresh: true,
|
|
||||||
}),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
server: {
|
|
||||||
cors: true,
|
|
||||||
watch: {
|
|
||||||
ignored: ['**/storage/framework/views/**'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features:**
|
|
||||||
- Tailwind CSS v4 via @tailwindcss/vite plugin
|
|
||||||
- Automatic CSS/JS refresh
|
|
||||||
- CORS enabled for dev server
|
|
||||||
- Ignores Laravel view cache files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Flux UI Theme Configuration
|
|
||||||
|
|
||||||
Flux v2 is configured via:
|
|
||||||
- CSS import: `@import '../../vendor/livewire/flux/dist/flux.css'`
|
|
||||||
- Content sources point to Flux stubs for autocomplete
|
|
||||||
- `@fluxAppearance` handles dark mode toggling
|
|
||||||
- `@fluxScripts` loads JavaScript enhancements
|
|
||||||
|
|
||||||
No custom Flux config file exists; uses defaults with CSS customizations.
|
|
||||||
|
|
||||||
51
CLAUDE.md
51
CLAUDE.md
@@ -1,15 +1,51 @@
|
|||||||
# FuelAlert — Claude Code Instructions
|
# Fuel Alert — Claude Code Instructions
|
||||||
|
|
||||||
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
||||||
based on local price trends. Built solo by a PHP/Laravel developer.
|
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:
|
||||||
|
|
||||||
|
- `php artisan migrate:fresh` (with any flags, including `--seed`)
|
||||||
|
- `php artisan migrate:reset`
|
||||||
|
- `php artisan db:wipe`
|
||||||
|
- Raw `DROP TABLE`, `DROP DATABASE`, or `TRUNCATE` via tinker, `database-query`, or any MCP tool
|
||||||
|
- Any sequence that effectively rebuilds the schema or drops tables
|
||||||
|
|
||||||
|
These are also blocked at the harness level via `.claude/settings.json` deny rules, but the prose rule applies everywhere the block doesn't reach (compound shell commands, MCP tools, etc.).
|
||||||
|
|
||||||
|
A user saying "trust me", "do the refactor", "clean up the mess", or "I want it in db" is **not** authorisation for these — the architectural decision is separate from the operational step. If a migration is awkward to apply in-place, propose the in-place version (read JSON → populate new columns → drop the old column) instead of suggesting a rebuild. Asking once at the start of a task does not authorise repeat wipes later in the session.
|
||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
|
- **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
|
- **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
|
- **Backend**: Laravel 13 + PHP 8.4. MySQL (Eloquent, migrations only — no raw DDL)
|
||||||
- **Database**: MySQL — Eloquent ORM, migrations only (no raw DDL)
|
- **Frontend**: **Vue 3 SPA** — Vue 3.5 + Vue Router 4, Vite 8, Tailwind CSS v4.
|
||||||
- **Payments**: Stripe via Laravel Cashier
|
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)
|
- **Notifications**: Laravel Notification channels — email, WhatsApp (Vonage), SMS (Vonage), push (OneSignal)
|
||||||
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
|
- **Queue**: Laravel queues with Redis driver (notifications and polling jobs)
|
||||||
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
|
- **Scheduler**: Laravel scheduler for Fuel Finder API polling and scoring
|
||||||
@@ -22,7 +58,8 @@ php artisan queue:work # Process notification jobs
|
|||||||
php artisan schedule:run # Run scheduled commands (cron every minute)
|
php artisan schedule:run # Run scheduled commands (cron every minute)
|
||||||
php artisan migrate # Run migrations
|
php artisan migrate # Run migrations
|
||||||
php artisan test # Run Pest test suite
|
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
|
## Imports
|
||||||
@@ -31,8 +68,10 @@ npm run dev # Vite asset watcher
|
|||||||
@.claude/rules/database.md
|
@.claude/rules/database.md
|
||||||
@.claude/rules/notifications.md
|
@.claude/rules/notifications.md
|
||||||
@.claude/rules/scoring.md
|
@.claude/rules/scoring.md
|
||||||
|
@.claude/rules/prediction.md
|
||||||
@.claude/rules/payments.md
|
@.claude/rules/payments.md
|
||||||
@.claude/rules/livewire.md
|
@.claude/rules/tiers.md
|
||||||
|
@.claude/rules/frontend.md
|
||||||
@.claude/rules/api-data.md
|
@.claude/rules/api-data.md
|
||||||
@.claude/rules/testing.md
|
@.claude/rules/testing.md
|
||||||
@.claude/rules/code-style.md
|
@.claude/rules/code-style.md
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Concerns\PasswordValidationRules;
|
use App\Concerns\PasswordValidationRules;
|
||||||
use App\Concerns\ProfileValidationRules;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
|
||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
{
|
{
|
||||||
use PasswordValidationRules, ProfileValidationRules;
|
use PasswordValidationRules;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and create a newly registered user.
|
* Validate and create a newly registered user.
|
||||||
@@ -20,7 +20,8 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
...$this->profileRules(),
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,4 @@ trait PasswordValidationRules
|
|||||||
{
|
{
|
||||||
return ['required', 'string', Password::default(), 'confirmed'];
|
return ['required', 'string', Password::default(), 'confirmed'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules used to validate the current password.
|
|
||||||
*
|
|
||||||
* @return array<int, Rule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
protected function currentPasswordRules(): array
|
|
||||||
{
|
|
||||||
return ['required', 'string', 'current_password'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Concerns;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
trait ProfileValidationRules
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the validation rules used to validate user profiles.
|
|
||||||
*
|
|
||||||
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
|
|
||||||
*/
|
|
||||||
protected function profileRules(?int $userId = null): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => $this->nameRules(),
|
|
||||||
'email' => $this->emailRules($userId),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules used to validate user names.
|
|
||||||
*
|
|
||||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
protected function nameRules(): array
|
|
||||||
{
|
|
||||||
return ['required', 'string', 'max:255'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules used to validate user emails.
|
|
||||||
*
|
|
||||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
protected function emailRules(?int $userId = null): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'email',
|
|
||||||
'max:255',
|
|
||||||
$userId === null
|
|
||||||
? Rule::unique(User::class)
|
|
||||||
: Rule::unique(User::class)->ignore($userId),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
app/Console/Commands/ArchiveOldPricesCommand.php
Normal file
52
app/Console/Commands/ArchiveOldPricesCommand.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\StationPrice;
|
||||||
|
use App\Models\StationPriceArchive;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ArchiveOldPricesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'fuel:archive';
|
||||||
|
|
||||||
|
protected $description = 'Move station price history older than 12 months to the archive table';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$cutoff = Carbon::now()->subMonths(12);
|
||||||
|
|
||||||
|
$count = StationPrice::where('price_effective_at', '<', $cutoff)->count();
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
$this->info('No prices to archive.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Archiving {$count} price record(s) older than {$cutoff->toDateString()}...");
|
||||||
|
|
||||||
|
StationPrice::where('price_effective_at', '<', $cutoff)
|
||||||
|
->chunkById(1000, function ($prices): void {
|
||||||
|
$rows = $prices->map(fn (StationPrice $price): array => [
|
||||||
|
'station_id' => $price->station_id,
|
||||||
|
'fuel_type' => $price->fuel_type->value,
|
||||||
|
'price_pence' => $price->price_pence,
|
||||||
|
'price_effective_at' => $price->price_effective_at,
|
||||||
|
'price_reported_at' => $price->price_reported_at,
|
||||||
|
'recorded_at' => $price->recorded_at,
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($rows, $prices): void {
|
||||||
|
StationPriceArchive::insert($rows);
|
||||||
|
StationPrice::whereIn('id', $prices->pluck('id'))->delete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info('Archive complete.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Console/Commands/BackfillOilPrices.php
Normal file
33
app/Console/Commands/BackfillOilPrices.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\BrentPriceFetcher;
|
||||||
|
use App\Services\BrentPriceSources\BrentPriceFetchException;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('oil:backfill {--from=2018-01-01 : ISO start date (inclusive)} {--to= : ISO end date (defaults to today, inclusive)}')]
|
||||||
|
#[Description('One-shot backfill of historical Brent crude prices from FRED into brent_prices.')]
|
||||||
|
class BackfillOilPrices extends Command
|
||||||
|
{
|
||||||
|
public function handle(BrentPriceFetcher $fetcher): int
|
||||||
|
{
|
||||||
|
$from = (string) $this->option('from');
|
||||||
|
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||||
|
|
||||||
|
$this->info("Backfilling Brent ({$from} → {$to}) from FRED...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = $fetcher->backfillFromFred($from, $to);
|
||||||
|
$this->info(sprintf('Upserted %d Brent rows.', $count));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
$this->error('FRED backfill failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Console/Commands/EvaluateVolatilityRegime.php
Normal file
30
app/Console/Commands/EvaluateVolatilityRegime.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\VolatilityRegimeService;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:evaluate-volatility')]
|
||||||
|
#[Description('Evaluate the volatility regime triggers and update volatility_regimes accordingly. Hourly cron.')]
|
||||||
|
class EvaluateVolatilityRegime extends Command
|
||||||
|
{
|
||||||
|
public function handle(VolatilityRegimeService $service): int
|
||||||
|
{
|
||||||
|
$regime = $service->evaluate();
|
||||||
|
|
||||||
|
if ($regime === null) {
|
||||||
|
$this->info('Volatility regime: OFF');
|
||||||
|
} else {
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Volatility regime: ON (trigger=%s, since %s)',
|
||||||
|
$regime->trigger,
|
||||||
|
$regime->flipped_on_at->toIso8601String(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Console/Commands/FetchOilPrices.php
Normal file
40
app/Console/Commands/FetchOilPrices.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\BrentPriceFetcher;
|
||||||
|
use App\Services\BrentPriceSources\BrentPriceFetchException;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
#[Signature('oil:fetch')]
|
||||||
|
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
|
||||||
|
class FetchOilPrices extends Command
|
||||||
|
{
|
||||||
|
public function handle(BrentPriceFetcher $fetcher): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$fetcher->fetchFromEia();
|
||||||
|
$this->info('Fetched Brent prices from EIA.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
Log::warning('FetchOilPrices: EIA fetch failed, falling back to FRED', ['error' => $e->getMessage()]);
|
||||||
|
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fetcher->fetchFromFred();
|
||||||
|
$this->info('Fetched Brent prices from FRED.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
|
||||||
|
$this->error('Both EIA and FRED failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Console/Commands/ImportBeisFuelPrices.php
Normal file
35
app/Console/Commands/ImportBeisFuelPrices.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\BeisImporter;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[Signature('beis:import')]
|
||||||
|
#[Description('Pull the latest gov.uk Weekly road fuel prices CSV and upsert into weekly_pump_prices.')]
|
||||||
|
class ImportBeisFuelPrices extends Command
|
||||||
|
{
|
||||||
|
public function handle(BeisImporter $importer): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $importer->import();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error('BEIS import failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Imported %d rows from %s — latest date: %s.',
|
||||||
|
$result['parsed'],
|
||||||
|
$result['csv_url'],
|
||||||
|
$result['latest_date'],
|
||||||
|
));
|
||||||
|
$this->info('Forecast cache flushed; next API hit will retrain on the new row.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Console/Commands/ImportPostcodes.php
Normal file
162
app/Console/Commands/ImportPostcodes.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
|
||||||
|
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
|
||||||
|
final class ImportPostcodes extends Command
|
||||||
|
{
|
||||||
|
private const int CHUNK_SIZE = 1000;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$file = $this->option('file');
|
||||||
|
|
||||||
|
if ($file === null || ! is_readable($file)) {
|
||||||
|
$this->error('--file is required and must be a readable path to an ONSPD CSV.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($file, 'r');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
$this->error("Unable to open {$file}.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = fgetcsv($handle);
|
||||||
|
|
||||||
|
if ($header === false) {
|
||||||
|
$this->error('CSV is empty.');
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerCounts = array_count_values(array_map('strtolower', $header));
|
||||||
|
$columns = array_change_key_case(array_flip($header), CASE_LOWER);
|
||||||
|
|
||||||
|
$pcdColumn = null;
|
||||||
|
|
||||||
|
foreach (['pcd', 'pcds', 'pcd7', 'pcd8'] as $candidate) {
|
||||||
|
if (isset($columns[$candidate])) {
|
||||||
|
$pcdColumn = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pcdColumn === null) {
|
||||||
|
$this->error('Missing required postcode column (expected one of: pcd, pcds, pcd7, pcd8).');
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([$pcdColumn, 'lat', 'long'] as $required) {
|
||||||
|
if (($headerCounts[$required] ?? 0) > 1) {
|
||||||
|
$this->error("Column '{$required}' appears more than once — refusing to import.");
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['lat', 'long'] as $required) {
|
||||||
|
if (! isset($columns[$required])) {
|
||||||
|
$this->error("Missing required column '{$required}'.");
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasDoterm = isset($columns['doterm']);
|
||||||
|
|
||||||
|
// Stream into a staging table first. Only swap into the live
|
||||||
|
// postcodes / outcodes tables once the full CSV has been consumed —
|
||||||
|
// a mid-stream failure leaves production data untouched.
|
||||||
|
Schema::dropIfExists('postcodes_staging');
|
||||||
|
Schema::create('postcodes_staging', function (Blueprint $table): void {
|
||||||
|
$table->string('postcode', 7);
|
||||||
|
$table->string('outcode', 4);
|
||||||
|
$table->decimal('lat', 10, 7);
|
||||||
|
$table->decimal('lng', 10, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
$buffer = [];
|
||||||
|
$imported = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lat = trim((string) ($row[$columns['lat']] ?? ''));
|
||||||
|
$lng = trim((string) ($row[$columns['long']] ?? ''));
|
||||||
|
|
||||||
|
if ($lat === '' || $lng === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
|
||||||
|
|
||||||
|
if ($pcd === '' || strlen($pcd) < 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer[] = [
|
||||||
|
'postcode' => $pcd,
|
||||||
|
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
|
||||||
|
'lat' => (float) $lat,
|
||||||
|
'lng' => (float) $lng,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($buffer) >= self::CHUNK_SIZE) {
|
||||||
|
DB::table('postcodes_staging')->insert($buffer);
|
||||||
|
$imported += count($buffer);
|
||||||
|
$buffer = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($buffer !== []) {
|
||||||
|
DB::table('postcodes_staging')->insert($buffer);
|
||||||
|
$imported += count($buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap: empty live tables, copy from staging, derive outcodes.
|
||||||
|
DB::table('outcodes')->truncate();
|
||||||
|
DB::table('postcodes')->truncate();
|
||||||
|
DB::statement(
|
||||||
|
'INSERT INTO postcodes (postcode, outcode, lat, lng)
|
||||||
|
SELECT postcode, outcode, lat, lng FROM postcodes_staging'
|
||||||
|
);
|
||||||
|
DB::statement(
|
||||||
|
'INSERT INTO outcodes (outcode, lat, lng)
|
||||||
|
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error('Import failed — live tables left untouched: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
Schema::dropIfExists('postcodes_staging');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Imported {$imported} postcodes.");
|
||||||
|
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,26 @@
|
|||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Events\PricesUpdatedEvent;
|
use App\Events\PricesUpdatedEvent;
|
||||||
|
use App\Models\Station;
|
||||||
use App\Services\FuelPriceService;
|
use App\Services\FuelPriceService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class PollFuelPrices extends Command
|
class PollFuelPrices extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'fuel:poll {--full : Also refresh station metadata}';
|
protected $signature = 'fuel:poll {--full : Force refresh station metadata before polling}';
|
||||||
|
|
||||||
protected $description = 'Poll the Fuel Finder API for latest prices';
|
protected $description = 'Poll the Fuel Finder API for latest prices';
|
||||||
|
|
||||||
public function handle(FuelPriceService $service): int
|
public function handle(FuelPriceService $service): int
|
||||||
{
|
{
|
||||||
$fullRefresh = (bool) $this->option('full');
|
$fullRefresh = (bool) $this->option('full');
|
||||||
|
$lastRefresh = Station::max('last_seen_at');
|
||||||
|
$stationsStale = $lastRefresh === null || Carbon::parse($lastRefresh)->isBefore(today());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($fullRefresh) {
|
if ($fullRefresh || $stationsStale) {
|
||||||
$this->info('Refreshing station metadata...');
|
$this->info('Refreshing station metadata...');
|
||||||
$service->refreshStations();
|
$service->refreshStations();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Services\OilPriceService;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class PredictOilPrices extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'oil:predict {--fetch : Fetch latest FRED prices before predicting}';
|
|
||||||
|
|
||||||
protected $description = 'Generate a Brent crude oil price direction prediction';
|
|
||||||
|
|
||||||
public function handle(OilPriceService $service): int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
if ($this->option('fetch')) {
|
|
||||||
$this->info('Fetching latest Brent crude prices from FRED...');
|
|
||||||
$service->fetchBrentPrices();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Generating prediction...');
|
|
||||||
$prediction = $service->generatePrediction();
|
|
||||||
|
|
||||||
if ($prediction === null) {
|
|
||||||
$this->error('Could not generate a prediction — not enough price data.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(sprintf(
|
|
||||||
'Done. [%s] direction=%s confidence=%d%% — %s',
|
|
||||||
strtoupper($prediction->source->value),
|
|
||||||
$prediction->direction->value,
|
|
||||||
$prediction->confidence,
|
|
||||||
$prediction->reasoning,
|
|
||||||
));
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->error("Prediction failed: {$e->getMessage()}");
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
app/Console/Commands/ResolveForecastOutcomes.php
Normal file
21
app/Console/Commands/ResolveForecastOutcomes.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\OutcomeResolver;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:resolve-outcomes')]
|
||||||
|
#[Description('Pair past weekly forecasts with the actual ULSP from BEIS data and write rows to forecast_outcomes.')]
|
||||||
|
class ResolveForecastOutcomes extends Command
|
||||||
|
{
|
||||||
|
public function handle(OutcomeResolver $resolver): int
|
||||||
|
{
|
||||||
|
$count = $resolver->resolvePending();
|
||||||
|
$this->info(sprintf('Resolved %d outstanding forecast(s).', $count));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Console/Commands/RunLlmOverlay.php
Normal file
34
app/Console/Commands/RunLlmOverlay.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\LlmOverlayService;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:llm-overlay {--event-driven : Honor the 4h cooldown (default: false; daily 07:00 cron always runs)}')]
|
||||||
|
#[Description('Run the daily Anthropic web-search overlay on the current weekly forecast.')]
|
||||||
|
class RunLlmOverlay extends Command
|
||||||
|
{
|
||||||
|
public function handle(LlmOverlayService $service): int
|
||||||
|
{
|
||||||
|
$row = $service->run(eventDriven: (bool) $this->option('event-driven'));
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
$this->warn('LLM overlay skipped (no API key, on cooldown, or rejected for empty citations).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Stored llm_overlays #%d — direction=%s confidence=%d major_impact=%s.',
|
||||||
|
$row->id,
|
||||||
|
$row->direction,
|
||||||
|
$row->confidence,
|
||||||
|
$row->major_impact_event ? 'YES' : 'no',
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,21 +11,20 @@ enum FuelType: string
|
|||||||
case B10 = 'b10';
|
case B10 = 'b10';
|
||||||
case Hvo = 'hvo';
|
case Hvo = 'hvo';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::E10 => 'Petrol (E10)',
|
||||||
|
self::E5 => 'Premium (E5)',
|
||||||
|
self::B7Standard => 'Diesel (B7)',
|
||||||
|
self::B7Premium => 'Prem Diesel',
|
||||||
|
self::B10 => 'Diesel (B10)',
|
||||||
|
self::Hvo => 'HVO',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static function fromApiValue(string $value): self
|
public static function fromApiValue(string $value): self
|
||||||
{
|
{
|
||||||
return self::from(strtolower($value));
|
return self::from(strtolower($value));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromAlias(string $alias): self
|
|
||||||
{
|
|
||||||
return match (strtolower($alias)) {
|
|
||||||
'diesel', 'b7_standard' => self::B7Standard,
|
|
||||||
'premium_diesel', 'b7_premium' => self::B7Premium,
|
|
||||||
'petrol', 'unleaded', 'e10' => self::E10,
|
|
||||||
'premium_unleaded', 'e5' => self::E5,
|
|
||||||
'b10' => self::B10,
|
|
||||||
'hvo' => self::Hvo,
|
|
||||||
default => throw new \ValueError("Unknown fuel type alias: {$alias}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
21
app/Enums/PlanTier.php
Normal file
21
app/Enums/PlanTier.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PlanTier: string
|
||||||
|
{
|
||||||
|
case Free = 'free';
|
||||||
|
case Basic = 'basic';
|
||||||
|
case Plus = 'plus';
|
||||||
|
case Pro = 'pro';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Free => 'Free',
|
||||||
|
self::Basic => 'Daily',
|
||||||
|
self::Plus => 'Smart',
|
||||||
|
self::Pro => 'Pro',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Enums/PriceReliability.php
Normal file
45
app/Enums/PriceReliability.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
enum PriceReliability: string
|
||||||
|
{
|
||||||
|
case Reliable = 'reliable';
|
||||||
|
case Stale = 'stale';
|
||||||
|
case Outdated = 'outdated';
|
||||||
|
|
||||||
|
public static function fromUpdatedAt(?Carbon $updatedAt): self
|
||||||
|
{
|
||||||
|
if ($updatedAt === null) {
|
||||||
|
return self::Outdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hours = $updatedAt->diffInHours(now());
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$hours <= 72 => self::Reliable,
|
||||||
|
$hours <= 168 => self::Stale,
|
||||||
|
default => self::Outdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function weight(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Reliable => 0,
|
||||||
|
self::Stale => 1,
|
||||||
|
self::Outdated => 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Reliable => 'Reliable',
|
||||||
|
self::Stale => 'Older price — verify before driving',
|
||||||
|
self::Outdated => 'Outdated — may be inaccurate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
|
|
||||||
case Data;
|
case Data;
|
||||||
|
|
||||||
|
case Forecasting;
|
||||||
|
|
||||||
case System;
|
case System;
|
||||||
|
|
||||||
public function getLabel(): string
|
public function getLabel(): string
|
||||||
@@ -20,6 +22,7 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Users => 'Users',
|
self::Users => 'Users',
|
||||||
self::Data => 'Data',
|
self::Data => 'Data',
|
||||||
|
self::Forecasting => 'Forecasting',
|
||||||
self::System => 'System',
|
self::System => 'System',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -29,6 +32,7 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Users => 'heroicon-o-users',
|
self::Users => 'heroicon-o-users',
|
||||||
self::Data => 'heroicon-o-circle-stack',
|
self::Data => 'heroicon-o-circle-stack',
|
||||||
|
self::Forecasting => null,
|
||||||
self::System => 'heroicon-o-cog-6-tooth',
|
self::System => 'heroicon-o-cog-6-tooth',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal file
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\Backtests\Pages\ListBacktests;
|
||||||
|
use App\Filament\Resources\Backtests\Pages\ViewBacktest;
|
||||||
|
use App\Filament\Resources\Backtests\Schemas\BacktestInfolist;
|
||||||
|
use App\Filament\Resources\Backtests\Tables\BacktestsTable;
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class BacktestResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Backtest::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Backtests';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return BacktestInfolist::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return BacktestsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit(Model $record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete(Model $record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListBacktests::route('/'),
|
||||||
|
'view' => ViewBacktest::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Backtests\BacktestResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBacktests extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BacktestResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Backtests\BacktestResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewBacktest extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BacktestResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests\Schemas;
|
||||||
|
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use Filament\Infolists\Components\IconEntry;
|
||||||
|
use Filament\Infolists\Components\KeyValueEntry;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class BacktestInfolist
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Section::make('Run')->columns(3)->schema([
|
||||||
|
TextEntry::make('model_version')->columnSpanFull(),
|
||||||
|
TextEntry::make('directional_accuracy')
|
||||||
|
->label('Accuracy')
|
||||||
|
->state(fn (Backtest $record): string => $record->directional_accuracy === null
|
||||||
|
? '—'
|
||||||
|
: round((float) $record->directional_accuracy, 1).'%'),
|
||||||
|
TextEntry::make('mae_pence')
|
||||||
|
->label('MAE')
|
||||||
|
->state(fn (Backtest $record): string => $record->mae_pence === null
|
||||||
|
? '—'
|
||||||
|
: number_format((float) $record->mae_pence, 2).'p'),
|
||||||
|
IconEntry::make('leak_suspected')
|
||||||
|
->label('Leak suspected')
|
||||||
|
->boolean()
|
||||||
|
->trueColor('danger'),
|
||||||
|
TextEntry::make('train_start')->date('d M Y'),
|
||||||
|
TextEntry::make('train_end')->date('d M Y'),
|
||||||
|
TextEntry::make('eval_start')->date('d M Y'),
|
||||||
|
TextEntry::make('eval_end')->date('d M Y'),
|
||||||
|
TextEntry::make('ran_at')->dateTime('d M Y H:i'),
|
||||||
|
]),
|
||||||
|
Section::make('Calibration table')
|
||||||
|
->description('Empirical hit rate per magnitude bin from the eval window.')
|
||||||
|
->schema([
|
||||||
|
KeyValueEntry::make('calibration_table')
|
||||||
|
->hiddenLabel()
|
||||||
|
->keyLabel('Magnitude bin')
|
||||||
|
->valueLabel('Empirical hit rate')
|
||||||
|
->state(fn (Backtest $record): array => collect($record->calibration_table ?? [])
|
||||||
|
->mapWithKeys(fn ($value, $key) => [$key => round((float) $value * 100, 1).'%'])
|
||||||
|
->all())
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
Section::make('Feature spec')->schema([
|
||||||
|
KeyValueEntry::make('features_json')
|
||||||
|
->hiddenLabel()
|
||||||
|
->state(fn (Backtest $record): array => self::flattenForKeyValue($record->features_json))
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
Section::make('Coefficients')
|
||||||
|
->visible(fn (Backtest $record) => $record->coefficients_json !== null)
|
||||||
|
->collapsed()
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('coefficients_json')
|
||||||
|
->hiddenLabel()
|
||||||
|
->state(fn (Backtest $record): string => json_encode(
|
||||||
|
$record->coefficients_json,
|
||||||
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||||
|
) ?: '')
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KeyValueEntry expects a flat string-keyed map, so collapse nested arrays
|
||||||
|
* into JSON strings rather than dropping them.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|null $features
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected static function flattenForKeyValue(?array $features): array
|
||||||
|
{
|
||||||
|
return collect($features ?? [])
|
||||||
|
->mapWithKeys(fn ($value, $key) => [
|
||||||
|
(string) $key => is_scalar($value)
|
||||||
|
? (string) $value
|
||||||
|
: (json_encode($value, JSON_UNESCAPED_SLASHES) ?: ''),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal file
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests\Tables;
|
||||||
|
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class BacktestsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('model_version')
|
||||||
|
->searchable()
|
||||||
|
->limit(32)
|
||||||
|
->tooltip(fn (Backtest $record) => strlen($record->model_version) > 32 ? $record->model_version : null),
|
||||||
|
TextColumn::make('directional_accuracy')
|
||||||
|
->label('Accuracy')
|
||||||
|
->state(fn (Backtest $record): string => $record->directional_accuracy === null
|
||||||
|
? '—'
|
||||||
|
: round((float) $record->directional_accuracy, 1).'%')
|
||||||
|
->color(fn (Backtest $record) => self::accuracyColor($record))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('mae_pence')
|
||||||
|
->label('MAE')
|
||||||
|
->state(fn (Backtest $record): string => $record->mae_pence === null
|
||||||
|
? '—'
|
||||||
|
: number_format((float) $record->mae_pence, 2).'p')
|
||||||
|
->sortable(),
|
||||||
|
IconColumn::make('leak_suspected')
|
||||||
|
->label('Leak?')
|
||||||
|
->boolean()
|
||||||
|
->trueColor('danger'),
|
||||||
|
TextColumn::make('eval_start')
|
||||||
|
->date('d M Y')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('eval_end')
|
||||||
|
->date('d M Y')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('ran_at')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('ran_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
Filter::make('leak_suspected')
|
||||||
|
->label('Suspicious accuracy (leak suspected)')
|
||||||
|
->toggle()
|
||||||
|
->query(fn (Builder $query) => $query->where('leak_suspected', true)),
|
||||||
|
Filter::make('below_ship_gate')
|
||||||
|
->label('Below ship gate')
|
||||||
|
->toggle()
|
||||||
|
->query(fn (Builder $query) => $query->where('directional_accuracy', '<', 62)),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
ViewAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function accuracyColor(Backtest $record): ?string
|
||||||
|
{
|
||||||
|
if ($record->directional_accuracy === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accuracy = (float) $record->directional_accuracy;
|
||||||
|
|
||||||
|
if ($accuracy > 75 && $record->leak_suspected) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($accuracy < 60) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($accuracy < 62) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($accuracy <= 75) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ namespace App\Filament\Resources\BrentPriceResource\Pages;
|
|||||||
|
|
||||||
use App\Filament\Resources\BrentPriceResource;
|
use App\Filament\Resources\BrentPriceResource;
|
||||||
use App\Filament\Widgets\BrentPriceChartWidget;
|
use App\Filament\Widgets\BrentPriceChartWidget;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
class ListBrentPrices extends ListRecords
|
class ListBrentPrices extends ListRecords
|
||||||
{
|
{
|
||||||
@@ -12,7 +15,30 @@ class ListBrentPrices extends ListRecords
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [
|
||||||
|
Action::make('fetchPrices')
|
||||||
|
->label('Fetch Prices Now')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Fetch latest Brent prices?')
|
||||||
|
->modalDescription('Pulls the latest Brent crude prices from EIA (falls back to FRED if EIA is unavailable).')
|
||||||
|
->action(function () {
|
||||||
|
$result = Artisan::call('oil:fetch');
|
||||||
|
|
||||||
|
if ($result === 0) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Brent prices fetched successfully')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('Fetch failed')
|
||||||
|
->body('Both EIA and FRED failed. Check API Logs for details.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
|
|||||||
@@ -1,141 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Filament\NavigationGroup;
|
|
||||||
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
|
|
||||||
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
|
|
||||||
use App\Models\PricePrediction;
|
|
||||||
use Filament\Actions\ViewAction;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Filters\Filter;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class OilPredictionResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = PricePrediction::class;
|
|
||||||
|
|
||||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Oil Predictions';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 3;
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('predicted_for')
|
|
||||||
->date('d M Y')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('source')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
|
|
||||||
PredictionSource::Llm => 'LLM',
|
|
||||||
PredictionSource::LlmWithContext => 'LLM + Context',
|
|
||||||
PredictionSource::Ewma => 'EWMA',
|
|
||||||
})
|
|
||||||
->color(fn (PredictionSource $state) => match ($state) {
|
|
||||||
PredictionSource::Llm => 'success',
|
|
||||||
PredictionSource::LlmWithContext => 'warning',
|
|
||||||
PredictionSource::Ewma => 'info',
|
|
||||||
}),
|
|
||||||
TextColumn::make('direction')
|
|
||||||
->badge()
|
|
||||||
->color(fn (TrendDirection $state) => match ($state) {
|
|
||||||
TrendDirection::Rising => 'danger',
|
|
||||||
TrendDirection::Falling => 'success',
|
|
||||||
TrendDirection::Flat => 'gray',
|
|
||||||
}),
|
|
||||||
TextColumn::make('confidence')
|
|
||||||
->suffix('%')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('reasoning')
|
|
||||||
->limit(60)
|
|
||||||
->placeholder('—'),
|
|
||||||
TextColumn::make('generated_at')
|
|
||||||
->dateTime('d M Y H:i')
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->defaultSort('predicted_for', 'desc')
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('source')
|
|
||||||
->options([
|
|
||||||
PredictionSource::Llm->value => 'LLM',
|
|
||||||
PredictionSource::LlmWithContext->value => 'LLM + Context',
|
|
||||||
PredictionSource::Ewma->value => 'EWMA',
|
|
||||||
]),
|
|
||||||
SelectFilter::make('direction')
|
|
||||||
->options([
|
|
||||||
TrendDirection::Rising->value => 'Rising',
|
|
||||||
TrendDirection::Falling->value => 'Falling',
|
|
||||||
TrendDirection::Flat->value => 'Flat',
|
|
||||||
]),
|
|
||||||
Filter::make('predicted_for')
|
|
||||||
->schema([
|
|
||||||
DatePicker::make('from')->label('From'),
|
|
||||||
DatePicker::make('until')->label('Until'),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data) {
|
|
||||||
$query
|
|
||||||
->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
|
|
||||||
->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->recordActions([
|
|
||||||
ViewAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->components([
|
|
||||||
Section::make('Prediction')->schema([
|
|
||||||
TextEntry::make('predicted_for')->date('d M Y'),
|
|
||||||
TextEntry::make('source')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
|
|
||||||
PredictionSource::Llm => 'LLM',
|
|
||||||
PredictionSource::LlmWithContext => 'LLM + Context',
|
|
||||||
PredictionSource::Ewma => 'EWMA',
|
|
||||||
})
|
|
||||||
->color(fn (PredictionSource $state) => match ($state) {
|
|
||||||
PredictionSource::Llm => 'success',
|
|
||||||
PredictionSource::LlmWithContext => 'warning',
|
|
||||||
PredictionSource::Ewma => 'info',
|
|
||||||
}),
|
|
||||||
TextEntry::make('direction')
|
|
||||||
->badge()
|
|
||||||
->color(fn (TrendDirection $state) => match ($state) {
|
|
||||||
TrendDirection::Rising => 'danger',
|
|
||||||
TrendDirection::Falling => 'success',
|
|
||||||
TrendDirection::Flat => 'gray',
|
|
||||||
}),
|
|
||||||
TextEntry::make('confidence')->suffix('%'),
|
|
||||||
TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
|
|
||||||
])->columns(3),
|
|
||||||
Section::make('Reasoning')->schema([
|
|
||||||
TextEntry::make('reasoning')
|
|
||||||
->columnSpanFull()
|
|
||||||
->placeholder('No reasoning recorded'),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => ListOilPredictions::route('/'),
|
|
||||||
'view' => ViewOilPrediction::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OilPredictionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OilPredictionResource;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
|
|
||||||
class ListOilPredictions extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = OilPredictionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('runPrediction')
|
|
||||||
->label('Run Prediction Now')
|
|
||||||
->icon('heroicon-o-cpu-chip')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Run oil price prediction?')
|
|
||||||
->modalDescription('This will fetch the latest FRED prices and generate a new prediction. May take a few seconds.')
|
|
||||||
->action(function () {
|
|
||||||
$result = Artisan::call('oil:predict', ['--fetch' => true]);
|
|
||||||
|
|
||||||
if ($result === 0) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Prediction generated successfully')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
} else {
|
|
||||||
Notification::make()
|
|
||||||
->title('Prediction failed')
|
|
||||||
->body('Check API Logs for details.')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OilPredictionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OilPredictionResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewOilPrediction extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = OilPredictionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
app/Filament/Resources/Plans/Pages/EditPlan.php
Normal file
24
app/Filament/Resources/Plans/Pages/EditPlan.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Plans\PlanResource;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class EditPlan extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
if (Cache::supportsTags()) {
|
||||||
|
Cache::tags(['plans'])->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Plans/Pages/ListPlans.php
Normal file
16
app/Filament/Resources/Plans/Pages/ListPlans.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Plans\PlanResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPlans extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Filament/Resources/Plans/PlanResource.php
Normal file
40
app/Filament/Resources/Plans/PlanResource.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\Plans\Pages\EditPlan;
|
||||||
|
use App\Filament\Resources\Plans\Pages\ListPlans;
|
||||||
|
use App\Filament\Resources\Plans\Schemas\PlanForm;
|
||||||
|
use App\Filament\Resources\Plans\Tables\PlansTable;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PlanResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Plan::class;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::System;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return PlanForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return PlansTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListPlans::route('/'),
|
||||||
|
'edit' => EditPlan::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Filament/Resources/Plans/Schemas/PlanForm.php
Normal file
99
app/Filament/Resources/Plans/Schemas/PlanForm.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class PlanForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Fuel Types')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('max_fuel_types')
|
||||||
|
->label('Max fuel types')
|
||||||
|
->helperText('Leave blank for unlimited.')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->nullable(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Email')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('email_enabled')
|
||||||
|
->label('Enabled'),
|
||||||
|
Select::make('email_frequency')
|
||||||
|
->label('Frequency')
|
||||||
|
->options([
|
||||||
|
'weekly_digest' => 'Weekly digest',
|
||||||
|
'daily' => 'Daily',
|
||||||
|
'triggered' => 'Triggered',
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Push')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('push_enabled')
|
||||||
|
->label('Enabled'),
|
||||||
|
Select::make('push_frequency')
|
||||||
|
->label('Frequency')
|
||||||
|
->options([
|
||||||
|
'none' => 'None (disabled)',
|
||||||
|
'daily' => 'Daily',
|
||||||
|
'triggered' => 'Triggered',
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('WhatsApp')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('whatsapp_enabled')
|
||||||
|
->label('Enabled'),
|
||||||
|
TextInput::make('whatsapp_daily_limit')
|
||||||
|
->label('Daily limit')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->required(),
|
||||||
|
TextInput::make('whatsapp_scheduled_updates')
|
||||||
|
->label('Scheduled updates per day')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('SMS')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('sms_enabled')
|
||||||
|
->label('Enabled'),
|
||||||
|
TextInput::make('sms_daily_limit')
|
||||||
|
->label('Daily limit')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Features')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('ai_predictions')
|
||||||
|
->label('AI predictions'),
|
||||||
|
Toggle::make('price_threshold')
|
||||||
|
->label('Price threshold alerts'),
|
||||||
|
Toggle::make('score_alerts')
|
||||||
|
->label('Score change alerts'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Filament/Resources/Plans/Tables/PlansTable.php
Normal file
40
app/Filament/Resources/Plans/Tables/PlansTable.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PlansTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label('Tier')
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('email_frequency')
|
||||||
|
->label('Email')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('sms_daily_limit')
|
||||||
|
->label('SMS/day')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('whatsapp_daily_limit')
|
||||||
|
->label('WhatsApp/day')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('max_fuel_types')
|
||||||
|
->label('Fuel types')
|
||||||
|
->placeholder('Unlimited'),
|
||||||
|
IconColumn::make('active')
|
||||||
|
->boolean(),
|
||||||
|
])
|
||||||
|
->defaultSort('id', 'asc')
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,29 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use App\Enums\PlanTier;
|
||||||
use App\Filament\NavigationGroup;
|
use App\Filament\NavigationGroup;
|
||||||
use App\Filament\Resources\UserResource\Pages\EditUser;
|
use App\Filament\Resources\UserResource\Pages\EditUser;
|
||||||
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
||||||
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class UserResource extends Resource
|
class UserResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -28,12 +37,89 @@ class UserResource extends Resource
|
|||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
Toggle::make('is_admin')
|
Section::make('Profile')->columns(2)->schema([
|
||||||
->label('Admin')
|
TextInput::make('name')
|
||||||
->helperText('Grants access to this admin panel.'),
|
->required()
|
||||||
TextInput::make('postcode')
|
->maxLength(255),
|
||||||
->label('Postcode')
|
TextInput::make('email')
|
||||||
->maxLength(8),
|
->email()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('postcode')
|
||||||
|
->maxLength(8),
|
||||||
|
Select::make('preferred_fuel_type')
|
||||||
|
->label('Preferred fuel type')
|
||||||
|
->options(collect(FuelType::cases())
|
||||||
|
->mapWithKeys(fn (FuelType $t) => [$t->value => $t->label()])
|
||||||
|
->all())
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Access')->columns(2)->schema([
|
||||||
|
Toggle::make('is_admin')
|
||||||
|
->label('Admin')
|
||||||
|
->helperText('Grants access to this admin panel.'),
|
||||||
|
DateTimePicker::make('email_verified_at')
|
||||||
|
->label('Email verified at'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Subscription')->columns(2)->schema([
|
||||||
|
Select::make('tier')
|
||||||
|
->label('Tier')
|
||||||
|
->options([
|
||||||
|
PlanTier::Free->value => 'Free',
|
||||||
|
PlanTier::Basic->value => 'Basic',
|
||||||
|
PlanTier::Plus->value => 'Plus',
|
||||||
|
PlanTier::Pro->value => 'Pro',
|
||||||
|
])
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->dehydrated(false)
|
||||||
|
->afterStateHydrated(fn (Select $component, ?User $record) => $component
|
||||||
|
->state($record ? PlanFeatures::for($record)->tier() : PlanTier::Free->value)),
|
||||||
|
Select::make('cadence')
|
||||||
|
->label('Billing Cadence')
|
||||||
|
->options([
|
||||||
|
'monthly' => 'Monthly',
|
||||||
|
'annual' => 'Annual',
|
||||||
|
])
|
||||||
|
->default('monthly')
|
||||||
|
->dehydrated(false)
|
||||||
|
->visible(fn (callable $get): bool => ($get('tier') ?? '') !== PlanTier::Free->value)
|
||||||
|
->helperText('Only applies when assigning a paid tier. Real Stripe subscriptions are not modified.'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Security')->columns(2)->schema([
|
||||||
|
DateTimePicker::make('two_factor_confirmed_at')
|
||||||
|
->label('2FA confirmed at')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('password')
|
||||||
|
->label('Set new password')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->minLength(8)
|
||||||
|
->dehydrated(fn (?string $state): bool => filled($state))
|
||||||
|
->dehydrateStateUsing(fn (string $state): string => bcrypt($state))
|
||||||
|
->helperText('Leave blank to keep current password.')
|
||||||
|
->afterStateHydrated(fn (TextInput $component) => $component->state(null)),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Billing')->columns(3)->schema([
|
||||||
|
TextInput::make('stripe_id')
|
||||||
|
->label('Stripe customer ID')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('pm_type')
|
||||||
|
->label('Payment method')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('pm_last_four')
|
||||||
|
->label('Card last 4')
|
||||||
|
->disabled(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Timestamps')->columns(2)->schema([
|
||||||
|
DateTimePicker::make('created_at')->disabled(),
|
||||||
|
DateTimePicker::make('updated_at')->disabled(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +130,16 @@ class UserResource extends Resource
|
|||||||
TextColumn::make('name')->searchable()->sortable(),
|
TextColumn::make('name')->searchable()->sortable(),
|
||||||
TextColumn::make('email')->searchable()->sortable(),
|
TextColumn::make('email')->searchable()->sortable(),
|
||||||
TextColumn::make('postcode')->placeholder('—'),
|
TextColumn::make('postcode')->placeholder('—'),
|
||||||
|
TextColumn::make('tier')
|
||||||
|
->label('Tier')
|
||||||
|
->state(fn (User $record): string => PlanFeatures::for($record)->tier())
|
||||||
|
->badge()
|
||||||
|
->colors([
|
||||||
|
'gray' => 'free',
|
||||||
|
'primary' => 'basic',
|
||||||
|
'warning' => 'plus',
|
||||||
|
'success' => 'pro',
|
||||||
|
]),
|
||||||
IconColumn::make('is_admin')
|
IconColumn::make('is_admin')
|
||||||
->label('Admin')
|
->label('Admin')
|
||||||
->boolean(),
|
->boolean(),
|
||||||
@@ -62,6 +158,60 @@ class UserResource extends Resource
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel any existing admin-granted subscription, then (if a paid tier
|
||||||
|
* was requested) insert a fresh synthetic active subscription row.
|
||||||
|
*/
|
||||||
|
public static function applyTier(User $user, string $tier, string $cadence): void
|
||||||
|
{
|
||||||
|
$hasRealStripeSubscription = $user->subscriptions()
|
||||||
|
->where('stripe_id', 'not like', 'admin_%')
|
||||||
|
->whereIn('stripe_status', ['active', 'trialing', 'past_due'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasRealStripeSubscription) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"User {$user->email} has an active Stripe subscription — modify it through the Stripe dashboard, not the admin panel."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->subscriptions()->where('stripe_id', 'like', 'admin_%')->delete();
|
||||||
|
|
||||||
|
if ($tier === PlanTier::Free->value) {
|
||||||
|
self::bustPlanCache($user);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?: "price_admin_{$tier}_{$cadence}";
|
||||||
|
|
||||||
|
$planColumn = $cadence === 'annual' ? 'stripe_price_id_annual' : 'stripe_price_id_monthly';
|
||||||
|
$plan = Plan::where('name', $tier)->first();
|
||||||
|
|
||||||
|
if ($plan && empty($plan->{$planColumn})) {
|
||||||
|
$plan->update([$planColumn => $priceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->subscriptions()->create([
|
||||||
|
'type' => 'default',
|
||||||
|
'stripe_id' => 'admin_'.Str::uuid(),
|
||||||
|
'stripe_status' => 'active',
|
||||||
|
'stripe_price' => $priceId,
|
||||||
|
'quantity' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::bustPlanCache($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function bustPlanCache(User $user): void
|
||||||
|
{
|
||||||
|
if (Cache::supportsTags()) {
|
||||||
|
Cache::tags(['plans'])->flush();
|
||||||
|
} else {
|
||||||
|
Cache::forget("plan_for_user_{$user->id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,14 +3,52 @@
|
|||||||
namespace App\Filament\Resources\UserResource\Pages;
|
namespace App\Filament\Resources\UserResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
|
use App\Filament\Resources\UserResource\Widgets\MissedNotificationsOverview;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditUser extends EditRecord
|
class EditUser extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
MissedNotificationsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaderWidgetsColumns(): int|array
|
||||||
|
{
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->record;
|
||||||
|
|
||||||
|
$tier = $this->data['tier'] ?? null;
|
||||||
|
$cadence = $this->data['cadence'] ?? 'monthly';
|
||||||
|
|
||||||
|
if ($tier === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
UserResource::applyTier($user, $tier, $cadence);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Tier not changed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\UserResource\Widgets;
|
||||||
|
|
||||||
|
use App\Models\NotificationLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class MissedNotificationsOverview extends StatsOverviewWidget
|
||||||
|
{
|
||||||
|
public ?User $record = null;
|
||||||
|
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
if ($this->record === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $this->record->id;
|
||||||
|
|
||||||
|
$missedTodayByChannel = fn (string $channel): int => NotificationLog::where('user_id', $userId)
|
||||||
|
->where('channel', $channel)
|
||||||
|
->where('sent', false)
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$missedThisMonth = NotificationLog::where('user_id', $userId)
|
||||||
|
->where('sent', false)
|
||||||
|
->whereMonth('created_at', now()->month)
|
||||||
|
->whereYear('created_at', now()->year)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('SMS missed today', $missedTodayByChannel('sms'))
|
||||||
|
->color($missedTodayByChannel('sms') > 0 ? 'warning' : 'gray'),
|
||||||
|
Stat::make('WhatsApp missed today', $missedTodayByChannel('whatsapp'))
|
||||||
|
->color($missedTodayByChannel('whatsapp') > 0 ? 'warning' : 'gray'),
|
||||||
|
Stat::make('Total missed this month', $missedThisMonth)
|
||||||
|
->color($missedThisMonth > 0 ? 'warning' : 'gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateWatchedEvent extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WatchedEventResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditWatchedEvent extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WatchedEventResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListWatchedEvents extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WatchedEventResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class WatchedEventForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
TextInput::make('label')
|
||||||
|
->required()
|
||||||
|
->maxLength(128)
|
||||||
|
->helperText('Short geopolitical event label, e.g. "Iran tensions Apr–May 2026".'),
|
||||||
|
DateTimePicker::make('starts_at')
|
||||||
|
->label('Starts at')
|
||||||
|
->required(),
|
||||||
|
DateTimePicker::make('ends_at')
|
||||||
|
->label('Ends at')
|
||||||
|
->required()
|
||||||
|
->after('starts_at'),
|
||||||
|
Textarea::make('notes')
|
||||||
|
->maxLength(2000)
|
||||||
|
->rows(4)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Tables;
|
||||||
|
|
||||||
|
use App\Models\WatchedEvent;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class WatchedEventsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('label')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->limit(60)
|
||||||
|
->tooltip(fn (WatchedEvent $record) => strlen($record->label) > 60 ? $record->label : null),
|
||||||
|
TextColumn::make('starts_at')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('ends_at')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->badge()
|
||||||
|
->state(fn (WatchedEvent $record): string => self::isActive($record) ? 'Active' : 'Inactive')
|
||||||
|
->color(fn (string $state) => $state === 'Active' ? 'success' : 'gray'),
|
||||||
|
TextColumn::make('notes')
|
||||||
|
->limit(50)
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('starts_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
Filter::make('currently_active')
|
||||||
|
->label('Currently active')
|
||||||
|
->toggle()
|
||||||
|
->query(fn (Builder $query) => $query
|
||||||
|
->where('starts_at', '<=', now())
|
||||||
|
->where('ends_at', '>=', now())),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function isActive(WatchedEvent $record): bool
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
return $record->starts_at !== null
|
||||||
|
&& $record->ends_at !== null
|
||||||
|
&& $record->starts_at->lessThanOrEqualTo($now)
|
||||||
|
&& $record->ends_at->greaterThanOrEqualTo($now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Pages\CreateWatchedEvent;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Pages\EditWatchedEvent;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Pages\ListWatchedEvents;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Schemas\WatchedEventForm;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Tables\WatchedEventsTable;
|
||||||
|
use App\Models\WatchedEvent;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class WatchedEventResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = WatchedEvent::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedFlag;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Watched Events';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return WatchedEventForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return WatchedEventsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListWatchedEvents::route('/'),
|
||||||
|
'create' => CreateWatchedEvent::route('/create'),
|
||||||
|
'edit' => EditWatchedEvent::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WeeklyForecasts\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListWeeklyForecasts extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WeeklyForecastResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WeeklyForecasts\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewWeeklyForecast extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WeeklyForecastResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user