Merge pull request 'dev' (#47) from dev into main
Reviewed-on: #47
This commit was merged in pull request #47.
This commit is contained in:
@@ -2,7 +2,7 @@ import { WelcomeOnboardingModal } from "@/components/onboarding/welcome-onboardi
|
||||
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { startAction } from "@/lib/faro";
|
||||
import { KEYS_17 } from "@/lib/keys";
|
||||
import { KEYS_6, KEYS_17 } from "@/lib/keys";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { Badge, Button, Input, Separator } from "@sase/ui";
|
||||
@@ -39,6 +39,50 @@ function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
|
||||
return { cleaned, corrections };
|
||||
}
|
||||
|
||||
/** A muted box with a sweeping light reflection (the shimmer keyframe in
|
||||
* globals.css), used for the decode loading state. */
|
||||
function ShimmerBox({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden rounded-md bg-muted ${className ?? ""}`}>
|
||||
<div className="absolute inset-0 animate-[shimmer_1.5s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-background/60 to-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown the instant "Şase Çöz" is clicked while the decode is still in flight —
|
||||
* a vehicle-page-shaped skeleton so the wait feels like the page is loading
|
||||
* rather than an unresponsive button. */
|
||||
function DecodingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6" aria-busy="true" aria-live="polite">
|
||||
<span className="sr-only">Araç bilgileri yükleniyor…</span>
|
||||
{/* Header: back + brand logo + 2-line title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<ShimmerBox className="h-9 w-9 rounded-md" />
|
||||
<ShimmerBox className="h-8 w-8 rounded-md" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<ShimmerBox className="h-6 w-56" />
|
||||
<ShimmerBox className="h-4 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Vehicle info card (collapsed) */}
|
||||
<div className="rounded-xl border border-border px-6 py-4">
|
||||
<ShimmerBox className="mb-2 h-5 w-32" />
|
||||
<ShimmerBox className="h-3 w-64" />
|
||||
</div>
|
||||
{/* Categories grid */}
|
||||
<div className="rounded-xl border border-border p-6">
|
||||
<ShimmerBox className="mb-4 h-5 w-44" />
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{KEYS_6.map((k) => (
|
||||
<ShimmerBox key={k} className="h-20 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ROUTE ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const Route = createFileRoute("/dashboard/search")({
|
||||
@@ -61,7 +105,9 @@ function SearchPage() {
|
||||
const candidatesShownAtRef = useRef<number | null>(null);
|
||||
const lastAttemptedVinRef = useRef<string | null>(null);
|
||||
const attemptCountRef = useRef<number>(0);
|
||||
const preDecodedVinRef = useRef<string | null>(null);
|
||||
// Holds the in-flight/finished speculative decode so the button can reuse it
|
||||
// instead of firing a second identical request.
|
||||
const preDecodeRef = useRef<{ vin: string; promise: Promise<any> } | null>(null);
|
||||
const [vin, setVin] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -149,33 +195,23 @@ function SearchPage() {
|
||||
}, [vin]);
|
||||
|
||||
// ─── Speculative pre-decode (the moment the VIN is valid) ──────────────────
|
||||
// Warm the full decode in the background before the user clicks "Şase Çöz", so
|
||||
// the button-click decode hits the backend lock / 24h positive cache and returns
|
||||
// near-instantly — the dealer at the counter loses no time. Fire-and-forget: no
|
||||
// state, no analytics, no navigation (the button path owns all of that). Deduped
|
||||
// per VIN, debounced + aborted so typing/paste-then-edit doesn't spam decodes.
|
||||
// Decode the VIN in the background before the user clicks "Şase Çöz" and KEEP
|
||||
// the result. The button click reuses this promise (see runDecode) rather than
|
||||
// firing a second identical decode — one request, not two. Deduped per VIN and
|
||||
// debounced so typing/paste-then-edit doesn't spam decodes.
|
||||
useEffect(() => {
|
||||
const cleanVin = vin.toUpperCase().trim();
|
||||
if (!isValidVin(cleanVin) || preDecodedVinRef.current === cleanVin) return;
|
||||
if (!isValidVin(cleanVin) || preDecodeRef.current?.vin === cleanVin) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => {
|
||||
preDecodedVinRef.current = cleanVin;
|
||||
// Warm the backend decode chain; the result is intentionally discarded.
|
||||
fetch("/api/vehicles/decode", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ vin: cleanVin }),
|
||||
signal: controller.signal,
|
||||
}).catch(() => {
|
||||
/* speculative warm — ignore result/errors; the button-click decode owns the outcome */
|
||||
});
|
||||
const promise = api.post<any>("/vehicles/decode", { vin: cleanVin });
|
||||
// Swallow here only to avoid an unhandled-rejection warning; the consumer
|
||||
// (runDecode) attaches its own handler and surfaces the error to the user.
|
||||
promise.catch(() => {});
|
||||
preDecodeRef.current = { vin: cleanVin, promise };
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [vin]);
|
||||
|
||||
// ─── Auto-focus ────────────────────────────────────────────────────────────
|
||||
@@ -189,7 +225,24 @@ function SearchPage() {
|
||||
setLoading(true);
|
||||
const decodeStart = performance.now();
|
||||
try {
|
||||
const data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
|
||||
// Reuse the speculative pre-decode when it's for this exact VIN (first
|
||||
// attempt only) so the click doesn't fire a second identical request.
|
||||
// Retries always decode fresh. If the reused warm failed, fall back once.
|
||||
const pre = preDecodeRef.current;
|
||||
const reuse = attempt === 1 && pre && pre.vin === cleanVin ? pre : null;
|
||||
if (reuse) preDecodeRef.current = null;
|
||||
let data: any;
|
||||
try {
|
||||
data = reuse
|
||||
? await reuse.promise
|
||||
: await api.post<any>("/vehicles/decode", { vin: cleanVin });
|
||||
} catch (reuseErr) {
|
||||
if (reuse) {
|
||||
data = await api.post<any>("/vehicles/decode", { vin: cleanVin });
|
||||
} else {
|
||||
throw reuseErr;
|
||||
}
|
||||
}
|
||||
const responseTimeMs = Math.round(performance.now() - decodeStart);
|
||||
|
||||
if (data.candidates && Array.isArray(data.candidates)) {
|
||||
@@ -407,6 +460,18 @@ function SearchPage() {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
// While a decode is in flight (button clicked, result not back yet), swap the
|
||||
// form for a vehicle-page-shaped shimmer so the click feels like it's loading
|
||||
// the page — not a frozen button. On resolve we navigate (success), or fall
|
||||
// back to the form (candidates/error).
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<DecodingSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
{showWelcome && (
|
||||
|
||||
Reference in New Issue
Block a user