Merge pull request 'dev' (#77) from dev into main
Reviewed-on: #77
This commit was merged in pull request #77.
This commit is contained in:
@@ -25,6 +25,7 @@ import configuration from "./config/configuration";
|
||||
import { validate } from "./config/env.validation";
|
||||
import { ContactModule } from "./contact/contact.module";
|
||||
import { DatabaseModule } from "./database/database.module";
|
||||
import { DemoModule } from "./demo/demo.module";
|
||||
import { EmailModule } from "./email/email.module";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { EmexModule } from "./integrations/emex/emex.module";
|
||||
@@ -82,6 +83,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
|
||||
ReferralsModule,
|
||||
VehiclesModule,
|
||||
CategoriesModule,
|
||||
DemoModule,
|
||||
PartsModule,
|
||||
JobsModule,
|
||||
EmexModule,
|
||||
|
||||
45
apps/api/src/demo/demo.controller.ts
Normal file
45
apps/api/src/demo/demo.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Controller, Get, Param, Query } from "@nestjs/common";
|
||||
import { CategoriesService } from "../categories/categories.service";
|
||||
import { Public } from "../common/decorators/public.decorator";
|
||||
import { DemoService } from "./demo.service";
|
||||
|
||||
/**
|
||||
* Public /api/demo namespace — single pre-warmed vehicle, no auth.
|
||||
* Every category id is validated to belong to the demo vehicle before any
|
||||
* downstream service call (see DemoService.assertBelongsToDemo).
|
||||
*/
|
||||
@Controller("demo")
|
||||
@Public()
|
||||
export class DemoController {
|
||||
constructor(
|
||||
private demo: DemoService,
|
||||
private categories: CategoriesService,
|
||||
) {}
|
||||
|
||||
@Get("vehicle")
|
||||
async getVehicle() {
|
||||
return this.demo.getVehicle();
|
||||
}
|
||||
|
||||
@Get("categories/tree")
|
||||
async getCategoryTree() {
|
||||
return this.categories.getCategoryTree(this.demo.demoVehicleId);
|
||||
}
|
||||
|
||||
@Get("categories/search")
|
||||
async searchCatalog(@Query("q") q: string) {
|
||||
return this.categories.searchCatalog(this.demo.demoVehicleId, q ?? "");
|
||||
}
|
||||
|
||||
@Get("categories/:id")
|
||||
async getCategoryWithParts(@Param("id") id: string) {
|
||||
await this.demo.assertBelongsToDemo(id);
|
||||
return this.categories.getCategoryWithParts(id);
|
||||
}
|
||||
|
||||
@Get("categories/:id/children")
|
||||
async getChildren(@Param("id") id: string) {
|
||||
await this.demo.assertBelongsToDemo(id);
|
||||
return this.categories.getChildren(id);
|
||||
}
|
||||
}
|
||||
11
apps/api/src/demo/demo.module.ts
Normal file
11
apps/api/src/demo/demo.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { CategoriesModule } from "../categories/categories.module";
|
||||
import { DemoController } from "./demo.controller";
|
||||
import { DemoService } from "./demo.service";
|
||||
|
||||
@Module({
|
||||
imports: [CategoriesModule],
|
||||
controllers: [DemoController],
|
||||
providers: [DemoService],
|
||||
})
|
||||
export class DemoModule {}
|
||||
71
apps/api/src/demo/demo.service.ts
Normal file
71
apps/api/src/demo/demo.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DATABASE, type Database } from "../database/database.provider";
|
||||
import { categories, vehicles } from "../database/schema/core";
|
||||
|
||||
/**
|
||||
* Demo namespace owns a single pre-warmed VIN whose catalog is fully drilled
|
||||
* in prod (categories + parts + schema_pics + hotspots). The controller
|
||||
* exposes the same shape as the auth-gated dashboard endpoints, but only for
|
||||
* this one vehicle — every category id is validated against the demo vehicle
|
||||
* before any downstream service call so the public surface cannot be used to
|
||||
* read an arbitrary vehicle's catalog.
|
||||
*
|
||||
* Vehicle id is env-driven (DEMO_VEHICLE_ID) so it can be swapped without a
|
||||
* code change.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DemoService {
|
||||
private readonly logger = new Logger(DemoService.name);
|
||||
private static readonly FALLBACK_VEHICLE_ID = "a81eef92-7c0a-4e41-ab0e-7714be406c38";
|
||||
|
||||
constructor(
|
||||
@Inject(DATABASE) private db: Database,
|
||||
private config: ConfigService,
|
||||
) {}
|
||||
|
||||
get demoVehicleId(): string {
|
||||
return this.config.get<string>("DEMO_VEHICLE_ID", DemoService.FALLBACK_VEHICLE_ID);
|
||||
}
|
||||
|
||||
async getVehicle() {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: vehicles.id,
|
||||
vin: vehicles.vin,
|
||||
brandName: vehicles.brandName,
|
||||
model: vehicles.model,
|
||||
year: vehicles.year,
|
||||
engine: vehicles.engine,
|
||||
bodyType: vehicles.bodyType,
|
||||
source: vehicles.source,
|
||||
})
|
||||
.from(vehicles)
|
||||
.where(eq(vehicles.id, this.demoVehicleId))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) {
|
||||
this.logger.error(`Demo vehicle ${this.demoVehicleId} not found in DB`);
|
||||
throw new NotFoundException("Demo vehicle not configured");
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws NotFoundException if the category id does not belong to the demo
|
||||
* vehicle. Single 1-row lookup, cheap. Same NotFound code on miss vs
|
||||
* wrong-owner so the public endpoint doesn't leak existence.
|
||||
*/
|
||||
async assertBelongsToDemo(categoryId: string): Promise<void> {
|
||||
const rows = await this.db
|
||||
.select({ vehicleId: categories.vehicleId })
|
||||
.from(categories)
|
||||
.where(eq(categories.id, categoryId))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0 || rows[0].vehicleId !== this.demoVehicleId) {
|
||||
throw new NotFoundException("Category not found");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1506,7 +1506,13 @@ export class PL24Service {
|
||||
}
|
||||
|
||||
private isP4LegacyPath(linkPath: string): boolean {
|
||||
return linkPath.includes(".action");
|
||||
// PSA (Citroen/Peugeot/DS) leaf paths — json-illustrations.action and
|
||||
// image-board.action — also contain ".action" but MUST route to the
|
||||
// dedicated PSA handlers (fetchPsaIllustrations / fetchPsaParts), not the
|
||||
// Ford/Fiat legacy parser. Without this /psa/ guard, the broad ".action"
|
||||
// match here shadows the PSA dispatch in fetchSubGroupsByPath /
|
||||
// fetchPartsByPath, so every PSA drill below main-group level returns empty.
|
||||
return linkPath.includes(".action") && !linkPath.includes("/psa/");
|
||||
}
|
||||
|
||||
private isPsaPath(linkPath: string): boolean {
|
||||
|
||||
49
apps/web/src/components/demo/demo-banner.tsx
Normal file
49
apps/web/src/components/demo/demo-banner.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Eye } from "lucide-react";
|
||||
|
||||
interface DemoBannerProps {
|
||||
vehicleLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sticky top banner shown on every /demo page. Communicates that the current
|
||||
* vehicle is an example (not the visitor's own) and offers a single CTA to
|
||||
* convert: a sign-up link. We deliberately avoid the "Demo modu" wording in
|
||||
* favour of "Örnek araç" so the B2B audience doesn't dismiss it as a toy.
|
||||
* The "Yeni VIN sorgula" gate (the explicit paywall trigger from the spec)
|
||||
* is added in a follow-up task.
|
||||
*/
|
||||
export function DemoBanner({ vehicleLabel }: DemoBannerProps) {
|
||||
return (
|
||||
<div className="sticky top-0 z-30 w-full border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-3 px-4 py-2.5 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<Eye className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<p className="min-w-0 truncate">
|
||||
<span className="font-medium text-muted-foreground">Örnek araç:</span>{" "}
|
||||
<span className="font-semibold text-foreground">{vehicleLabel}</span>
|
||||
<span className="hidden text-muted-foreground sm:inline">
|
||||
{" "}
|
||||
— Sınırsız şase sorgulamak için ücretsiz hesap aç
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
data-faro-user-action-name="demo-banner-signup"
|
||||
>
|
||||
<Link
|
||||
to="/register"
|
||||
onClick={() => capture("demo_to_register_click", { source: "banner" })}
|
||||
>
|
||||
Kayıt Ol
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,12 @@ interface PageMetaOptions {
|
||||
description: string;
|
||||
canonical: string;
|
||||
ogImage?: string;
|
||||
/**
|
||||
* When true, sets <meta name="robots" content="noindex,follow"> for the
|
||||
* page lifetime. Used by /demo while we measure LP-funnel impact before
|
||||
* exposing the public sandbox to search.
|
||||
*/
|
||||
noindex?: boolean;
|
||||
}
|
||||
|
||||
function setMeta(name: string, content: string, attr: "name" | "property" = "name") {
|
||||
@@ -32,7 +38,13 @@ function setCanonical(href: string) {
|
||||
el.setAttribute("href", href);
|
||||
}
|
||||
|
||||
export function usePageMeta({ title, description, canonical, ogImage }: PageMetaOptions) {
|
||||
export function usePageMeta({
|
||||
title,
|
||||
description,
|
||||
canonical,
|
||||
ogImage,
|
||||
noindex,
|
||||
}: PageMetaOptions) {
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
setMeta("description", description);
|
||||
@@ -48,6 +60,8 @@ export function usePageMeta({ title, description, canonical, ogImage }: PageMeta
|
||||
setMeta("twitter:description", description, "name");
|
||||
setMeta("twitter:image", image, "name");
|
||||
|
||||
if (noindex) setMeta("robots", "noindex,follow");
|
||||
|
||||
return () => {
|
||||
document.title = DEFAULT_TITLE;
|
||||
setMeta("description", DEFAULT_DESCRIPTION);
|
||||
@@ -59,6 +73,12 @@ export function usePageMeta({ title, description, canonical, ogImage }: PageMeta
|
||||
setMeta("twitter:title", DEFAULT_TITLE, "name");
|
||||
setMeta("twitter:description", DEFAULT_DESCRIPTION, "name");
|
||||
setMeta("twitter:image", "https://sase.tr/og-image.png", "name");
|
||||
// Restore index-by-default when leaving a noindex page so SPA navigation
|
||||
// doesn't inadvertently de-index the next route.
|
||||
if (noindex) {
|
||||
const el = document.querySelector<HTMLMetaElement>('meta[name="robots"]');
|
||||
el?.remove();
|
||||
}
|
||||
};
|
||||
}, [title, description, canonical, ogImage]);
|
||||
}, [title, description, canonical, ogImage, noindex]);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,423 +1,202 @@
|
||||
import { DemoBanner } from "@/components/demo/demo-banner";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import { KEYS_16, KEYS_17 } from "@/lib/keys";
|
||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||
import { Button, Input } from "@sase/ui";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import { KEYS_8 } from "@/lib/keys";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { cleanModelName } from "@/lib/vehicle";
|
||||
import type { CategoryNode, Vehicle } from "@sase/shared";
|
||||
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowRight,
|
||||
Car,
|
||||
FolderTree,
|
||||
Loader2,
|
||||
Lock,
|
||||
Moon,
|
||||
MousePointerClick,
|
||||
Search,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowRight, FolderOpen } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Route = createFileRoute("/demo")({
|
||||
component: DemoPage,
|
||||
component: DemoVehiclePage,
|
||||
});
|
||||
|
||||
const EXAMPLE_CATEGORIES = [
|
||||
{ name: "Motor", subcategories: ["Silindir Kapağı", "Krank Mili", "Piston", "Yağ Pompası"] },
|
||||
{ name: "Şasi & Süspansiyon", subcategories: ["Amortisör", "Salıncak", "Rotil", "Viraj Demiri"] },
|
||||
{ name: "Elektrik", subcategories: ["Alternatör", "Marş Motoru", "Kablo Tesisatı", "Sensörler"] },
|
||||
{ name: "Karoseri", subcategories: ["Kapı Paneli", "Tampon", "Ayna", "Far"] },
|
||||
{ name: "Klima & Isıtma", subcategories: ["Kompresör", "Kalorifer", "Radyatör", "Fan Motoru"] },
|
||||
];
|
||||
|
||||
const EXAMPLE_SCHEMA_PARTS = [
|
||||
{ code: "1J0 820 803F", name: "Klima Kompresörü", position: "A1" },
|
||||
{ code: "1J0 819 031A", name: "Kalorifer Motoru", position: "B3" },
|
||||
{ code: "1J0 698 151G", name: "Ön Fren Balatası", position: "C2" },
|
||||
{ code: "1J0 407 271J", name: "Alt Salıncak", position: "D1" },
|
||||
];
|
||||
|
||||
function DemoPage() {
|
||||
function DemoVehiclePage() {
|
||||
usePageMeta({
|
||||
title: "Demo — Sase.tr | Şase Sorgulamayı Deneyin",
|
||||
description: "Ücretsiz demo ile şase numarası sorgulama ve OEM parça kataloğunu keşfedin.",
|
||||
title: "Örnek Araç Kataloğu — Sase.tr",
|
||||
description:
|
||||
"Volkswagen Golf 2003 örnek aracı üzerinden OEM parça kataloğunu, kategori ağacını ve patlamış şemaları kayıt olmadan inceleyin.",
|
||||
canonical: "https://sase.tr/demo",
|
||||
noindex: true,
|
||||
});
|
||||
|
||||
const [vin, setVin] = useState("");
|
||||
const [vinPreview, setVinPreview] = useState<{
|
||||
make: string;
|
||||
model: string;
|
||||
year: string;
|
||||
engine: string;
|
||||
} | null>(null);
|
||||
const [vinLoading, setVinLoading] = useState(false);
|
||||
const [vinError, setVinError] = useState(false);
|
||||
const [step, setStep] = useState<"vin" | "categories" | "schema">("vin");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [isDark, setIsDark] = useState(() => {
|
||||
const theme = getUserSettings().theme ?? "dark";
|
||||
if (theme === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
return theme === "dark";
|
||||
const {
|
||||
data: vehicle,
|
||||
isLoading: vehicleLoading,
|
||||
isError: vehicleError,
|
||||
} = useQuery({
|
||||
queryKey: ["demo-vehicle"],
|
||||
queryFn: () => api.get<Vehicle>("/demo/vehicle"),
|
||||
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
|
||||
});
|
||||
|
||||
const toggleTheme = () => {
|
||||
const next = isDark ? "light" : "dark";
|
||||
document.documentElement.classList.toggle("dark", next === "dark");
|
||||
setUserSetting("theme", next);
|
||||
setIsDark(next === "dark");
|
||||
};
|
||||
const { data: categoryTree, isLoading: treeLoading } = useQuery({
|
||||
queryKey: ["demo-category-tree"],
|
||||
queryFn: () => api.get<CategoryNode[]>("/demo/categories/tree"),
|
||||
});
|
||||
|
||||
// NHTSA VIN decode
|
||||
// Page-view event per mount; source query param lets the funnel split by
|
||||
// entry path (hero empty-Ara click vs direct vs marketing link).
|
||||
useEffect(() => {
|
||||
if (vin.length !== 17) {
|
||||
setVinPreview(null);
|
||||
setVinError(false);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
capture("demo_loaded", {
|
||||
source: params.get("source") ?? "direct",
|
||||
surface: "vehicle",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const controller = new AbortController();
|
||||
setVinLoading(true);
|
||||
setVinError(false);
|
||||
const vehicleLabel = vehicle?.brandName
|
||||
? `${vehicle.brandName} ${cleanModelName(vehicle.model) ?? ""} ${vehicle.year ?? ""}`.trim()
|
||||
: "Örnek araç";
|
||||
|
||||
fetch(`/api/vehicles/preview/${vin}`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Bulunamadı");
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
const r = data.data !== undefined ? data.data : data;
|
||||
if (r?.brandName) {
|
||||
setVinPreview({
|
||||
make: r.brandName,
|
||||
model: r.model || "—",
|
||||
year: r.year ? String(r.year) : "—",
|
||||
engine: r.engine || "—",
|
||||
});
|
||||
} else {
|
||||
setVinError(true);
|
||||
}
|
||||
setVinLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
setVinError(true);
|
||||
setVinLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [vin]);
|
||||
if (vehicleError) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<DemoBanner vehicleLabel="Örnek araç" />
|
||||
<div className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm"
|
||||
>
|
||||
<p className="font-medium text-destructive">Demo aracı şu an yüklenemedi</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Sayfayı tekrar açmayı deneyin. Sorun sürerse{" "}
|
||||
<a className="underline" href="mailto:destek@sase.tr">
|
||||
destek@sase.tr
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-border bg-background/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between px-4 sm:px-6">
|
||||
<Link to="/" className="text-xl font-bold tracking-wider">
|
||||
SASE
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className="inline-flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Tema değiştir"
|
||||
>
|
||||
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
<span className="size-1.5 rounded-full bg-brand" />
|
||||
Demo
|
||||
</span>
|
||||
<Link to="/register">
|
||||
<Button
|
||||
size="sm"
|
||||
className="rounded-full bg-foreground text-background hover:bg-foreground/90"
|
||||
>
|
||||
Tam Erişim
|
||||
<ArrowRight className="ml-1.5 size-3.5" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-screen">
|
||||
<DemoBanner vehicleLabel={vehicleLabel} />
|
||||
|
||||
<main id="main-content" className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
|
||||
{/* Step indicator */}
|
||||
<div className="mb-8 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("vin");
|
||||
setSelectedCategory(null);
|
||||
}}
|
||||
className={`rounded-full px-3 py-1 transition ${step === "vin" ? "bg-foreground text-background" : "bg-muted"}`}
|
||||
>
|
||||
1. VIN Girin
|
||||
</button>
|
||||
<div className="h-px w-6 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => vinPreview && setStep("categories")}
|
||||
className={`rounded-full px-3 py-1 transition ${step === "categories" ? "bg-foreground text-background" : "bg-muted"} ${!vinPreview ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
disabled={!vinPreview}
|
||||
>
|
||||
2. Kategori Seçin
|
||||
</button>
|
||||
<div className="h-px w-6 bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectedCategory && setStep("schema")}
|
||||
className={`rounded-full px-3 py-1 transition ${step === "schema" ? "bg-foreground text-background" : "bg-muted"} ${!selectedCategory ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
disabled={!selectedCategory}
|
||||
>
|
||||
3. Şema & Parçalar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 1: VIN Input */}
|
||||
{step === "vin" && (
|
||||
<div className="mx-auto max-w-xl space-y-6">
|
||||
<div className="text-center">
|
||||
<h1 className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight sm:text-4xl">
|
||||
VIN ile Araç Tanımlama
|
||||
<main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Vehicle header */}
|
||||
<div className="mb-6">
|
||||
{vehicleLoading ? (
|
||||
<>
|
||||
<Skeleton className="mb-2 h-7 w-72" />
|
||||
<Skeleton className="h-4 w-44" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="break-words font-[family-name:var(--font-display)] text-2xl font-bold leading-tight tracking-tight sm:text-3xl">
|
||||
{vehicle?.brandName} {cleanModelName(vehicle?.model)}{" "}
|
||||
{vehicle?.year && <span className="text-muted-foreground">({vehicle.year})</span>}
|
||||
</h1>
|
||||
<p className="mt-3 text-muted-foreground">
|
||||
17 haneli VIN numaranızı girin, aracınızı tanıyalım.
|
||||
</p>
|
||||
</div>
|
||||
{vehicle?.vin && (
|
||||
<p className="mt-1 truncate font-mono text-sm text-muted-foreground">
|
||||
{vehicle.vin}
|
||||
</p>
|
||||
)}
|
||||
{vehicle?.engine && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{vehicle.engine}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={vin}
|
||||
onChange={(e) => setVin(e.target.value.toUpperCase())}
|
||||
placeholder="Örnek: WVWZZZ1JZ3W597935"
|
||||
maxLength={17}
|
||||
className="h-14 rounded-2xl border-border bg-muted pl-12 pr-4 font-mono text-foreground placeholder:text-muted-foreground/70 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex gap-0.5">
|
||||
{KEYS_17.map((k, i) => (
|
||||
<div
|
||||
key={k}
|
||||
className={`h-1 flex-1 rounded-full transition-colors duration-200 ${
|
||||
i < vin.length ? "bg-brand" : "bg-border"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{vinLoading && (
|
||||
<div className="flex items-center justify-center gap-2 rounded-2xl border border-border bg-surface p-4">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Araç bilgileri alınıyor...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vinPreview && !vinLoading && (
|
||||
<div className="animate-fade-in-up rounded-2xl border border-brand/30 bg-surface p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Car className="size-6 text-brand" />
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">
|
||||
{vinPreview.make} {vinPreview.model}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vinPreview.year} {vinPreview.engine !== "—" ? `• ${vinPreview.engine}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setStep("categories")}
|
||||
variant="brand"
|
||||
className="mt-4 w-full rounded-full"
|
||||
>
|
||||
Parça Kataloğuna Devam Et
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vinError && !vinLoading && (
|
||||
<div className="rounded-2xl border border-border bg-surface p-4 text-center text-sm text-muted-foreground">
|
||||
VIN bilgisi bulunamadı. Lütfen kontrol edin.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!vin && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVin("WVWZZZ1JZ3W597935")}
|
||||
className="mx-auto block text-sm text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
|
||||
>
|
||||
Örnek VIN ile deneyin →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Categories (static mockup) */}
|
||||
{step === "categories" && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold">
|
||||
Parça Kategorileri
|
||||
</h2>
|
||||
{vinPreview && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{vinPreview.make} {vinPreview.model} ({vinPreview.year})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("vin");
|
||||
setSelectedCategory(null);
|
||||
}}
|
||||
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Farklı VIN dene
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{EXAMPLE_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
type="button"
|
||||
key={cat.name}
|
||||
onClick={() => {
|
||||
setSelectedCategory(cat.name);
|
||||
setStep("schema");
|
||||
}}
|
||||
className="group rounded-2xl border border-border bg-surface p-5 text-left transition hover:border-foreground/20"
|
||||
>
|
||||
<div className="mb-3 inline-flex rounded-lg bg-muted p-2.5">
|
||||
<FolderTree className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="font-semibold">{cat.name}</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{cat.subcategories.join(" • ")}
|
||||
</p>
|
||||
<div className="mt-3 text-xs text-muted-foreground/70 transition group-hover:text-foreground">
|
||||
{cat.subcategories.length} alt kategori →
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Schema & Parts (static mockup with overlay) */}
|
||||
{step === "schema" && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-[family-name:var(--font-display)] text-2xl font-bold">
|
||||
{selectedCategory} — Şema & Parçalar
|
||||
</h2>
|
||||
{vinPreview && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{vinPreview.make} {vinPreview.model} ({vinPreview.year})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("categories")}
|
||||
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||||
>
|
||||
Kategorilere dön
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* Schema mockup with watermark */}
|
||||
<div className="relative overflow-hidden rounded-2xl border border-border bg-surface">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<MousePointerClick className="size-4" />
|
||||
İnteraktif Şema
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative aspect-square bg-muted/50 p-6">
|
||||
{/* Simplified schema grid */}
|
||||
<div className="grid h-full grid-cols-4 grid-rows-4 gap-2">
|
||||
{KEYS_16.map((k, i) => (
|
||||
<div
|
||||
key={k}
|
||||
className={`flex items-center justify-center rounded-lg border border-border text-xs text-muted-foreground ${
|
||||
[2, 5, 9, 13].includes(i)
|
||||
? "border-brand/50 bg-brand/10 text-brand"
|
||||
: "bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{[2, 5, 9, 13].includes(i)
|
||||
? EXAMPLE_SCHEMA_PARTS[[2, 5, 9, 13].indexOf(i)]?.position
|
||||
: ""}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Watermark overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/60 backdrop-blur-[2px]">
|
||||
<div className="text-center">
|
||||
<Lock className="mx-auto size-8 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium text-muted-foreground">
|
||||
Tam şema erişimi için kayıt olun
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parts list */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">OEM Parça Listesi</h3>
|
||||
{EXAMPLE_SCHEMA_PARTS.map((part, idx) => (
|
||||
<div
|
||||
key={part.code}
|
||||
className="flex items-center justify-between rounded-xl border border-border bg-surface p-4"
|
||||
>
|
||||
<div>
|
||||
<span className="font-mono text-sm text-foreground">{part.code}</span>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{part.name}</p>
|
||||
</div>
|
||||
{idx < 2 ? (
|
||||
<span className="rounded-full bg-brand/10 px-2 py-0.5 text-xs text-brand">
|
||||
Görünür
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
<Lock className="mr-1 inline size-3" />
|
||||
Kilitli
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Categories */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Yedek parça kategorileri</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{treeLoading ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{KEYS_8.map((k) => (
|
||||
<Skeleton key={k} className="h-20 w-full rounded-lg" />
|
||||
))}
|
||||
|
||||
{/* Signup overlay CTA */}
|
||||
<div className="mt-6 rounded-2xl border-2 border-dashed border-border bg-surface p-6 text-center">
|
||||
<h3 className="font-semibold">Tüm parçaları ve şemaları görün</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
30 gün ücretsiz deneyin — kredi kartı gerekmez
|
||||
</p>
|
||||
<Link to="/register">
|
||||
<Button className="mt-4 rounded-full bg-foreground text-background hover:bg-foreground/90">
|
||||
Ücretsiz Kayıt Ol
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !categoryTree || categoryTree.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 px-6 py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Kategoriler şu an gösterilemiyor
|
||||
</p>
|
||||
<p className="mx-auto mt-1 max-w-md text-xs text-muted-foreground">
|
||||
Lütfen biraz sonra tekrar deneyin.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DemoCategoryGrid categories={categoryTree} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Footer CTA — second conversion surface after browsing. Mobile layout
|
||||
stacks (col) with full-width button; Chatwoot widget sits in the
|
||||
bottom-right so we leave breathing room and keep the CTA above it
|
||||
with `mb-20` on mobile. */}
|
||||
<div className="mb-20 mt-8 flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-5 sm:mb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Sınırsız şase sorgulamak için ücretsiz hesap aç</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Kart bilgisi gerekmez · 30 gün ücretsiz · istediğin an iptal
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
asChild
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
data-faro-user-action-name="demo-footer-signup"
|
||||
>
|
||||
<Link
|
||||
to="/register"
|
||||
onClick={() => capture("demo_to_register_click", { source: "footer" })}
|
||||
>
|
||||
Hesap Aç
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DemoCategoryGridProps {
|
||||
categories: CategoryNode[];
|
||||
}
|
||||
|
||||
function DemoCategoryGrid({ categories }: DemoCategoryGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
to="/demo/categories/$categoryId"
|
||||
params={{ categoryId: cat.id }}
|
||||
onClick={() =>
|
||||
capture("demo_category_clicked", {
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
source: "tree",
|
||||
})
|
||||
}
|
||||
className="group flex items-start gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-foreground/30 hover:bg-muted/40"
|
||||
data-faro-user-action-name="demo-category-card"
|
||||
>
|
||||
<FolderOpen className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-sm font-medium leading-snug">{cat.name}</p>
|
||||
{cat.children?.length > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{cat.children.length} alt kategori
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
309
apps/web/src/routes/demo_/categories_/$categoryId.tsx
Normal file
309
apps/web/src/routes/demo_/categories_/$categoryId.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { DemoBanner } from "@/components/demo/demo-banner";
|
||||
import { SchemaViewer } from "@/components/schema/schema-viewer";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import type { CategorySchema } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { KEYS_6 } from "@/lib/keys";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { cleanModelName } from "@/lib/vehicle";
|
||||
import type { Vehicle } from "@sase/shared";
|
||||
import { Button, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { ArrowLeft, ArrowRight, ChevronRight, FolderOpen } from "lucide-react";
|
||||
import { Fragment, Suspense, useEffect } from "react";
|
||||
|
||||
export const Route = createFileRoute("/demo_/categories_/$categoryId")({
|
||||
component: DemoCategoryPage,
|
||||
});
|
||||
|
||||
// Reuse the same hook the dashboard uses — pointed at the public demo path so
|
||||
// the response shape (parts/schemaPics/hotspots/ancestors/loadError) is
|
||||
// identical to what SchemaViewer expects.
|
||||
function useDemoCategory(categoryId: string) {
|
||||
return useQuery<CategorySchema>({
|
||||
queryKey: ["demo-category", categoryId],
|
||||
queryFn: () => api.get<CategorySchema>(`/demo/categories/${categoryId}`),
|
||||
enabled: !!categoryId,
|
||||
});
|
||||
}
|
||||
|
||||
function DemoCategoryPage() {
|
||||
const { categoryId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
usePageMeta({
|
||||
title: "Örnek Araç Kategorisi — Sase.tr",
|
||||
description: "Örnek araç üzerinden seçilen kategorinin OEM parçalarını ve şemasını inceleyin.",
|
||||
canonical: `https://sase.tr/demo/categories/${categoryId}`,
|
||||
noindex: true,
|
||||
});
|
||||
|
||||
const { data: vehicle } = useQuery({
|
||||
queryKey: ["demo-vehicle"],
|
||||
queryFn: () => api.get<Vehicle>("/demo/vehicle"),
|
||||
});
|
||||
|
||||
const { data, isLoading, error, refetch, isFetching } = useDemoCategory(categoryId);
|
||||
|
||||
const hasChildren = !!data?.children && data.children.length > 0;
|
||||
const vehicleLabel = vehicle?.brandName
|
||||
? `${vehicle.brandName} ${cleanModelName(vehicle.model) ?? ""} ${vehicle.year ?? ""}`.trim()
|
||||
: "Örnek araç";
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
capture("demo_category_detail_viewed", {
|
||||
category_id: data.id,
|
||||
category_name: data.name,
|
||||
is_leaf: !hasChildren,
|
||||
parts_count: data.parts?.length ?? 0,
|
||||
has_schema: (data.schemaPics?.length ?? 0) > 0,
|
||||
});
|
||||
}
|
||||
}, [data, hasChildren]);
|
||||
|
||||
const handleBack = () => {
|
||||
const parentId = data?.ancestors?.at(-1)?.id ?? data?.parentId ?? null;
|
||||
if (parentId) {
|
||||
navigate({
|
||||
to: "/demo/categories/$categoryId",
|
||||
params: { categoryId: parentId },
|
||||
});
|
||||
} else {
|
||||
navigate({ to: "/demo" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<DemoBanner vehicleLabel={vehicleLabel} />
|
||||
|
||||
<main className="mx-auto max-w-7xl space-y-4 px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Breadcrumb (inline — demo routes) */}
|
||||
<DemoBreadcrumb
|
||||
vehicleLabel={vehicleLabel}
|
||||
ancestors={data?.ancestors ?? []}
|
||||
currentName={data?.name ?? (isLoading ? "…" : undefined)}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleBack}
|
||||
title="Geri dön"
|
||||
aria-label="Geri dön"
|
||||
data-faro-user-action-name="demo-category-back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">
|
||||
{data?.name ??
|
||||
(isLoading ? (
|
||||
<span className="inline-block h-6 w-48 animate-pulse rounded-md bg-primary/10 align-middle" />
|
||||
) : (
|
||||
"Kategori"
|
||||
))}
|
||||
</h1>
|
||||
{data?.description && (
|
||||
<p className="text-sm text-muted-foreground">{data.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hard error — distinct from data.loadError */}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Kategori yüklenemedi</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{error instanceof Error ? error.message : "Veriler yüklenirken bir hata oluştu."}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && !data && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{KEYS_6.map((k) => (
|
||||
<Skeleton key={k} className="h-20 w-full rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Children grid — when this category is a parent */}
|
||||
{hasChildren && data?.children && <DemoChildrenGrid items={data.children} />}
|
||||
|
||||
{/* Leaf — schema viewer or graceful upstream-error retry */}
|
||||
{data &&
|
||||
!hasChildren &&
|
||||
(data.loadError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-destructive">Katalog şu an yüklenemedi</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Bu kategori tedarikçi katalogundan alınamadı. Lütfen birazdan tekrar deneyin.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Suspense fallback={<SchemaSkeleton />}>
|
||||
<SchemaViewer
|
||||
schemaPic={data.schemaPics?.[0] ?? null}
|
||||
hotspots={data.hotspots ?? []}
|
||||
parts={data.parts ?? []}
|
||||
isLoading={isLoading}
|
||||
vehicleId="demo"
|
||||
categoryId={categoryId}
|
||||
/>
|
||||
</Suspense>
|
||||
))}
|
||||
|
||||
{/* Footer CTA — present on every leaf so the conversion path is always
|
||||
one click away after the user has just had an "aha" moment. Mobile
|
||||
layout stacks with a full-width button and clears the Chatwoot
|
||||
widget that sits in the bottom-right (`mb-20` on mobile). */}
|
||||
{data && !data.loadError && (
|
||||
<div className="mb-20 mt-6 flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-5 sm:mb-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm font-semibold">Sınırsız şase sorgulamak için ücretsiz hesap aç</p>
|
||||
<Button asChild className="w-full shrink-0 sm:w-auto">
|
||||
<Link
|
||||
to="/register"
|
||||
onClick={() => capture("demo_to_register_click", { source: "category_footer" })}
|
||||
>
|
||||
Hesap Aç
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DemoBreadcrumbProps {
|
||||
vehicleLabel: string;
|
||||
ancestors: Array<{ id: string; name: string }>;
|
||||
currentName?: string;
|
||||
}
|
||||
|
||||
function DemoBreadcrumb({ vehicleLabel, ancestors, currentName }: DemoBreadcrumbProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="flex flex-wrap items-center gap-x-1 gap-y-1 text-xs text-muted-foreground"
|
||||
>
|
||||
<Link
|
||||
to="/demo"
|
||||
className="max-w-[220px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{vehicleLabel}
|
||||
</Link>
|
||||
{ancestors.map((node) => (
|
||||
<Fragment key={node.id}>
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<Link
|
||||
to="/demo/categories/$categoryId"
|
||||
params={{ categoryId: node.id }}
|
||||
className="max-w-[180px] truncate rounded px-1 transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
{node.name}
|
||||
</Link>
|
||||
</Fragment>
|
||||
))}
|
||||
{currentName && (
|
||||
<>
|
||||
<ChevronRight aria-hidden className="h-3 w-3 shrink-0" />
|
||||
<span aria-current="page" className="max-w-[220px] truncate px-1 text-foreground">
|
||||
{currentName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
interface DemoChildrenGridProps {
|
||||
items: Array<{ id: string; name: string; children?: unknown[] }>;
|
||||
}
|
||||
|
||||
function DemoChildrenGrid({ items }: DemoChildrenGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{items.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
to="/demo/categories/$categoryId"
|
||||
params={{ categoryId: cat.id }}
|
||||
onClick={() =>
|
||||
capture("demo_category_clicked", {
|
||||
category_id: cat.id,
|
||||
category_name: cat.name,
|
||||
source: "drill",
|
||||
})
|
||||
}
|
||||
className="group flex items-start gap-3 rounded-lg border border-border bg-card p-4 transition-colors hover:border-foreground/30 hover:bg-muted/40"
|
||||
data-faro-user-action-name="demo-child-card"
|
||||
>
|
||||
<FolderOpen className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-sm font-medium leading-snug">{cat.name}</p>
|
||||
{(cat.children?.length ?? 0) > 0 && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{cat.children?.length} alt kategori
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemaSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border md:h-[600px] md:flex-row">
|
||||
<div className="flex h-[300px] items-center justify-center md:h-auto md:w-[60%]">
|
||||
<Skeleton className="h-[80%] w-[80%]" />
|
||||
</div>
|
||||
<div className="w-full space-y-3 p-4 md:w-[40%]">
|
||||
<Skeleton className="h-6 w-1/2" />
|
||||
{KEYS_6.map((k) => (
|
||||
<Skeleton key={k} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,13 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Suspense, lazy, useEffect, useRef, useState } from "react";
|
||||
|
||||
// The example VIN shown as the hero input placeholder. Clicking "Ara" with
|
||||
// the input empty (or already containing this exact VIN — common when users
|
||||
// mistake the placeholder for typed text) opens /demo with the pre-warmed
|
||||
// VW Golf 2003 catalog instead of stalling at the signup wall. Any other
|
||||
// VIN takes the normal /register?vin= path.
|
||||
const EXAMPLE_VIN = "WVWZZZ1JZ3W597935";
|
||||
|
||||
// Remotion — lazy loaded to keep initial bundle lean
|
||||
const RemotionSchemaPlayer = lazy(() =>
|
||||
Promise.all([import("@remotion/player"), import("@/remotion/SchemaDemo")]).then(
|
||||
@@ -366,8 +373,25 @@ export function HomePage() {
|
||||
}, [vin]);
|
||||
|
||||
const handleVinSearch = async () => {
|
||||
const trimmed = vin.trim();
|
||||
if (!trimmed) return;
|
||||
const trimmed = vin.trim().toUpperCase();
|
||||
|
||||
// Empty input OR the example VIN itself → open the public demo instead
|
||||
// of the signup wall. Real visitors were repeatedly clicking "Ara" with
|
||||
// the placeholder showing because they mistook it for typed text; this
|
||||
// turns that dead-end click into an "aha" catalog browse.
|
||||
if (!trimmed || trimmed === EXAMPLE_VIN) {
|
||||
capture("hero_ara_demo_redirect", {
|
||||
had_value: !!trimmed,
|
||||
was_example_match: trimmed === EXAMPLE_VIN,
|
||||
});
|
||||
navigate({
|
||||
to: "/demo",
|
||||
search: {
|
||||
source: trimmed === EXAMPLE_VIN ? "hero_example_match" : "hero_empty",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Read latest auth state directly from store (avoids stale closure)
|
||||
const { user, isLoading } = useAuthStore.getState();
|
||||
@@ -430,7 +454,7 @@ export function HomePage() {
|
||||
};
|
||||
|
||||
const fillExampleVin = () => {
|
||||
setVin("WVWZZZ1JZ3W597935");
|
||||
setVin(EXAMPLE_VIN);
|
||||
setIsExampleVin(true);
|
||||
};
|
||||
|
||||
@@ -933,7 +957,13 @@ export function HomePage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t("landing.hero.searchCta")}
|
||||
{/* When the input is empty or already holds the example
|
||||
VIN, the button takes the visitor to /demo instead of
|
||||
the signup wall — surface that intent in the label so
|
||||
the click outcome is predictable. */}
|
||||
{vin.trim() === "" || vin.trim().toUpperCase() === EXAMPLE_VIN
|
||||
? "Örnek aracı dene"
|
||||
: t("landing.hero.searchCta")}
|
||||
<ArrowRight className="ml-2 size-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user