Merge pull request 'dev' (#78) from dev into main
Reviewed-on: #78
This commit was merged in pull request #78.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -17,7 +17,7 @@ interface DemoBannerProps {
|
||||
*/
|
||||
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="sticky top-16 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" />
|
||||
|
||||
39
apps/web/src/components/demo/demo-footer-cta.tsx
Normal file
39
apps/web/src/components/demo/demo-footer-cta.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Button } from "@sase/ui";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
interface DemoFooterCtaProps {
|
||||
/** Analytics surface tag — e.g. "footer", "category_footer". */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer conversion card on /demo pages — anti-gimmick B2B trust strip +
|
||||
* single "Hesap Aç" primary button routed to /register (no VIN carried;
|
||||
* visitors who want to query their own VIN can use the hero on /, or paste
|
||||
* it on the register page itself). Mobile-friendly: full-width button below
|
||||
* the headline; `mb-20` clears the Chatwoot widget in the bottom-right.
|
||||
*/
|
||||
export function DemoFooterCta({ source }: DemoFooterCtaProps) {
|
||||
return (
|
||||
<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-cta-${source}`}
|
||||
>
|
||||
<Link to="/register" onClick={() => capture("demo_to_register_click", { source })}>
|
||||
Hesap Aç
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 açı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 aç
|
||||
</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>
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { DemoBanner } from "@/components/demo/demo-banner";
|
||||
import { DemoFooterCta } from "@/components/demo/demo-footer-cta";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
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 { Card, CardContent, CardHeader, CardTitle, Skeleton } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowRight, FolderOpen } from "lucide-react";
|
||||
@@ -55,32 +58,37 @@ function DemoVehiclePage() {
|
||||
|
||||
if (vehicleError) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<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>
|
||||
<main className="flex-1">
|
||||
<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>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<DemoBanner vehicleLabel={vehicleLabel} />
|
||||
|
||||
<main className="mx-auto max-w-7xl px-4 py-6 sm:px-6 sm:py-8">
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Vehicle header */}
|
||||
<div className="mb-6">
|
||||
{vehicleLoading ? (
|
||||
@@ -133,32 +141,9 @@ function DemoVehiclePage() {
|
||||
</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>
|
||||
<DemoFooterCta source="footer" />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { DemoBanner } from "@/components/demo/demo-banner";
|
||||
import { DemoFooterCta } from "@/components/demo/demo-footer-cta";
|
||||
import { SchemaViewer } from "@/components/schema/schema-viewer";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import { SiteHeader } from "@/components/site-header";
|
||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||
import type { CategorySchema } from "@/hooks/use-parts";
|
||||
import { api } from "@/lib/api-client";
|
||||
@@ -76,10 +79,11 @@ function DemoCategoryPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<DemoBanner vehicleLabel={vehicleLabel} />
|
||||
|
||||
<main className="mx-auto max-w-7xl space-y-4 px-4 py-6 sm:px-6 sm:py-8">
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 space-y-4 px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Breadcrumb (inline — demo routes) */}
|
||||
<DemoBreadcrumb
|
||||
vehicleLabel={vehicleLabel}
|
||||
@@ -189,25 +193,9 @@ function DemoCategoryPage() {
|
||||
</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>
|
||||
)}
|
||||
{data && !data.loadError && <DemoFooterCta source="category_footer" />}
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user