feat(demo): public /demo namespace serving pre-warmed VW Golf 2003 catalog

Replaces the old marketing "guided tour" /demo with a real, fully-functional
catalog browsing experience for the pre-warmed example vehicle. No auth
required, no upstream calls — entirely served from prod DB.

Backend (apps/api/src/demo):
* New @Public() controller exposing five endpoints under /api/demo:
  - GET /vehicle                    → demo vehicle metadata
  - GET /categories/tree            → top-level category tree
  - GET /categories/search?q=       → cross-tree search
  - GET /categories/:id             → getCategoryWithParts (parts+schema+hotspots)
  - GET /categories/:id/children    → drill children
* DemoService validates every category id against DEMO_VEHICLE_ID before any
  downstream service call — the public surface can't be used to read an
  arbitrary vehicle's catalog (1-row SELECT, NotFound on miss or wrong owner).
* Vehicle id is env-driven (DEMO_VEHICLE_ID, defaults to the pre-warmed
  WVWZZZ1JZ3W597935 — VW Golf 2003 with 277 cats / 9841 parts / 178 schemas
  fully drilled in prod).
* Wires CategoriesModule (already exports CategoriesService) — zero new
  business logic, just a thin public façade.

Frontend (apps/web):
* /demo (replaces old marketing page): vehicle header + top categories grid
  reading /api/demo/* + sticky DemoBanner with sign-up CTA.
* /demo/categories/$categoryId: drill page rendering either a children grid
  (parent) or the existing SchemaViewer + parts panel (leaf) — same shape
  the dashboard uses, so hotspot overlay, breadcrumb trail, retry on
  upstream loadError all just work.
* DemoBanner: sticky top, "Örnek araç: {label} — Kayıt Ol" CTA. The
  "Yeni VIN sorgula" explicit paywall trigger lands in a follow-up task.
* PostHog events: demo_loaded (source query-param-aware),
  demo_category_clicked, demo_category_detail_viewed, demo_to_register_click
  (banner / footer / category_footer placements).
* usePageMeta gains an opt-in `noindex` flag — demo sets it to noindex,follow
  for the first 4-6 weeks per spec; cleaned up on unmount so SPA navigation
  doesn't carry it to the next route.
This commit is contained in:
2026-06-02 00:07:29 +03:00
parent 286307155e
commit 078076b619
9 changed files with 1251 additions and 952 deletions

View File

@@ -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,

View 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);
}
}

View 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 {}

View 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");
}
}
}

View 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">
{" "}
Kendi aracınız için ücretsiz hesap
</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>
);
}

View File

@@ -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

View File

@@ -1,423 +1,195 @@
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 */}
<div className="mt-8 flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold">Kendi aracınız için sınırsız erişim</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="shrink-0" data-faro-user-action-name="demo-footer-signup">
<Link
to="/register"
onClick={() => capture("demo_to_register_click", { source: "footer" })}
>
Hesap
<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>
);
}

View File

@@ -0,0 +1,307 @@
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. */}
{data && !data.loadError && (
<div className="mt-6 flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-5 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm font-semibold">Kendi aracınız için sınırsız erişim</p>
<Button asChild className="shrink-0">
<Link
to="/register"
onClick={() => capture("demo_to_register_click", { source: "category_footer" })}
>
Hesap
<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>
);
}