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:
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user