Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08afafe6bd | ||
|
|
685a84e159 | ||
|
|
4c0017cb91 | ||
|
|
347a71154b |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,4 +24,5 @@ yarn-error.log
|
|||||||
/.zed
|
/.zed
|
||||||
/.tmp/
|
/.tmp/
|
||||||
/.worktrees/
|
/.worktrees/
|
||||||
|
/.deploy-last-commit
|
||||||
/ONSPD_Online_Latest_Centroids_*.csv
|
/ONSPD_Online_Latest_Centroids_*.csv
|
||||||
|
|||||||
45
deploy.sh
45
deploy.sh
@@ -6,10 +6,11 @@
|
|||||||
# ./deploy.sh v0.1.3 # deploy a specific tag
|
# ./deploy.sh v0.1.3 # deploy a specific tag
|
||||||
#
|
#
|
||||||
# It puts the site in maintenance mode, updates the code, runs migrations and
|
# It puts the site in maintenance mode, updates the code, runs migrations and
|
||||||
# cache rebuilds, restarts the queue, then brings the site back up. composer
|
# cache rebuilds, restarts the queue, then brings the site back up. The SPA is
|
||||||
# install and npm build only run when their inputs actually changed, so most
|
# always rebuilt; the heavier dependency installs (composer install, npm ci)
|
||||||
# deploys skip them. If any step fails the script aborts and the site stays in
|
# only run when their lockfiles changed or the installed dir is missing. If any
|
||||||
# maintenance mode on purpose — fix the issue, then re-run.
|
# step fails the script aborts and the site stays in maintenance mode on
|
||||||
|
# purpose — fix the issue, then re-run.
|
||||||
#
|
#
|
||||||
# See docs/ops/deployment.md for first-time setup and troubleshooting.
|
# See docs/ops/deployment.md for first-time setup and troubleshooting.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -19,8 +20,13 @@ cd "$(dirname "$0")"
|
|||||||
REF="${1:-main}"
|
REF="${1:-main}"
|
||||||
echo "==> Deploying ref: ${REF}"
|
echo "==> Deploying ref: ${REF}"
|
||||||
|
|
||||||
# Remember the current commit so we can see what changed after checkout.
|
# Baseline for change detection: the last *fully* deployed commit, recorded at
|
||||||
BEFORE="$(git rev-parse HEAD)"
|
# the end of a successful run (falls back to current HEAD the first time). Using
|
||||||
|
# a persisted marker instead of the pre-checkout HEAD keeps the diff honest even
|
||||||
|
# when the same ref is re-deployed or a previous run aborted partway — both of
|
||||||
|
# which otherwise make BEFORE == AFTER and silently skip build/install steps.
|
||||||
|
MARKER=".deploy-last-commit"
|
||||||
|
BEFORE="$(cat "${MARKER}" 2>/dev/null || git rev-parse HEAD)"
|
||||||
|
|
||||||
echo "==> Maintenance mode on"
|
echo "==> Maintenance mode on"
|
||||||
php artisan down --retry=15
|
php artisan down --retry=15
|
||||||
@@ -36,23 +42,29 @@ fi
|
|||||||
AFTER="$(git rev-parse HEAD)"
|
AFTER="$(git rev-parse HEAD)"
|
||||||
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}" || true)"
|
CHANGED="$(git diff --name-only "${BEFORE}" "${AFTER}" || true)"
|
||||||
|
|
||||||
# Reinstall PHP deps only if the lockfile moved.
|
# Reinstall PHP deps only when the lockfile moved or vendor is missing.
|
||||||
if grep -q '^composer\.lock$' <<<"${CHANGED}"; then
|
if grep -q '^composer\.lock$' <<<"${CHANGED}" || [ ! -d vendor ]; then
|
||||||
echo "==> composer.lock changed — installing PHP deps"
|
echo "==> Installing PHP deps (composer.lock changed or vendor missing)"
|
||||||
composer install --no-dev --optimize-autoloader
|
composer install --no-dev --optimize-autoloader
|
||||||
else
|
else
|
||||||
echo "==> composer.lock unchanged — skipping composer install"
|
echo "==> composer.lock unchanged — skipping composer install"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Rebuild the Vue SPA only if frontend sources or the JS lockfile moved.
|
# Install JS deps only when the lockfile moved or node_modules is missing.
|
||||||
if grep -qE '^(package(-lock)?\.json|vite\.config\.|resources/(js|css)/)' <<<"${CHANGED}"; then
|
if grep -qE '^package(-lock)?\.json$' <<<"${CHANGED}" || [ ! -d node_modules ]; then
|
||||||
echo "==> Frontend changed — rebuilding SPA"
|
echo "==> Installing JS deps (lockfile changed or node_modules missing)"
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
|
||||||
else
|
else
|
||||||
echo "==> No frontend changes — skipping npm build"
|
echo "==> JS deps unchanged — skipping npm ci"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Always rebuild the SPA. The build is cheap (a few seconds), and gating it on a
|
||||||
|
# git diff silently shipped stale assets whenever BEFORE == AFTER — re-deploying
|
||||||
|
# the same ref, or re-running after an aborted deploy. Correctness over the few
|
||||||
|
# seconds saved.
|
||||||
|
echo "==> Building SPA"
|
||||||
|
npm run build
|
||||||
|
|
||||||
echo "==> Running migrations"
|
echo "==> Running migrations"
|
||||||
php artisan migrate --force
|
php artisan migrate --force
|
||||||
|
|
||||||
@@ -68,5 +80,10 @@ php artisan queue:restart
|
|||||||
echo "==> Maintenance mode off"
|
echo "==> Maintenance mode off"
|
||||||
php artisan up
|
php artisan up
|
||||||
|
|
||||||
|
# Record the just-deployed commit as the baseline for the next run. Only reached
|
||||||
|
# on full success — `set -e` aborts earlier on any failure, so a broken deploy
|
||||||
|
# never advances the baseline and the next run re-evaluates from the last good one.
|
||||||
|
git rev-parse HEAD > "${MARKER}"
|
||||||
|
|
||||||
echo "==> Deploy complete"
|
echo "==> Deploy complete"
|
||||||
php artisan about
|
php artisan about
|
||||||
|
|||||||
112
docs/superpowers/specs/2026-06-12-inline-fuel-pills-design.md
Normal file
112
docs/superpowers/specs/2026-06-12-inline-fuel-pills-design.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Inline Petrol/Diesel Quick Pills — Design
|
||||||
|
|
||||||
|
Date: 2026-06-12
|
||||||
|
Status: Approved (brainstorm)
|
||||||
|
Branch: `feature/inline-fuel-pills`
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
After a station search, the only way to change fuel type is to open the
|
||||||
|
"Filters" popover, which buries the two everyday fuels — Petrol (`e10`) and
|
||||||
|
Diesel (`b7_standard`) — behind a button. Almost every user wants one of those
|
||||||
|
two. We want them surfaced as one-tap pills directly in the results filter bar,
|
||||||
|
while keeping the four long-tail fuels (Premium `e5`, Prem Diesel `b7_premium`,
|
||||||
|
`b10`, `hvo`) reachable.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **Single file:** `resources/js/components/PostSearchFilters.vue`.
|
||||||
|
- **No backend/API change** — server-side fuel filtering already exists via the
|
||||||
|
`fuel_type` query param on `/api/stations`.
|
||||||
|
- **`Home.vue` unchanged** — the pills reuse the existing `search` event
|
||||||
|
contract the popover already emits.
|
||||||
|
- **`HeroSearch.vue` unchanged** — placement is results-bar only (the hero keeps
|
||||||
|
forwarding whatever `fuelType` is in the URL).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
The top row of the filter bar changes from a single right-aligned "Filters"
|
||||||
|
button to:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ⛽ Petrol ] [ Diesel ] ……………… [ ≡ Filters ▾ ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Two fuel pills left-aligned; the Filters pill pushed right via `ml-auto`.
|
||||||
|
- All three reuse the existing `pill !rounded-xl` class + `is-active` state, so
|
||||||
|
they are visually identical (the user's explicit "same row, same style"
|
||||||
|
requirement).
|
||||||
|
- ⛽ (`lucide:fuel`) icon on Petrol only; Diesel is label-only (matches the
|
||||||
|
user's mock).
|
||||||
|
- The row stays `flex flex-wrap items-center` so it wraps gracefully on narrow
|
||||||
|
screens.
|
||||||
|
|
||||||
|
## Fuel model
|
||||||
|
|
||||||
|
- **Primary fuels (pills):** `e10` → "Petrol", `b7_standard` → "Diesel".
|
||||||
|
- **Long-tail fuels (popover "More fuels"):** every other entry in `FUEL_TYPES`
|
||||||
|
— `e5`, `b7_premium`, `b10`, `hvo`. Derived by filtering `FUEL_TYPES` to
|
||||||
|
exclude the two primary values, so the list stays driven by the shared
|
||||||
|
`window.FUEL_TYPES` source rather than a second hard-coded list.
|
||||||
|
|
||||||
|
The two primary fuel values (`e10`, `b7_standard`) are defined once as a local
|
||||||
|
constant in the component, with the short pill labels ("Petrol" / "Diesel").
|
||||||
|
The canonical enum labels ("Petrol (E10)", "Diesel (B7)") are intentionally not
|
||||||
|
used on the pills — the short forms are a UX shortening.
|
||||||
|
|
||||||
|
## Behaviour
|
||||||
|
|
||||||
|
- Tapping **Petrol** sets `fuelType = 'e10'`; tapping **Diesel** sets
|
||||||
|
`fuelType = 'b7_standard'`. Either triggers an immediate re-search via the
|
||||||
|
existing `watch([fuelType, radius, sort])` — no new emit wiring.
|
||||||
|
- The popover "Fuel" section is relabeled **"More fuels"** and renders only the
|
||||||
|
long-tail fuels. Selecting one re-searches and drops both pill highlights.
|
||||||
|
|
||||||
|
## Highlighting — the selected fuel is always visible
|
||||||
|
|
||||||
|
- `e10` active → **Petrol** pill `is-active`.
|
||||||
|
- `b7_standard` active → **Diesel** pill `is-active`.
|
||||||
|
- A long-tail fuel active → neither pill highlighted, **but** the selected fuel
|
||||||
|
is highlighted inside the "More fuels" grid, and the Filters pill shows its
|
||||||
|
active state + badge. Selection is never ambiguous in any state.
|
||||||
|
|
||||||
|
## Filters badge / active count
|
||||||
|
|
||||||
|
- Fuel contributes to the Filters badge (`activeCount`) **only when a long-tail
|
||||||
|
fuel is active** — the pills already display Petrol/Diesel selection, so those
|
||||||
|
need no badge.
|
||||||
|
- Radius, sort, and brand contribute to `activeCount` as before.
|
||||||
|
- `hasActive` (which controls the "Clear all" affordance) likewise treats fuel
|
||||||
|
as active only when a long-tail fuel is selected.
|
||||||
|
|
||||||
|
## Clear all
|
||||||
|
|
||||||
|
- Resets radius / sort / brand to their defaults.
|
||||||
|
- If a long-tail fuel is selected, snaps fuel back to Petrol (`e10`) so the badge
|
||||||
|
clears.
|
||||||
|
- A Petrol/Diesel pill choice is left alone (the default is Petrol anyway).
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- The two pills form a `role="radiogroup"` labelled "Fuel" with `aria-checked`
|
||||||
|
on each, consistent with the existing radio pattern already used in this
|
||||||
|
component for radius/sort/brand.
|
||||||
|
- The "More fuels" grid remains its own radiogroup.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- No new filter criteria (supermarket-only, open-now, amenities, price ceiling).
|
||||||
|
- No hero fuel selector.
|
||||||
|
- No server-side brand filtering (brand stays a client-side filter in `Home.vue`).
|
||||||
|
- No change to the radius or sort options.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- `npm run build` must succeed (no Vite/compile regressions).
|
||||||
|
- Behavioural verification of: Petrol/Diesel toggles re-search and highlight;
|
||||||
|
selecting a long-tail fuel via the popover shows the badge with neither pill
|
||||||
|
highlighted; "Clear all" resets radius/sort/brand and snaps a long-tail fuel
|
||||||
|
back to Petrol.
|
||||||
|
- The exact mechanism (Pest 4 browser test vs. a JS unit harness vs. manual) is
|
||||||
|
decided in the implementation plan after confirming what frontend test
|
||||||
|
infrastructure exists in the repo.
|
||||||
@@ -1,12 +1,27 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="popoverRoot">
|
<div ref="popoverRoot">
|
||||||
<div class="flex flex-wrap items-center justify-end gap-2 md:gap-2.5 py-2 border-b border-zinc-200">
|
<div class="flex flex-wrap items-center gap-2 md:gap-2.5 py-2 border-b border-zinc-200">
|
||||||
|
<div class="grid grid-cols-2 gap-2 md:gap-2.5" role="radiogroup" aria-label="Fuel">
|
||||||
|
<button
|
||||||
|
v-for="fuel in PRIMARY_FUELS"
|
||||||
|
:key="fuel.value"
|
||||||
|
:aria-checked="fuelType === fuel.value"
|
||||||
|
:class="{ 'is-active': fuelType === fuel.value }"
|
||||||
|
class="pill !rounded-xl w-full justify-center"
|
||||||
|
role="radio"
|
||||||
|
type="button"
|
||||||
|
@click="fuelType = fuel.value"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium">{{ fuel.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
:class="{ 'is-active': activeCount > 0 || open }"
|
:class="{ 'is-active': activeCount > 0 || open }"
|
||||||
aria-controls="post-search-filters-panel"
|
aria-controls="post-search-filters-panel"
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
class="pill !rounded-xl"
|
class="pill !rounded-xl ml-auto"
|
||||||
type="button"
|
type="button"
|
||||||
@click="open = !open"
|
@click="open = !open"
|
||||||
>
|
>
|
||||||
@@ -35,11 +50,11 @@
|
|||||||
aria-label="Filters"
|
aria-label="Filters"
|
||||||
class="mt-3 rounded-2xl border border-zinc-200 bg-white shadow-sm p-4 space-y-4 max-h-[70vh] overflow-y-auto"
|
class="mt-3 rounded-2xl border border-zinc-200 bg-white shadow-sm p-4 space-y-4 max-h-[70vh] overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div>
|
<div v-if="SECONDARY_FUELS.length">
|
||||||
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">Fuel</span>
|
<span class="block text-[10px] font-mono uppercase tracking-widest text-zinc-500 mb-2">More fuels</span>
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2" role="radiogroup" aria-label="Fuel type">
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2" role="radiogroup" aria-label="More fuel types">
|
||||||
<button
|
<button
|
||||||
v-for="fuel in FUEL_TYPES"
|
v-for="fuel in SECONDARY_FUELS"
|
||||||
:key="fuel.value"
|
:key="fuel.value"
|
||||||
:aria-checked="fuelType === fuel.value"
|
:aria-checked="fuelType === fuel.value"
|
||||||
:class="{ 'is-active': fuelType === fuel.value }"
|
:class="{ 'is-active': fuelType === fuel.value }"
|
||||||
@@ -152,6 +167,18 @@ const DEFAULTS = Object.freeze({
|
|||||||
sort: 'reliable',
|
sort: 'reliable',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The two everyday fuels are surfaced as equal-width quick pills outside the
|
||||||
|
// popover; everything else in the shared FUEL_TYPES source stays under
|
||||||
|
// "More fuels".
|
||||||
|
const PRIMARY_FUEL_VALUES = Object.freeze(['e10', 'b7_standard'])
|
||||||
|
|
||||||
|
const PRIMARY_FUELS = Object.freeze([
|
||||||
|
{ value: 'e10', label: 'Petrol' },
|
||||||
|
{ value: 'b7_standard', label: 'Diesel' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const SECONDARY_FUELS = FUEL_TYPES.filter((fuel) => !PRIMARY_FUEL_VALUES.includes(fuel.value))
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ label: 'Reliable', value: 'reliable', icon: 'lucide:shield-check' },
|
{ label: 'Reliable', value: 'reliable', icon: 'lucide:shield-check' },
|
||||||
{ label: 'Price', value: 'price', icon: 'lucide:pound-sterling' },
|
{ label: 'Price', value: 'price', icon: 'lucide:pound-sterling' },
|
||||||
@@ -195,8 +222,12 @@ watch([fuelType, radius, sort], () => {
|
|||||||
if (postcode.value.trim() || coords.value) emitSearch()
|
if (postcode.value.trim() || coords.value) emitSearch()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Petrol/Diesel are shown as pills, so only a long-tail fuel counts as a
|
||||||
|
// "hidden" filter on the Filters badge.
|
||||||
|
const isSecondaryFuel = computed(() => !PRIMARY_FUEL_VALUES.includes(fuelType.value))
|
||||||
|
|
||||||
const hasActive = computed(() => (
|
const hasActive = computed(() => (
|
||||||
fuelType.value !== DEFAULTS.fuelType
|
isSecondaryFuel.value
|
||||||
|| radius.value !== DEFAULTS.radius
|
|| radius.value !== DEFAULTS.radius
|
||||||
|| sort.value !== DEFAULTS.sort
|
|| sort.value !== DEFAULTS.sort
|
||||||
|| Boolean(props.brandFilter)
|
|| Boolean(props.brandFilter)
|
||||||
@@ -204,7 +235,7 @@ const hasActive = computed(() => (
|
|||||||
|
|
||||||
const activeCount = computed(() => {
|
const activeCount = computed(() => {
|
||||||
let count = 0
|
let count = 0
|
||||||
if (fuelType.value !== DEFAULTS.fuelType) count++
|
if (isSecondaryFuel.value) count++
|
||||||
if (radius.value !== DEFAULTS.radius) count++
|
if (radius.value !== DEFAULTS.radius) count++
|
||||||
if (sort.value !== DEFAULTS.sort) count++
|
if (sort.value !== DEFAULTS.sort) count++
|
||||||
if (props.brandFilter) count++
|
if (props.brandFilter) count++
|
||||||
@@ -212,7 +243,8 @@ const activeCount = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
fuelType.value = DEFAULTS.fuelType
|
// Leave a Petrol/Diesel pill choice alone; only snap a long-tail fuel back.
|
||||||
|
if (isSecondaryFuel.value) fuelType.value = DEFAULTS.fuelType
|
||||||
radius.value = DEFAULTS.radius
|
radius.value = DEFAULTS.radius
|
||||||
sort.value = DEFAULTS.sort
|
sort.value = DEFAULTS.sort
|
||||||
if (props.brandFilter) emit('update:brandFilter', '')
|
if (props.brandFilter) emit('update:brandFilter', '')
|
||||||
|
|||||||
Reference in New Issue
Block a user