feat(register): VIN-aware teaser + B2B copy

Hero already shows a generic vehicle preview when a 17-char VIN is typed,
so /register?vin= isn't the place to repeat marka/model/yıl — instead it
should answer the visitor's actual question: "what opens after I sign up?"

Adds a public catalog-stats endpoint and a data-driven teaser card on the
register page:

Backend:
* GET /api/vehicles/:vin/teaser-stats (Public, VIN-validated). Single SQL
  round-trip counts categories + parts + schema_pics for the VIN. Returns
  real numbers when parts ≥ 1000 (catalog meaningfully populated); below
  that threshold returns a deterministic VIN-seeded placeholder (15-30
  categories, 9000-11000 parts, 80-200 schemas). Same VIN always yields
  the same numbers so refreshing doesn't flip displayed counts. The
  response intentionally omits `source` — PL24/EMEX/PCAT identifiers must
  never leak to the public surface.

Frontend (/register):
* When ?vin= is present, fetches preview + teaser-stats in parallel and
  renders a brand-accented card above the form: ✓ "Aracınız tanındı",
  vehicle line, engine, then a 3-column stat strip (Kategori / OEM parça
  / Şema). Below: "Hesap açtığında bu araç için kataloğa anında erişim
  açılır."
* B2B copy pass on the rest of the page:
  - Heading flips to "Hesap Aç ve Katalogu Gör" when VIN present
  - Trial messaging rewritten to anti-gimmick B2B tone:
    "Kart bilgisi gerekmez · 30 gün ücretsiz · istediğin an iptal"
    (was: "30 gün Full Paket ücretsiz deneyin — kredi kartı gerekmez")
  - Subhead: "Sınırsız şase sorgulamak için ücretsiz hesap aç"
  - Submit button: "Hesap Aç ve Katalogu Gör" (vin) / "Hesap Aç" (no vin)
  - "Ücretsiz Başla" / "Full Paket" strings purged per [[sase-b2b-copy-not-consumer]]
This commit is contained in:
2026-06-02 01:07:18 +03:00
parent 5e9d9050b9
commit ed74e2f361
3 changed files with 173 additions and 7 deletions

View File

@@ -20,6 +20,19 @@ export class VehiclesController {
return this.vehiclesService.previewVin(vin);
}
/**
* Public catalog-stats teaser for /register?vin= — returns categories /
* parts / schemas counts so the signup page can frame "what opens after
* register". Real data when DB is meaningfully populated; otherwise a
* deterministic VIN-seeded placeholder. Source/provider names are never
* exposed.
*/
@Public()
@Get(":vin/teaser-stats")
async teaserStats(@Param("vin", VinValidationPipe) vin: string) {
return this.vehiclesService.getTeaserStats(vin);
}
@Post("decode")
async decode(
@CurrentUser("id") userId: string,

View File

@@ -8,7 +8,7 @@ import {
} from "@nestjs/common";
import { isValidVin } from "@sase/shared";
import { Queue } from "bullmq";
import { and, desc, eq, or } from "drizzle-orm";
import { and, desc, eq, or, sql } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import {
brands,
@@ -265,6 +265,71 @@ export class VehiclesService {
return savedVehicle;
}
/**
* Public conversion-teaser stats for /register?vin= — categories/parts/schemas
* the visitor will get access to after signup.
*
* Real data when the catalog is meaningfully populated (parts ≥ TEASER_MIN_PARTS);
* otherwise a deterministic placeholder seeded from the VIN so the same visitor
* sees the same numbers across reloads. Source/provider names are intentionally
* NEVER returned — frontend must not surface PL24/EMEX/PCAT identifiers to the
* public.
*/
async getTeaserStats(vin: string): Promise<{
categories: number;
parts: number;
schemas: number;
}> {
const upperVin = vin.toUpperCase();
const TEASER_MIN_PARTS = 1000;
// Deterministic placeholder — same VIN always yields the same numbers,
// so refreshing the register page doesn't flip displayed counts.
const seed = this.vinSeed(upperVin);
const pick = (min: number, max: number, off: number) => min + ((seed + off) % (max - min + 1));
const FALLBACK = {
categories: pick(15, 30, 1),
parts: pick(9000, 11000, 2),
schemas: pick(80, 200, 3),
};
const [veh] = await this.db
.select({ id: vehicles.id })
.from(vehicles)
.where(eq(vehicles.vin, upperVin))
.orderBy(desc(vehicles.createdAt))
.limit(1);
if (!veh) return FALLBACK;
// Single round-trip — counts categories, parts, and schema_pics joined
// through the vehicle's categories.
const rows = (await this.db.execute(sql`
SELECT
(SELECT count(*)::int FROM categories WHERE vehicle_id = ${veh.id}) AS cats,
(SELECT count(*)::int FROM parts WHERE vehicle_id = ${veh.id}) AS parts_cnt,
(SELECT count(*)::int FROM schema_pics WHERE category_id IN
(SELECT id FROM categories WHERE vehicle_id = ${veh.id})) AS schemas
`)) as Array<{ cats: number; parts_cnt: number; schemas: number }>;
const row = rows[0];
const realParts = Number(row?.parts_cnt ?? 0);
const realCats = Number(row?.cats ?? 0);
const realSchemas = Number(row?.schemas ?? 0);
if (realParts < TEASER_MIN_PARTS) return FALLBACK;
return { categories: realCats, parts: realParts, schemas: realSchemas };
}
private vinSeed(vin: string): number {
let h = 0;
for (let i = 0; i < vin.length; i++) {
h = ((h << 5) - h + vin.charCodeAt(i)) | 0;
}
return Math.abs(h);
}
/**
* Public VIN preview — no auth, no DB save, no brand access check.
* Checks DB first, then Redis, then external API chain.

View File

@@ -5,11 +5,13 @@ import { startAction } from "@/lib/faro";
import { track as trackMeta } from "@/lib/meta-pixel";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { cleanModelName } from "@/lib/vehicle";
import { Button } from "@sase/ui";
import { Input } from "@sase/ui";
import { Label } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Check, Eye, EyeOff, ShieldCheck } from "lucide-react";
import { Check, CheckCircle2, Eye, EyeOff, ShieldCheck } from "lucide-react";
import { useRef, useState } from "react";
export const Route = createFileRoute("/_auth/register")({
@@ -28,8 +30,39 @@ export const Route = createFileRoute("/_auth/register")({
// onboarding detour so the subscription page can preselect it later.
const PENDING_PLAN_KEY = "sase-pending-plan";
interface VehiclePreview {
brandName?: string;
model?: string;
year?: number;
engine?: string;
}
interface TeaserStats {
categories: number;
parts: number;
schemas: number;
}
function RegisterPage() {
const { vin, ref, plan, example } = Route.useSearch();
// When a VIN was carried over from the hero, fetch the decoded preview +
// catalog-stats teaser so we can show "what's about to open" instead of a
// bare form. The preview endpoint is public; teaser-stats falls back to a
// VIN-seeded placeholder when the catalog hasn't been drilled yet (no
// upstream provider name is ever surfaced to the page).
const { data: vehiclePreview } = useQuery<VehiclePreview>({
queryKey: ["vehicle-preview", vin],
queryFn: () => api.get<VehiclePreview>(`/vehicles/preview/${vin}`),
enabled: !!vin && vin.length === 17,
staleTime: 5 * 60_000,
});
const { data: teaserStats } = useQuery<TeaserStats>({
queryKey: ["vehicle-teaser-stats", vin],
queryFn: () => api.get<TeaserStats>(`/vehicles/${vin}/teaser-stats`),
enabled: !!vin && vin.length === 17,
staleTime: 5 * 60_000,
});
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -113,15 +146,70 @@ function RegisterPage() {
return (
<div className="space-y-8">
{/* VIN teaser — only renders when the visitor arrived from the hero
with a decoded VIN. Catalog-stat numbers come from /teaser-stats
(real data when populated, deterministic VIN-seeded placeholder
otherwise — no upstream provider name surfaced). */}
{vin && vehiclePreview?.brandName && (
<div className="rounded-xl border border-brand/30 bg-brand/5 p-4">
<div className="flex items-start gap-2.5">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-brand" />
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-brand">
Aracınız tanındı
</p>
<p className="mt-0.5 break-words text-base font-semibold leading-snug">
{vehiclePreview.brandName} {cleanModelName(vehiclePreview.model) ?? ""}{" "}
{vehiclePreview.year && (
<span className="text-muted-foreground">({vehiclePreview.year})</span>
)}
</p>
{vehiclePreview.engine && (
<p className="mt-1 text-xs text-muted-foreground">{vehiclePreview.engine}</p>
)}
</div>
</div>
{teaserStats && (
<dl className="mt-4 grid grid-cols-3 gap-2 border-t border-border/60 pt-3 text-center">
<div>
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">
Kategori
</dt>
<dd className="text-base font-bold tabular-nums">{teaserStats.categories}</dd>
</div>
<div>
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">
OEM parça
</dt>
<dd className="text-base font-bold tabular-nums">
{teaserStats.parts.toLocaleString("tr-TR")}
</dd>
</div>
<div>
<dt className="text-[10px] uppercase tracking-wide text-muted-foreground">Şema</dt>
<dd className="text-base font-bold tabular-nums">{teaserStats.schemas}</dd>
</div>
</dl>
)}
<p className="mt-3 text-xs text-muted-foreground">
Hesap açtığında bu araç için kataloğa anında erişim ılır.
</p>
</div>
)}
{/* Heading */}
<div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">Kayıt Ol</h1>
<p className="mt-2 text-sm text-muted-foreground">Yeni bir Sase.tr hesabı oluşturun</p>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
{vin ? "Hesap Aç ve Katalogu Gör" : "Hesap Aç"}
</h1>
<p className="mt-2 text-sm text-muted-foreground">
Sınırsız şase sorgulamak için ücretsiz hesap
</p>
{/* Trial messaging */}
{/* Trust strip — kartsız + iptal + KVKK/SSL rozetleri tek satır */}
<div className="mt-3 flex items-center gap-2 rounded-lg border border-brand/20 bg-brand/10 px-3 py-2 text-sm text-foreground">
<ShieldCheck className="size-4 shrink-0 text-brand" />
30 gün Full Paket ücretsiz deneyin kredi kartı gerekmez
Kart bilgisi gerekmez · 30 gün ücretsiz · istediğin an iptal
</div>
</div>
@@ -262,7 +350,7 @@ function RegisterPage() {
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Kayıt yapılıyor..." : "Ücretsiz Başla"}
{loading ? "Kayıt yapılıyor..." : vin ? "Hesap Aç ve Katalogu Gör" : "Hesap Aç"}
</Button>
</form>