First
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-04-29 00:06:36 +01:00
commit 6f64e1a530
97 changed files with 3179 additions and 0 deletions
View File
+2
View File
@@ -0,0 +1,2 @@
//= link_tree ../images
//= link_tree ../builds
View File
@@ -0,0 +1 @@
@import "tailwindcss";
@@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end
@@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end
+38
View File
@@ -0,0 +1,38 @@
class AdminsController < ApplicationController
PASSWORD_PARTS = %w[GEAR AXLE TURBO PARK].freeze
def show
@unlocked = admin_unlocked?
end
def create
if submitted_password == admin_password
session[:admin_unlocked] = true
redirect_to admin_path, notice: "Admin Panel Unlocked"
else
session[:admin_unlocked] = false
@unlocked = false
flash.now[:alert] = "That passphrase did not unlock anything. Check the advert again."
render :show, status: :unprocessable_entity
end
end
def destroy
session.delete(:admin_unlocked)
redirect_to admin_path, notice: "Admin session cleared."
end
private
def admin_password
PASSWORD_PARTS.join("-")
end
def admin_unlocked?
session[:admin_unlocked] == true
end
def submitted_password
params.fetch(:password, "").upcase.gsub(/\s+/, "")
end
end
@@ -0,0 +1,4 @@
class ApplicationController < ActionController::Base
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
end
View File
+88
View File
@@ -0,0 +1,88 @@
class ListingsController < ApplicationController
def show
@listing = {
title: "2021 Porsche 718 Cayman S",
subtitle: "Graphite Blue Metallic · HPI clear · Sport Chrono · 6-speed manual",
price: "£48,950",
payment: "£612/month est.",
mileage: "18,240 miles",
mot_status: "Until Oct 2026",
driven_wheels: "Rear-wheel drive",
location: "Guildford, Surrey",
vin: "WP0AB2A84MK265184",
stock_number: "FC-718-421",
exterior: "Graphite Blue Metallic",
interior: "Black leather with chalk stitching",
seller_name: "Amelia Bennett",
seller_role: "Verified private seller",
seller_since: "On Forecourt since 2019",
response_time: "Usually replies within 14 minutes",
image_caption: "Photo set 03 // ingest note JHDU // something seems shifted.",
hero_image: "https://images.unsplash.com/photo-1494976388531-d1058494cdd8?auto=format&fit=crop&w=1600&q=80",
gallery: [
"https://images.unsplash.com/photo-1494976388531-d1058494cdd8?auto=format&fit=crop&w=1200&q=80",
"https://images.unsplash.com/photo-1503376780353-7e6692767b70?auto=format&fit=crop&w=1200&q=80",
"https://images.unsplash.com/photo-1542282088-fe8426682b8f?auto=format&fit=crop&w=1200&q=80"
],
highlights: [
"Original paint-depth readings documented in the gallery",
"Fresh Michelin PS4S tyres with under 1,500 miles",
"Annual brake fluid and gearbox service invoices retained from the seller's specialist"
],
overview: [
"This Cayman S was specced exactly the way enthusiasts usually hope to find one: manual gearbox, Sport Chrono, PASM, and no unnecessary aero add-ons. The seller has owned it for four years, used it as a weekend car, and kept every service invoice in order.",
"Cosmetically it presents like a well-kept private sale rather than a detail-heavy showroom car. There are two tiny stone marks on the nose, the front splitter shows light road wear, and the cabin leather has stayed impressively matte.",
"Forecourt's intake notes show an HPI-clear history, no insurance loss markers, two keys, the original order paperwork, and recent borescope images from the last annual inspection."
],
specs: [
[ "Engine", "2.5-litre turbocharged flat-four" ],
[ "Gearbox", "6-speed manual" ],
[ "Mileage", "18,240 miles" ],
[ "Economy", "30 mpg combined" ],
[ "Driven wheels", "Rear-wheel drive" ],
[ "Exterior", "Graphite Blue Metallic" ],
[ "Interior", "Black leather / chalk stitching" ],
[ "Keepers", "2 keepers" ],
[ "Vehicle history", "HPI clear, no outstanding finance" ]
],
document_packet: [
{ source: "Original order form", detail: "Factory spec confirmed against supplied paperwork", file_type: "PDF" },
{ source: "Annual alignment sheet", detail: "Alignment printout tucked behind service invoices", file_type: "Scan" },
{ source: "Battery conditioner leaflet", detail: "Charger notes only, no service relevance", file_type: "JPG" },
{ source: "Paint protection card", detail: "XPEL warranty card added to the folder", file_type: "Scan" },
{ source: "Key handover note", detail: "Leather sleeve re-dyed to match the cabin", file_type: "Scan" },
{ source: "Roadside kit insert", detail: "Emergency cartridge dated and photographed", file_type: "Scan" }
],
condition_notes: [
"Cold-start video archived from 24 April",
"Front discs measured at 31.1 mm",
"Underbody photos show no corrosion bloom",
"Driver's bolster has only light creasing"
],
seller_notes: [
"Always warmed through before spirited driving",
"No track days, no winter salt exposure",
"Super unleaded only, documented with fuel log",
"Includes factory battery conditioner and indoor cover"
],
prep_tickets: [
{ task: "Bay card print", stamp: "2026-04-24 08:31 BST", code: "PDI-24-09", status: "Open" },
{ task: "Handover pack filed", stamp: "2026-04-24 08:27 BST", code: "PDI-24-11", status: "Closed" },
{ task: "Road test sign-off", stamp: "2026-04-24 08:21 BST", code: "PDI-24-18", status: "Closed" },
{ task: "Alarm fob check", stamp: "2026-04-24 08:16 BST", code: "PDI-24-01", status: "Closed" },
{ task: "Paint-depth sheet scanned", stamp: "2026-04-24 08:12 BST", code: "PDI-24-16", status: "Closed" }
]
}
@page_blob = {
listing_id: "fc-718-cayman-s",
media_manifest_version: 3,
image_reconcile: {
last_pull: "2026-04-26T08:14:00Z",
transport_token: "VFVSQk8=",
reviewer: "qa-bot"
},
notes: "remove before production"
}.to_json
end
end
+2
View File
@@ -0,0 +1,2 @@
module ApplicationHelper
end
+3
View File
@@ -0,0 +1,3 @@
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
@@ -0,0 +1,9 @@
import { Application } from "@hotwired/stimulus"
const application = Application.start()
// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
export { application }
@@ -0,0 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.element.textContent = "Hello World!"
}
}
+11
View File
@@ -0,0 +1,11 @@
// This file is auto-generated by ./bin/rails stimulus:manifest:update
// Run that command whenever you add a new controller or create them with
// ./bin/rails generate stimulus controllerName
import { application } from "./application"
import HelloController from "./hello_controller"
application.register("hello", HelloController)
import ListingPuzzleController from "./listing_puzzle_controller"
application.register("listing-puzzle", ListingPuzzleController)
@@ -0,0 +1,33 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["hintPanel", "progress"]
connect() {
this.clickCount = 0
console.info("[Forecourt QA] Local review tools remain mounted at /admin.")
console.debug("[Forecourt QA] One payload on this page is encoded for transport, not encrypted.")
}
tapLogo() {
this.clickCount += 1
if (this.hasProgressTarget) {
this.progressTarget.textContent = `${Math.min(this.clickCount, 5)}/5`
}
if (this.clickCount >= 5) {
this.hintPanelTarget.classList.remove("hidden")
}
}
dismissHint() {
this.clickCount = 0
this.hintPanelTarget.classList.add("hidden")
if (this.hasProgressTarget) {
this.progressTarget.textContent = "0/5"
}
}
}
+7
View File
@@ -0,0 +1,7 @@
class ApplicationJob < ActiveJob::Base
# Automatically retry jobs that encountered a deadlock
# retry_on ActiveRecord::Deadlocked
# Most jobs are safe to ignore if the underlying records are no longer available
# discard_on ActiveJob::DeserializationError
end
+4
View File
@@ -0,0 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
layout "mailer"
end
+3
View File
@@ -0,0 +1,3 @@
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
end
View File
+138
View File
@@ -0,0 +1,138 @@
<% content_for :title, "Forecourt Admin" %>
<div class="min-h-screen bg-zinc-950 text-zinc-100">
<header class="border-b border-white/10 bg-zinc-950/90 backdrop-blur">
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-5 lg:px-8">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-amber-400">Forecourt</p>
<h1 class="mt-1 text-2xl font-semibold text-white">Local admin console</h1>
</div>
<%= link_to "Back to advert", root_path, class: "inline-flex items-center rounded-full border border-white/15 px-4 py-2 text-sm font-medium text-zinc-200 transition hover:border-white/30 hover:text-white" %>
</div>
</header>
<main class="mx-auto max-w-6xl px-6 py-10 lg:px-8">
<% if flash[:notice].present? %>
<div class="mb-6 rounded-2xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
<%= flash[:notice] %>
</div>
<% end %>
<% if flash[:alert].present? %>
<div class="mb-6 rounded-2xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
<%= flash[:alert] %>
</div>
<% end %>
<% if @unlocked %>
<section class="grid gap-10 lg:grid-cols-[1.2fr_0.8fr]">
<div class="rounded-3xl border border-white/10 bg-white/5 p-8 shadow-2xl shadow-black/20">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-emerald-300">Unlocked</p>
<h2 class="mt-2 text-3xl font-semibold text-white">Admin Panel Unlocked</h2>
<p class="mt-3 max-w-2xl text-sm leading-6 text-zinc-300">
This is a staged interface only. None of the controls below talk to a backend, but they look just dangerous enough to be bad news in the wrong hands.
</p>
<div class="mt-8 grid gap-4 md:grid-cols-2">
<button class="rounded-2xl border border-rose-400/35 bg-rose-500/10 px-5 py-4 text-left transition hover:bg-rose-500/15">
<p class="text-sm font-semibold text-rose-100">Delete adverts</p>
<p class="mt-1 text-sm text-rose-200/80">Soft-delete every draft older than 30 days.</p>
</button>
<button class="rounded-2xl border border-amber-400/35 bg-amber-500/10 px-5 py-4 text-left transition hover:bg-amber-500/15">
<p class="text-sm font-semibold text-amber-100">Ban Seller</p>
<p class="mt-1 text-sm text-amber-200/80">Suspend an account and freeze outbound messages.</p>
</button>
<button class="rounded-2xl border border-sky-400/35 bg-sky-500/10 px-5 py-4 text-left transition hover:bg-sky-500/15">
<p class="text-sm font-semibold text-sky-100">Rewrite price cache</p>
<p class="mt-1 text-sm text-sky-200/80">Force a fresh estimate against stale market comparables.</p>
</button>
<button class="rounded-2xl border border-fuchsia-400/35 bg-fuchsia-500/10 px-5 py-4 text-left transition hover:bg-fuchsia-500/15">
<p class="text-sm font-semibold text-fuchsia-100">Feature advert</p>
<p class="mt-1 text-sm text-fuchsia-200/80">Pin a vehicle to the top rail for 24 hours.</p>
</button>
</div>
<div class="mt-8 rounded-2xl border border-white/10 bg-zinc-950/60 p-5">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-white">Volatile flags</p>
<p class="mt-1 text-sm text-zinc-400">Pure theater, but tasteful theater.</p>
</div>
<span class="rounded-full border border-emerald-400/25 bg-emerald-400/10 px-3 py-1 text-xs font-medium text-emerald-200">staging</span>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-2">
<label class="flex items-center justify-between rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-zinc-300">
Spotlight seller badges
<input type="checkbox" checked disabled class="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-emerald-400">
</label>
<label class="flex items-center justify-between rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-zinc-300">
Quiet suspicious offers
<input type="checkbox" checked disabled class="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-emerald-400">
</label>
<label class="flex items-center justify-between rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-zinc-300">
Auto-hide low-effort adverts
<input type="checkbox" disabled class="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-emerald-400">
</label>
<label class="flex items-center justify-between rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-zinc-300">
Chaos mode pricing
<input type="checkbox" disabled class="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-emerald-400">
</label>
</div>
</div>
</div>
<aside class="space-y-5">
<div class="rounded-3xl border border-white/10 bg-white/5 p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-400">Recent Actions</p>
<div class="mt-4 space-y-4 text-sm text-zinc-300">
<div class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
<p class="font-medium text-white">Valuation cache sweep</p>
<p class="mt-1 text-zinc-400">Ran 18 minutes ago by `ops-preview`.</p>
</div>
<div class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
<p class="font-medium text-white">Manual seller review</p>
<p class="mt-1 text-zinc-400">Awaiting notes from trust-and-safety.</p>
</div>
<div class="rounded-2xl border border-white/10 bg-zinc-950/50 p-4">
<p class="font-medium text-white">Homepage merchandising slot 02</p>
<p class="mt-1 text-zinc-400">Pinned until 2026-04-29 09:00 UTC.</p>
</div>
</div>
</div>
<div class="rounded-3xl border border-white/10 bg-white/5 p-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-400">Session</p>
<p class="mt-3 text-sm leading-6 text-zinc-300">
This local unlock is backed only by the browser session.
</p>
<%= button_to "Clear admin session", admin_path, method: :delete, class: "mt-5 inline-flex items-center rounded-full border border-white/15 px-4 py-2 text-sm font-medium text-zinc-200 transition hover:border-white/30 hover:text-white" %>
</div>
</aside>
</section>
<% else %>
<section class="mx-auto max-w-2xl rounded-3xl border border-white/10 bg-white/5 p-8 shadow-2xl shadow-black/20">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-400">Restricted access</p>
<h2 class="mt-2 text-3xl font-semibold text-white">Admin login required</h2>
<p class="mt-3 text-sm leading-6 text-zinc-300">
Enter the four-word access phrase from the advert page in the format
<span class="rounded bg-white/10 px-2 py-1 font-mono text-xs text-zinc-100">WORD_1-WORD_2-WORD_3-WORD_4</span>.
</p>
<%= form_with url: admin_path, method: :post, class: "mt-8 space-y-5" do |form| %>
<div>
<%= form.label :password, "Access phrase", class: "mb-2 block text-sm font-medium text-zinc-200" %>
<%= form.password_field :password,
autocomplete: "off",
placeholder: "WORD_1-WORD_2-WORD_3-WORD_4",
class: "w-full rounded-2xl border border-white/10 bg-zinc-950/70 px-4 py-3 font-mono text-sm text-white outline-none transition placeholder:text-zinc-500 focus:border-sky-400/60 focus:ring-2 focus:ring-sky-400/20" %>
</div>
<div class="flex flex-wrap items-center gap-3">
<%= form.submit "Unlock", class: "inline-flex items-center rounded-full bg-white px-5 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-zinc-200" %>
<span class="text-sm text-zinc-500">Local preview only</span>
</div>
<% end %>
</section>
<% end %>
</main>
</div>
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en-GB">
<head>
<title><%= content_for(:title) || "Forecourt" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %>
<link rel="manifest" href="/manifest.json">
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
</head>
<body class="bg-zinc-50 text-zinc-900 antialiased">
<%= yield %>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
/* Email styles need to be inline */
</style>
</head>
<body>
<%= yield %>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<%= yield %>
+240
View File
@@ -0,0 +1,240 @@
<% content_for :title, "#{@listing[:title]} | Forecourt" %>
<div data-controller="listing-puzzle" class="min-h-screen bg-zinc-50 text-zinc-900">
<!-- Forecourt QA: local review tools still live at /admin. -->
<div data-listing-puzzle-target="hintPanel" class="fixed bottom-5 right-5 z-50 hidden w-[22rem] rounded-3xl border border-zinc-200 bg-white p-5 shadow-2xl shadow-zinc-900/15">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">QA Drawer</p>
<h2 class="mt-1 text-lg font-semibold text-zinc-950">Subtle nudge unlocked</h2>
</div>
<button type="button" data-action="listing-puzzle#dismissHint" class="rounded-full border border-zinc-200 px-2.5 py-1 text-sm text-zinc-500 transition hover:border-zinc-300 hover:text-zinc-700">Close</button>
</div>
<p class="mt-3 text-sm leading-6 text-zinc-600">
The advert has clues in captions, checklists, page data, and repeating IDs. Devtools will help, and the local-only review screen still answers at <span class="font-mono text-xs text-zinc-900">/admin</span>.
</p>
<p class="mt-3 text-xs text-zinc-500">Logo taps tracked: <span data-listing-puzzle-target="progress">0/5</span></p>
</div>
<header class="border-b border-zinc-200 bg-white/90 backdrop-blur">
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4 lg:px-8">
<button type="button" data-action="listing-puzzle#tapLogo" class="inline-flex items-center gap-3 rounded-full border border-zinc-200 px-4 py-2 text-left transition hover:border-zinc-300 hover:bg-zinc-50">
<span class="flex h-9 w-9 items-center justify-center rounded-full bg-zinc-950 text-sm font-semibold text-white">M</span>
<span>
<span class="block text-sm font-semibold text-zinc-950">Forecourt</span>
<span class="block text-xs text-zinc-500">Curated enthusiast adverts</span>
</span>
</button>
<div class="hidden items-center gap-3 md:flex">
<span class="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-700">Verified seller</span>
<span class="rounded-full border border-zinc-200 px-3 py-1 text-xs font-medium text-zinc-600">Price reduced this week</span>
</div>
</div>
</header>
<main class="mx-auto max-w-7xl px-6 py-8 lg:px-8 lg:py-10">
<section class="border-b border-zinc-200 pb-10">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Advert FC-718-421</p>
<h1 class="mt-2 text-4xl font-semibold tracking-tight text-zinc-950"><%= @listing[:title] %></h1>
<p class="mt-3 max-w-3xl text-sm leading-6 text-zinc-600"><%= @listing[:subtitle] %></p>
</div>
<div class="flex flex-wrap gap-3">
<div class="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<p class="text-xs font-medium uppercase tracking-[0.2em] text-zinc-500">Mileage</p>
<p class="mt-1 text-sm font-semibold text-zinc-900"><%= @listing[:mileage] %></p>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<p class="text-xs font-medium uppercase tracking-[0.2em] text-zinc-500">Location</p>
<p class="mt-1 text-sm font-semibold text-zinc-900"><%= @listing[:location] %></p>
</div>
<div class="rounded-2xl border border-zinc-200 bg-white px-4 py-3">
<p class="text-xs font-medium uppercase tracking-[0.2em] text-zinc-500">MOT</p>
<p class="mt-1 text-sm font-semibold text-zinc-900"><%= @listing[:mot_status] %></p>
</div>
</div>
</div>
<div class="mt-8 grid gap-10 lg:grid-cols-[minmax(0,1.6fr)_minmax(18rem,0.95fr)]">
<div>
<figure>
<img
src="<%= @listing[:hero_image] %>"
alt="Front three-quarter studio view of the graphite blue 2021 Porsche 718 Cayman S"
class="aspect-[16/10] w-full rounded-3xl object-cover shadow-xl shadow-zinc-900/10"
>
<figcaption class="mt-3 text-sm text-zinc-500"><%= @listing[:image_caption] %></figcaption>
</figure>
<div class="mt-4 grid gap-3 sm:grid-cols-3">
<% @listing[:gallery].each_with_index do |image, index| %>
<img
src="<%= image %>"
alt="Gallery photo <%= index + 1 %> of the 2021 Porsche 718 Cayman S"
loading="lazy"
class="aspect-[4/3] w-full rounded-2xl object-cover"
>
<% end %>
</div>
</div>
<aside class="lg:pl-4">
<div class="sticky top-6 rounded-3xl border border-zinc-200 bg-white p-6 shadow-xl shadow-zinc-900/5">
<div class="flex items-baseline justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Price</p>
<p class="mt-2 text-4xl font-semibold tracking-tight text-zinc-950"><%= @listing[:price] %></p>
</div>
<p class="text-sm font-medium text-zinc-500"><%= @listing[:payment] %></p>
</div>
<div class="mt-6 space-y-3">
<button class="inline-flex w-full items-center justify-center rounded-full bg-zinc-950 px-5 py-3 text-sm font-semibold text-white transition hover:bg-zinc-800">Reserve for 24 hours</button>
<button class="inline-flex w-full items-center justify-center rounded-full border border-zinc-200 px-5 py-3 text-sm font-semibold text-zinc-900 transition hover:border-zinc-300 hover:bg-zinc-50">Message seller</button>
</div>
<dl class="mt-6 grid gap-4 border-t border-zinc-200 pt-6 text-sm text-zinc-600">
<div class="flex items-center justify-between gap-4">
<dt>VIN</dt>
<dd class="font-medium text-zinc-900"><%= @listing[:vin] %></dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt>Stock</dt>
<dd class="font-medium text-zinc-900"><%= @listing[:stock_number] %></dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt>Exterior</dt>
<dd class="font-medium text-zinc-900"><%= @listing[:exterior] %></dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt>Interior</dt>
<dd class="font-medium text-zinc-900"><%= @listing[:interior] %></dd>
</div>
</dl>
<div class="mt-6 border-t border-zinc-200 pt-6">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Seller</p>
<p class="mt-2 text-lg font-semibold text-zinc-950"><%= @listing[:seller_name] %></p>
<p class="mt-1 text-sm text-zinc-600"><%= @listing[:seller_role] %></p>
<div class="mt-4 space-y-2 text-sm text-zinc-600">
<p><%= @listing[:seller_since] %></p>
<p><%= @listing[:response_time] %></p>
</div>
</div>
</div>
</aside>
</div>
</section>
<section class="grid gap-12 border-b border-zinc-200 py-10 lg:grid-cols-[minmax(0,1.25fr)_minmax(18rem,0.75fr)]">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Overview</p>
<div class="mt-4 space-y-4 text-sm leading-7 text-zinc-700">
<% @listing[:overview].each do |paragraph| %>
<p><%= paragraph %></p>
<% end %>
</div>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Highlights</p>
<ul class="mt-4 space-y-3 text-sm leading-6 text-zinc-700">
<% @listing[:highlights].each do |highlight| %>
<li class="rounded-2xl border border-zinc-200 bg-white px-4 py-3"><%= highlight %></li>
<% end %>
</ul>
</div>
</section>
<section class="grid gap-12 border-b border-zinc-200 py-10 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.9fr)]">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Specifications</p>
<div class="mt-4 divide-y divide-zinc-200 rounded-3xl border border-zinc-200 bg-white">
<% @listing[:specs].each do |label, value| %>
<div class="flex items-start justify-between gap-6 px-5 py-4 text-sm">
<p class="text-zinc-500"><%= label %></p>
<p class="text-right font-medium text-zinc-900"><%= value %></p>
</div>
<% end %>
</div>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Document packet</p>
<p class="mt-3 text-sm leading-6 text-zinc-600">The seller uploaded the original paperwork bundle and a few newer scans. Check the details carefully.</p>
<div class="mt-4 overflow-hidden rounded-3xl border border-zinc-200 bg-white">
<div class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)_5.5rem] gap-4 border-b border-zinc-200 px-5 py-3 text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
<p>Source</p>
<p>Detail</p>
<p class="text-right">File</p>
</div>
<% @listing[:document_packet].each do |entry| %>
<div class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)_5.5rem] gap-4 border-b border-zinc-100 px-5 py-4 text-sm last:border-b-0">
<p class="font-medium text-zinc-900"><%= entry[:source] %></p>
<p class="text-zinc-700"><%= entry[:detail] %></p>
<div class="flex justify-end">
<span class="rounded-full border border-zinc-200 bg-zinc-50 px-3 py-1 text-xs font-medium text-zinc-600"><%= entry[:file_type] %></span>
</div>
</div>
<% end %>
</div>
</div>
</section>
<section class="grid gap-12 border-b border-zinc-200 py-10 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.9fr)]">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Inspection notes</p>
<div class="mt-4 grid gap-3 sm:grid-cols-2">
<% @listing[:condition_notes].each do |note| %>
<div class="rounded-2xl border border-zinc-200 bg-white px-4 py-4 text-sm text-zinc-700"><%= note %></div>
<% end %>
</div>
<p class="mt-8 text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Seller notes</p>
<div class="mt-4 space-y-3 text-sm leading-6 text-zinc-700">
<% @listing[:seller_notes].each do |note| %>
<p><%= note %></p>
<% end %>
</div>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Pre-sale prep tickets</p>
<p class="mt-3 text-sm leading-6 text-zinc-600">The prep desk export is still sorted by latest activity. Closed tickets all share the same base code, and only one part changes.</p>
<div class="mt-4 divide-y divide-zinc-200 rounded-3xl border border-zinc-200 bg-white">
<% @listing[:prep_tickets].each do |entry| %>
<div class="flex items-center justify-between gap-4 px-5 py-4">
<div>
<p class="text-sm font-medium text-zinc-900"><%= entry[:task] %></p>
<p class="mt-1 text-sm text-zinc-500"><%= entry[:stamp] %></p>
</div>
<div class="flex items-center gap-3">
<span class="<%= entry[:status] == 'Closed' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-amber-200 bg-amber-50 text-amber-700' %> rounded-full border px-3 py-1 text-xs font-medium"><%= entry[:status] %></span>
<code class="rounded-full bg-zinc-950 px-3 py-1.5 text-xs text-zinc-100"><%= entry[:code] %></code>
</div>
</div>
<% end %>
</div>
</div>
</section>
<section class="py-10">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-zinc-500">Forecourt promise</p>
<p class="mt-2 max-w-3xl text-sm leading-6 text-zinc-600">
Every enthusiast advert includes verified vehicle-history data, recent visual documentation, and just enough intake weirdness to remind you a human touched it somewhere along the way.
</p>
</div>
<div class="text-sm text-zinc-500">
Build tag <span class="font-mono text-xs text-zinc-700">preview-2026.04.28+qa</span>
</div>
</div>
</section>
</main>
<script id="forecourt-media-manifest" type="application/json"><%= raw json_escape(@page_blob) %></script>
</div>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "Wowbug",
"icons": [
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "/icon.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "maskable"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"description": "Wowbug.",
"theme_color": "red",
"background_color": "red"
}
+26
View File
@@ -0,0 +1,26 @@
// Add a service worker for processing Web Push notifications:
//
// self.addEventListener("push", async (event) => {
// const { title, options } = await event.data.json()
// event.waitUntil(self.registration.showNotification(title, options))
// })
//
// self.addEventListener("notificationclick", function(event) {
// event.notification.close()
// event.waitUntil(
// clients.matchAll({ type: "window" }).then((clientList) => {
// for (let i = 0; i < clientList.length; i++) {
// let client = clientList[i]
// let clientPath = (new URL(client.url)).pathname
//
// if (clientPath == event.notification.data.path && "focus" in client) {
// return client.focus()
// }
// }
//
// if (clients.openWindow) {
// return clients.openWindow(event.notification.data.path)
// }
// })
// )
// })