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