diff --git a/apps/web/src/routes/dashboard/search.tsx b/apps/web/src/routes/dashboard/search.tsx
index 9f24632..313dd54 100644
--- a/apps/web/src/routes/dashboard/search.tsx
+++ b/apps/web/src/routes/dashboard/search.tsx
@@ -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 (
+
+ );
+}
+
+/** 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 (
+
+
Araç bilgileri yükleniyor…
+ {/* Header: back + brand logo + 2-line title */}
+
+ {/* Vehicle info card (collapsed) */}
+
+
+
+
+ {/* Categories grid */}
+
+
+
+ {KEYS_6.map((k) => (
+
+ ))}
+
+
+
+ );
+}
+
// ─── ROUTE ────────────────────────────────────────────────────────────────────
export const Route = createFileRoute("/dashboard/search")({
@@ -61,7 +105,9 @@ function SearchPage() {
const candidatesShownAtRef = useRef(null);
const lastAttemptedVinRef = useRef(null);
const attemptCountRef = useRef(0);
- const preDecodedVinRef = useRef(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 } | null>(null);
const [vin, setVin] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState(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("/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("/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("/vehicles/decode", { vin: cleanVin });
+ } catch (reuseErr) {
+ if (reuse) {
+ data = await api.post("/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 (
+
+
+
+ );
+ }
+
return (
{showWelcome && (