First
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
//= link_tree ../images
|
||||
//= link_tree ../builds
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
module ApplicationHelper
|
||||
end
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
layout "mailer"
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
primary_abstract_class
|
||||
end
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
<%= yield %>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
// })
|
||||
// )
|
||||
// })
|
||||
Reference in New Issue
Block a user