Merge pull request 'dev' (#43) from dev into main

Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
2026-05-24 20:40:01 +00:00
6 changed files with 457 additions and 89 deletions

View File

@@ -0,0 +1,256 @@
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { getUserSettings } from "@/lib/user-settings";
import {
Badge,
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Separator,
} from "@sase/ui";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight, Check, CheckCircle2, Loader2, Sparkles } from "lucide-react";
import { Suspense, lazy, useEffect, useRef, useState } from "react";
const LazyPlayer = lazy(() => import("@remotion/player").then((mod) => ({ default: mod.Player })));
const LazyOnboardingProgress = lazy(() =>
import("@/remotion/OnboardingProgress").then((mod) => ({
default: mod.OnboardingProgress as React.FC<Record<string, unknown>>,
})),
);
interface SubMe {
subscription: {
status: string;
plan?: { name: string; key: string };
billingPeriod?: string;
endDate?: string;
} | null;
eligibleForTrial: boolean;
}
/**
* Post-signup "account activation" motion, shown as a modal over the search page.
*
* Replaces the old full-page provisioning view that lived on /dashboard/subscription.
* New users now land directly on /dashboard/search (value-first) — this modal runs the
* trial provisioning + onboarding animation on top, then steps out of the way so the
* user can immediately decode a VIN.
*
* Side effects, fired once the subscription state is known:
* - applies a carried-over referral code (needed for the Google OAuth signup path,
* which can't apply it before the redirect)
* - starts the free trial (`POST /subscriptions/trial`) and fires `trial_started` for
* EVERY eligible new user — previously this only fired on a rarely-clicked button,
* which is why the Onboarding funnel read 0 at the trial step.
*/
export function WelcomeOnboardingModal({
refCode,
onFinished,
}: {
refCode: string | null;
onFinished: () => void;
}) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [phase, setPhase] = useState<"provisioning" | "completed">("provisioning");
const [animationEnded, setAnimationEnded] = useState(false);
const provisionedRef = useRef(false);
const refAppliedRef = useRef(false);
const [isDark] = useState(() => {
const theme = getUserSettings().theme ?? "dark";
return theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
: theme === "dark";
});
const { data: subData } = useQuery({
queryKey: ["subscription", "me"],
queryFn: () => api.get<SubMe>("/subscriptions/me"),
});
const eligibleForTrial = subData?.eligibleForTrial ?? false;
const subscription = subData?.subscription ?? null;
const trialMutation = useMutation({
mutationFn: () => api.post("/subscriptions/trial"),
onSuccess: () => {},
onError: () => {},
});
// Apply a carried-over referral code once (covers the Google OAuth signup path,
// where register.tsx can't apply it before redirecting away).
useEffect(() => {
if (refAppliedRef.current || !refCode) return;
refAppliedRef.current = true;
api.post("/referrals/apply", { code: refCode.toUpperCase().trim() }).catch(() => {});
}, [refCode]);
// Kick off trial provisioning once we know the subscription state.
useEffect(() => {
if (provisionedRef.current || !subData) return;
provisionedRef.current = true;
if (!eligibleForTrial) {
// Already on a plan / trial already used — no motion, just step aside.
onFinished();
return;
}
capture("trial_started", { source: "welcome_onboarding" });
trialMutation.mutate();
}, [subData, eligibleForTrial, onFinished, trialMutation.mutate]);
// The animation runs ~7s; mark it ended a touch after.
useEffect(() => {
const timer = setTimeout(() => setAnimationEnded(true), 7500);
return () => clearTimeout(timer);
}, []);
// Move to the completed state once the animation finished AND the trial provisioned.
useEffect(() => {
if (phase !== "provisioning") return;
if (!animationEnded || !trialMutation.isSuccess) return;
setPhase("completed");
queryClient.invalidateQueries({ queryKey: ["subscription"] });
import("canvas-confetti").then((mod) => {
mod.default({ particleCount: 150, spread: 80, origin: { y: 0.6 } });
});
}, [phase, animationEnded, trialMutation.isSuccess, queryClient]);
return (
<Dialog
open
onOpenChange={(open) => {
// Only the completed-state CTA / Esc may dismiss; ignore closes mid-provisioning.
if (!open && phase === "completed") onFinished();
}}
>
<DialogContent
className="max-w-[calc(100vw-2rem)] overflow-hidden border-brand/25 [&>button]:hidden sm:max-w-lg"
onInteractOutside={(e) => {
if (phase !== "completed") e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (phase !== "completed") e.preventDefault();
}}
>
{phase === "provisioning" ? (
<div className="flex flex-col items-center gap-6 py-4">
<DialogHeader className="items-center">
<DialogTitle className="flex items-center gap-2 text-xl">
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
{t("subscription.onboarding.provisioning")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("subscription.onboarding.provisioning")}
</DialogDescription>
</DialogHeader>
<Suspense
fallback={
<div className="flex h-[180px] w-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-brand" />
</div>
}
>
<LazyPlayer
component={LazyOnboardingProgress}
inputProps={{
isDark,
stepLabels: [
t("subscription.onboarding.step1"),
t("subscription.onboarding.step2"),
t("subscription.onboarding.step3"),
t("subscription.onboarding.step4"),
],
}}
durationInFrames={210}
fps={30}
compositionWidth={800}
compositionHeight={200}
autoPlay
style={{ width: "100%", maxWidth: 520, aspectRatio: "800 / 200" }}
controls={false}
/>
</Suspense>
{animationEnded && trialMutation.isPending && (
<div className="flex items-center gap-2 text-sm leading-5 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("subscription.onboarding.step4")}...
</div>
)}
{trialMutation.isError && (
<div className="flex flex-col items-center gap-3">
<p className="text-sm leading-5 text-red-600 dark:text-red-400">
{t("subscription.onboarding.error")}
</p>
<Button variant="outline" onClick={() => trialMutation.mutate()}>
{t("subscription.onboarding.retry")}
</Button>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center gap-6 py-4">
<DialogHeader className="items-center">
<CheckCircle2 className="mx-auto h-14 w-14 text-brand" />
<DialogTitle className="text-center text-2xl">
{t("subscription.onboarding.completed")}
</DialogTitle>
<DialogDescription className="sr-only">
{t("subscription.onboarding.completed")}
</DialogDescription>
</DialogHeader>
<div className="w-full space-y-4 rounded-xl border border-brand/20 bg-background/60 p-5">
<div className="flex items-center justify-between">
<span className="text-sm leading-5 text-muted-foreground">
{t("subscription.currentPlan")}
</span>
<Badge className="bg-brand text-brand-foreground">
{t("subscription.plans.full.name")}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm leading-5 text-muted-foreground">
{t("subscription.billingPeriod")}
</span>
<span className="text-sm font-medium leading-5">
{t("subscription.onboarding.trialDuration")}
</span>
</div>
{subscription?.endDate && (
<div className="flex items-center justify-between">
<span className="text-sm leading-5 text-muted-foreground">
{t("subscription.endDate")}
</span>
<span className="text-sm font-medium leading-5">
{new Date(subscription.endDate).toLocaleDateString("tr-TR")}
</span>
</div>
)}
<Separator />
<ul className="space-y-2 text-sm leading-relaxed">
{["allBrands", "vinSearch", "partsCatalog", "schemaViewer"].map((f) => (
<li key={f} className="flex items-center gap-2 text-foreground/85">
<Check className="h-4 w-4 text-brand" />
{t(`subscription.features.${f}`)}
</li>
))}
</ul>
</div>
<Button
size="lg"
className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={onFinished}
>
{t("subscription.onboarding.startSearching")}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -43,7 +43,7 @@ vi.mock("@tanstack/react-router", async () => {
return {
...actual,
useNavigate: () => vi.fn(),
createFileRoute: () => (options: any) => ({ options }),
createFileRoute: () => (options: any) => ({ options, useSearch: () => ({}) }),
Link: ({ children, to, ...props }: any) => (
<a href={to} {...props}>
{children}
@@ -98,7 +98,7 @@ async function submitForm() {
const TEST_VIN = "WVWZZZ1JZ3W597935";
test("retry happy path: shows banner, retry button calls api twice and emits events", async () => {
test("decode retry: banner shows, re-submitting calls the API again and then succeeds", async () => {
const { ApiError: MockApiError } = await import("@/lib/api-client");
(api.post as any)
.mockRejectedValueOnce(new (MockApiError as any)(503, "Servis geçici olarak kullanılamıyor"))
@@ -115,10 +115,12 @@ test("retry happy path: shows banner, retry button calls api twice and emits eve
expect(screen.getByRole("alert")).toHaveTextContent("Şase çözümlenemedi");
expect(screen.getByRole("alert")).toHaveTextContent("Servis geçici olarak kullanılamıyor");
const retryButton = screen.getByRole("button", { name: /Tekrar Dene/i });
expect(retryButton).toBeInTheDocument();
// There is no separate "Tekrar Dene" CTA — the submit button doubles as the
// retry (it re-enables after a transient error because the VIN is still valid),
// so re-submitting is the retry path. (FN-415 / a33deef dropped the duplicate.)
expect(screen.queryByRole("button", { name: /Tekrar Dene/i })).toBeNull();
fireEvent.click(retryButton);
await submitForm();
await waitFor(() => {
expect(api.post).toHaveBeenCalledTimes(2);
@@ -127,11 +129,6 @@ test("retry happy path: shows banner, retry button calls api twice and emits eve
expect(api.post).toHaveBeenNthCalledWith(1, "/vehicles/decode", { vin: TEST_VIN });
expect(api.post).toHaveBeenNthCalledWith(2, "/vehicles/decode", { vin: TEST_VIN });
expect(capture).toHaveBeenCalledWith(
"vin_decode_retry_clicked",
expect.objectContaining({ attempt: 2 }),
);
await waitFor(() => {
expect(capture).toHaveBeenCalledWith(
"vin_decode_success",
@@ -199,41 +196,23 @@ test("locator stability: canonical Faro/ARIA hooks resolve and no data-testid is
expect(alert).toHaveAttribute("aria-live", "assertive");
expect(alert).toHaveTextContent("Şase çözümlenemedi");
// (a) Retry — [data-faro-user-action-name="vin-decode-retry"].
const retryByFaro = container.querySelector(
'[data-faro-user-action-name="vin-decode-retry"]',
) as HTMLButtonElement | null;
expect(retryByFaro).not.toBeNull();
// (c) Retry accessible-name fallback.
const retryByName = screen.getByRole("button", { name: "Tekrar Dene" });
expect(retryByName).toBe(retryByFaro);
// (a) No separate retry CTA — the submit button doubles as retry post-error
// (FN-415 / a33deef dropped the duplicate "Tekrar Dene" affordance).
expect(screen.queryByRole("button", { name: "Tekrar Dene" })).toBeNull();
// AC 4 (drift hardening) — no data-testid attributes anywhere in the render.
expect(container.querySelector("[data-testid]")).toBeNull();
});
test("hit-target audit: retry button meets the 44px Apple HIG / Material floor", async () => {
const { ApiError: MockApiError } = await import("@/lib/api-client");
(api.post as any).mockRejectedValueOnce(
new (MockApiError as any)(503, "Servis geçici olarak kullanılamıyor"),
);
test("hit-target audit: the submit CTA meets the 44px Apple HIG / Material floor", async () => {
renderSearch();
typeVin(TEST_VIN);
await submitForm();
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
const retry = screen.getByRole("button", { name: "Tekrar Dene" });
// The retry button uses Tailwind `h-11` (= 2.75rem = 44px at the default
// 16px root) and `w-full`, so it inherits the parent error-banner width on
// any viewport ≥ ~280px (well below the iPhone SE 375px baseline). Assert
// the class contract — jsdom does not run layout, so a getBoundingClientRect
// measurement would always be 0×0 and is intentionally avoided here.
expect(retry.className).toMatch(/(^|\s)h-11(\s|$)/);
expect(retry.className).toMatch(/(^|\s)w-full(\s|$)/);
// The retry CTA was folded into the submit button (FN-415 / a33deef), which is
// the primary tappable affordance and also the post-error retry. It uses
// Tailwind `h-12` (= 3rem = 48px at the 16px root) and `w-full`, clearing the
// 44px floor. jsdom does not run layout, so assert the class contract — a
// getBoundingClientRect measurement would always be 0×0 here.
const submit = screen.getByRole("button", { name: "Şase Çöz" });
expect(submit.className).toMatch(/(^|\s)h-12(\s|$)/);
expect(submit.className).toMatch(/(^|\s)w-full(\s|$)/);
});

View File

@@ -26,8 +26,11 @@ function RegisterPage() {
const [password, setPassword] = useState("");
const [refCode, setRefCode] = useState(ref || "");
const [loading, setLoading] = useState(false);
// Land new users straight on the VIN search (value-first). The activation
// "motion" + trial provisioning now runs as a modal on the search page
// (see WelcomeOnboardingModal), so we carry welcome/vin/ref across.
const redirectUrl = [
"/dashboard/subscription?welcome=1",
"/dashboard/search?welcome=1",
vin ? `&vin=${encodeURIComponent(vin)}` : "",
refCode.trim() ? `&ref=${encodeURIComponent(refCode.trim().toUpperCase())}` : "",
].join("");

View File

@@ -1,3 +1,4 @@
import { WelcomeOnboardingModal } from "@/components/onboarding/welcome-onboarding-modal";
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
import { ApiError, api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
@@ -42,13 +43,21 @@ function sanitizeVin(raw: string): { cleaned: string; corrections: string[] } {
export const Route = createFileRoute("/dashboard/search")({
component: SearchPage,
validateSearch: (
search: Record<string, unknown>,
): { vin?: string; welcome?: string; ref?: string } => ({
vin: search.vin ? String(search.vin) : undefined,
welcome: search.welcome ? String(search.welcome) : undefined,
ref: search.ref ? String(search.ref) : undefined,
}),
});
function SearchPage() {
const navigate = useNavigate();
const { vin: vinParam, welcome: welcomeParam, ref: refParam } = Route.useSearch();
const inputRef = useRef<HTMLInputElement>(null);
const focusFiredRef = useRef(false);
const querySourceRef = useRef<"manual" | "paste" | "history">("manual");
const querySourceRef = useRef<"manual" | "paste" | "history" | "landing">("manual");
const candidatesShownAtRef = useRef<number | null>(null);
const lastAttemptedVinRef = useRef<string | null>(null);
const attemptCountRef = useRef<number>(0);
@@ -58,6 +67,12 @@ function SearchPage() {
const [reportSending, setReportSending] = useState(false);
const [reportSent, setReportSent] = useState(false);
// Post-signup activation modal + landing-page VIN hand-off (see register.tsx).
const [showWelcome, setShowWelcome] = useState(() => welcomeParam === "1");
const [initialRef] = useState(() => refParam ?? null);
const pendingVinRef = useRef<string | null>(null);
const didInitFromUrlRef = useRef(false);
// Vehicle candidate selection (PartsCatalogs or EMEX multi-result)
const [candidates, setCandidates] = useState<any[] | null>(null);
const [candidateVin, setCandidateVin] = useState("");
@@ -213,6 +228,48 @@ function SearchPage() {
await runDecode(cleanVin, 1);
}
// Decode a VIN carried over from the landing page — no manual re-typing needed.
function autoDecode(rawVin: string) {
const cleanVin = rawVin.toUpperCase().trim();
querySourceRef.current = "landing";
capture("vin_decoded", { vin: cleanVin, query_source: "landing" });
if (!isValidVin(cleanVin)) {
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
return;
}
lastAttemptedVinRef.current = cleanVin;
attemptCountRef.current = 1;
void runDecode(cleanVin, 1);
}
// Dismiss the activation modal, then decode any VIN that rode in from landing.
function handleWelcomeFinished() {
setShowWelcome(false);
const pending = pendingVinRef.current;
pendingVinRef.current = null;
if (pending) autoDecode(pending);
}
// On first mount: prefill a carried VIN and decode it (deferring past the welcome
// modal when present), then strip the params so a refresh won't replay onboarding.
// biome-ignore lint/correctness/useExhaustiveDependencies: one-time mount init from the initial URL params; re-runs are guarded by didInitFromUrlRef
useEffect(() => {
if (didInitFromUrlRef.current) return;
didInitFromUrlRef.current = true;
const carried = vinParam?.toUpperCase().trim();
if (carried) {
setVin(carried);
if (welcomeParam === "1") {
pendingVinRef.current = carried;
} else {
autoDecode(carried);
}
}
if (vinParam || welcomeParam || refParam) {
navigate({ to: "/dashboard/search", replace: true, search: {} });
}
}, []);
async function handleReportVin() {
setReportSending(true);
try {
@@ -321,6 +378,9 @@ function SearchPage() {
return (
<div className="mx-auto max-w-3xl space-y-8">
{showWelcome && (
<WelcomeOnboardingModal refCode={initialRef} onFinished={handleWelcomeFinished} />
)}
{/* ─── SECTION 1: Header + Form (no card) ─────────────────────────── */}
<header className="flex items-start gap-4">
<div

View File

@@ -193,6 +193,7 @@ export function SubscriptionPage() {
const [onboardingPhase, setOnboardingPhase] = useState<"provisioning" | "completed" | null>(null);
const [animationEnded, setAnimationEnded] = useState(false);
const hasFiredRef = useRef(false);
const checkoutStartedRef = useRef(false);
const [isDark] = useState(() => {
const theme = getUserSettings().theme ?? "dark";
return theme === "system"
@@ -332,6 +333,22 @@ export function SubscriptionPage() {
return () => clearTimeout(timer);
}, [onboardingPhase]);
// checkout_started fires once when the user reaches the payment step, via any
// path (plan card, sticky CTA, or a prefilled pending plan). It previously
// fired only for the "full" plan, under-counting vs the downstream
// payment_initiated; gating on the payment step fixes that and keeps the
// plan -> checkout funnel step meaningful (brand-selection drop-off shows up).
useEffect(() => {
if (step === "plan") {
checkoutStartedRef.current = false; // re-arm if the user restarts checkout
return;
}
if (step === "payment" && selectedPlanKey && !checkoutStartedRef.current) {
checkoutStartedRef.current = true;
capture("checkout_started", { plan: selectedPlanKey, period: billingPeriod });
}
}, [step, selectedPlanKey, billingPeriod]);
useEffect(() => {
if (search.stripe === "success" && !stripeResultRef.current) {
stripeResultRef.current = true;
@@ -423,9 +440,8 @@ export function SubscriptionPage() {
capture("plan_selected", { plan: planKey });
setSelectedPlanKey(planKey);
setSelectedBrandIds([]);
if (planKey === "full") {
capture("checkout_started", { plan: planKey, period: billingPeriod });
}
// checkout_started now fires from the step-driven effect when the payment
// step is reached (covers every plan + advance path), so we don't fire here.
setStep(planKey === "full" ? "payment" : "brands");
}
@@ -764,7 +780,6 @@ export function SubscriptionPage() {
onAdvance={() => {
if (step === "plan") {
if (!selectedPlanKey) return;
capture("checkout_started", { plan: selectedPlanKey, period: billingPeriod });
setStep(isFullPlan ? "payment" : "brands");
} else if (step === "brands") {
handleAdvanceFromBrands();

View File

@@ -1,9 +1,9 @@
# Sase.tr - Project Documentation Index
> **Automotive parts search platform** for the Turkish market with VIN decoding, subscription-based access, and multi-source parts catalog integration.
> **URL:** https://sase.tr | **Repo:** `/home/s/ss`
> **URL:** https://sase.tr · **Staging:** https://dev.sase.tr | **Repo:** `/home/s/sase.tr`
Generated: 2026-03-02
Generated: 2026-03-02 · Last refreshed: 2026-05-24
---
@@ -74,15 +74,15 @@ Generated: 2026-03-02
| **Database** | PostgreSQL 17, Drizzle ORM 0.41 |
| **Cache** | Redis 7.4, ioredis |
| **Auth** | Better Auth 1.2 (email/password + Google OAuth) |
| **Payments** | Stripe (card), EFT (bank transfer with receipt upload) |
| **Payments** | Stripe (card checkout + webhook) |
| **Storage** | MinIO (S3-compatible) |
| **Jobs** | BullMQ (Redis-backed queues) |
| **Email** | Postal (transactional email) |
| **Analytics** | PostHog (product analytics) |
| **Observability** | OpenTelemetry (API), Grafana Faro (frontend) |
| **Observability** | OpenTelemetry + Sentry (API), Grafana Faro (frontend) |
| **Testing** | Vitest 3, Playwright 1.50 |
| **Linting** | Biome (2-space, double quotes, semicolons, trailing commas) |
| **CI/CD** | GitHub Actions → SSH deploy → PM2 |
| **CI/CD** | GitHub Actions `qa-gate` (PR tests) · Coolify (dev.sase.tr) · GitHub Actions → PM2 (prod) |
| **Package Mgmt** | pnpm 10.29, Turborepo 2 |
---
@@ -95,15 +95,17 @@ sase.tr/
│ ├── api/ # NestJS backend API
│ │ ├── src/
│ │ │ ├── main.ts # Bootstrap (global prefix /api, CORS, Helmet, rate limiting)
│ │ │ ├── instrument.ts # Sentry init (imported first in main.ts)
│ │ │ ├── app.module.ts # Root module (global guards, interceptors, filters)
│ │ │ ├── worker.ts # Standalone worker process
│ │ │ ├── instrument-worker.ts # Sentry init for the worker process
│ │ │ ├── health.controller.ts # Health check endpoint
│ │ │ ├── auth/ # Better Auth integration
│ │ │ ├── users/ # User account management
│ │ │ ├── brands/ # Brand CRUD
│ │ │ ├── plans/ # Pricing plan CRUD
│ │ │ ├── subscriptions/ # Subscription lifecycle
│ │ │ ├── payments/ # Stripe + EFT payment processing
│ │ │ ├── payments/ # Stripe payment processing (checkout + webhook)
│ │ │ ├── referrals/ # Referral program
│ │ │ ├── vehicles/ # VIN decoding + vehicle history
│ │ │ ├── categories/ # Parts category tree
@@ -111,7 +113,11 @@ sase.tr/
│ │ │ ├── catalog/ # VIN-less catalog browser (PL24 model families)
│ │ │ ├── translations/ # Automotive term translations
│ │ │ ├── admin/ # Admin dashboard endpoints
│ │ │ ├── analytics/ # Usage analytics tracking
│ │ │ ├── internal-admin/ # Founder-only "Supe Panel" (impersonation, lifecycle, billing, refunds)
│ │ │ ├── analytics/ # OEM copy-event tracking
│ │ │ ├── posthog/ # PostHog server-side event capture
│ │ │ ├── blog/ # Blog posts (public + automation webhook)
│ │ │ ├── changelog/ # Changelog entries (public + automation webhook)
│ │ │ ├── common/ # Shared guards, pipes, interceptors, filters, decorators
│ │ │ ├── config/ # Runtime configuration
│ │ │ ├── database/ # Drizzle ORM setup + schemas (core, emex, pl24, parts-catalogs, relations)
@@ -175,6 +181,7 @@ sase.tr/
| API Server | `apps/api/src/main.ts` | NestJS bootstrap (Helmet, CORS, rate limiting) |
| Root Module | `apps/api/src/app.module.ts` | Global guards, interceptors, filters |
| Worker | `apps/api/src/worker.ts` | BullMQ background job processor |
| Sentry Init | `apps/api/src/instrument.ts` · `instrument-worker.ts` | Sentry instrumentation (imported before app bootstrap) |
| Health | `apps/api/src/health.controller.ts` | Health check endpoint |
| Frontend | `apps/web/src/main.tsx` | React 19 + TanStack Router + Query + Faro + PostHog |
| Root Layout | `apps/web/src/routes/__root.tsx` | Theme, Toaster, PostHog tracking |
@@ -192,15 +199,19 @@ sase.tr/
| **BrandsModule** | module, service, controller, spec | Brand CRUD (cached, admin-managed) |
| **PlansModule** | module, service, controller, spec | Pricing plan CRUD (cached, admin-managed) |
| **SubscriptionsModule** | module, service, controller, spec | Create, activate, cancel, resume, extend subscriptions |
| **PaymentsModule** | module, service, controller, spec | Stripe card payments, EFT with receipt upload, admin approval |
| **PaymentsModule** | module, service, controller, spec | Stripe card payments (checkout session + webhook), payment history |
| **ReferralsModule** | module, service, controller, spec | Referral code generation, application, tier-based rewards |
| **VehiclesModule** | module, service, controller, spec | VIN decode (multi-source fallback), vehicle history, brand access check |
| **CategoriesModule** | module, service, controller, spec | Hierarchical category tree, schema pictures |
| **PartsModule** | module, service, controller, spec | Parts by category, OEM code search |
| **CatalogModule** | module, service, controller, dto | VIN-less PL24 catalog browser: brands, models, category trees, parts |
| **TranslationsModule** | module, service, controller, spec | Automotive term translation (Redis → DB → Dictionary fallback) |
| **AdminModule** | module, service, controller, spec | Dashboard stats, user management, payment approval, analytics |
| **AnalyticsModule** | module, service, controller | Usage analytics tracking |
| **AdminModule** | module, service, controller, spec | Dashboard stats, user management, query logs, referral & daily stats, OEM copy logs |
| **InternalAdminModule** | impersonation, lifecycle, billing, payments, vehicles (controller+service each) | Founder-only "Supe Panel": read-only impersonation, suspend/ban, trial/plan edits, refunds, vehicle cache ops. Gated by `InternalTokenGuard` (`INTERNAL_API_TOKEN`) |
| **AnalyticsModule** | module, service, controller | OEM code copy-event tracking (`oemCodeCopies`) |
| **PostHogModule** | module, service | Server-side PostHog event capture (optional) |
| **BlogModule** | module, service, controller | Blog posts: public list/detail + automation webhook (Bearer token) |
| **ChangelogModule** | module, service, controller, spec | Changelog entries: public list + admin CRUD + automation webhook |
| **EmailModule** | module, service | Postal transactional emails (password reset, welcome, payment confirmation) |
| **StorageModule** | module, service | S3/MinIO file upload/download |
| **RedisModule** | module, service, provider | Key-value cache operations |
@@ -213,6 +224,8 @@ sase.tr/
| **Guard** | `RolesGuard` (global) | Checks `@Roles("admin")` metadata against `user.role` |
| **Guard** | `BrandAccessGuard` (per-route) | Verifies user's subscription includes the target brand |
| **Guard** | `ThrottlerGuard` (global) | Rate limiting (100/min default) |
| **Guard** | `ImpersonationReadonlyGuard` (global) | Blocks mutations during a read-only impersonation session |
| **Guard** | `InternalTokenGuard` (per-route) | Validates `INTERNAL_API_TOKEN` bearer for `/api/internal/admin/*` (Supe Panel) |
| **Interceptor** | `TransformInterceptor` (global) | Wraps responses: `{success: true, data: ...}` |
| **Interceptor** | `LoggingInterceptor` (global) | Logs method, URL, status, response time |
| **Interceptor** | `TimeoutInterceptor` (global) | 30s request timeout |
@@ -291,9 +304,13 @@ userBrands (junction)
payments
├── id (uuid, PK), userId → users, subscriptionId → userSubscriptions
├── amount, currency, method (stripe/eft), status (pending/completed/failed/refunded)
├── stripePaymentIntentId, eftReceiptUrl, adminNote
── Indexes: userId, status
├── amount, currency, method, status (pending/completed/failed/refunded)
├── stripeSessionId, stripePaymentIntentId, adminNote
── iyzicoPaymentId, bankAccountId → bankAccounts, eftReceiptUrl (@deprecated — legacy iyzico/EFT, kept for historical rows only)
└── Indexes: userId, status, stripeSessionId
bankAccounts
└── (legacy) EFT bank-transfer accounts; retained for historical payments only
vehicles
├── id (uuid, PK), vin (unique), brandId → brands
@@ -340,6 +357,15 @@ passwordResetTokens
emexCategoryTranslations
└── id, originalName (unique), translatedName, isManual
oemCodeCopies
└── OEM code copy events (userId, oemCode, optional partId/vehicleId/categoryId) — admin copy-log analytics
blogPosts
└── SEO blog posts (slug, title, content, publish state)
changelogEntries
└── Product changelog entries (shown in Settings → Changelog)
catalogVehicles
├── id (uuid, PK), source (pl24), serviceName, brandName, brandId → brands
├── model, year, engine, bodyType, transmission, market
@@ -467,14 +493,11 @@ Instrumentation: Express, HTTP, ioredis, NestJS Core, BullMQ, Drizzle ORM
#### Payments
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/payments/stripe/checkout` | User | Start Stripe card payment |
| `POST` | `/api/payments/stripe/checkout` | User | Create Stripe checkout session |
| `POST` | `/api/payments/stripe/webhook` | Public | Stripe webhook callback |
| `POST` | `/api/payments/eft` | User | Create EFT payment |
| `POST` | `/api/payments/eft/:id/receipt` | User | Upload EFT receipt (PNG/JPG/PDF, 5MB max) |
| `PATCH` | `/api/payments/eft/:id/approve` | Admin | Approve EFT payment |
| `PATCH` | `/api/payments/eft/:id/reject` | Admin | Reject EFT payment |
| `GET` | `/api/payments/me` | User | Payment history |
| `GET` | `/api/payments/pending` | Admin | Pending EFT payments |
> EFT (bank-transfer) endpoints were removed in the Stripe migration (FN-343). The `payments` table keeps `eftReceiptUrl`/`bankAccountId`/`iyzicoPaymentId` only for historical rows.
#### Referrals
| Method | Path | Auth | Description |
@@ -486,8 +509,12 @@ Instrumentation: Express, HTTP, ioredis, NestJS Core, BullMQ, Drizzle ORM
#### Vehicles
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/vehicles/preview/:vin` | Public | Preview vehicle by VIN (pre-decode) |
| `POST` | `/api/vehicles/decode` | User | Decode VIN (rate limited: 20/min) |
| `GET` | `/api/vehicles/history` | User | Vehicle history (paginated) |
| `POST` | `/api/vehicles/report-vin` | User | Report an unrecognized VIN to admin |
| `GET` | `/api/vehicles/:vehicleId/prefetch-status` | User | Catalog prefetch job status |
| `GET` | `/api/vehicles/:vehicleId/categories/:categoryId` | User | Parts for a vehicle + category |
| `GET` | `/api/vehicles/:id` | User | Get vehicle details |
| `DELETE` | `/api/vehicles/:id` | User | Delete vehicle |
@@ -515,6 +542,9 @@ Instrumentation: Express, HTTP, ioredis, NestJS Core, BullMQ, Drizzle ORM
| `GET` | `/api/catalog/vehicles/:id/categories/:categoryId` | User | Sub-categories or parts+schema (`?body=&engine=&gearbox=`); lazy PL24 fetch |
| `POST` | `/api/catalog/explore/:serviceName` | Admin | Explore PL24 service structure (discovery tool) |
**EMEX sub-catalog** (`/api/catalog/emex/*`): `brands`, `brands/:code/vehicles`, `brands/:code/wizard`, `brands/:code/wizard-vehicles`, `vehicles/:id/groups`, `vehicles/:id/groups/:groupId`, `search?oem=`, `match?catalogCode=&name=`
**PCAT sub-catalog** (`/api/catalog/pcat/*`): `catalogs`, `catalogs/:id/models`, `catalogs/:id/groups`, `catalogs/:id/models/:modelId/cars`, `cars/:carId/groups`, `cars/:carId/groups/:groupId/schemas`, `schemas/:schemaImageId`
#### Translations
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -526,19 +556,39 @@ Instrumentation: Express, HTTP, ioredis, NestJS Core, BullMQ, Drizzle ORM
#### Admin
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/admin/users` | Admin | Create a user |
| `GET` | `/api/admin/dashboard` | Admin | Dashboard stats (users, revenue, queries) |
| `GET` | `/api/admin/users` | Admin | User list with search (paginated) |
| `GET` | `/api/admin/users/:id` | Admin | User detail with subscriptions & payments |
| `GET` | `/api/admin/payments/pending` | Admin | Pending EFT payments |
| `GET` | `/api/admin/query-logs` | Admin | Query logs (paginated, filterable) |
| `GET` | `/api/admin/referrals` | Admin | Referral stats (paginated) |
| `GET` | `/api/admin/stats/daily` | Admin | Daily VIN decode stats (last 30 days) |
| `GET` | `/api/admin/copy-logs` | Admin | OEM code copy logs |
| `GET` | `/api/admin/copy-logs/top` | Admin | Top copied OEM codes (`?days=`) |
#### Analytics
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `POST` | `/api/analytics/oem-copy` | User | Track OEM code copy event (oemCode, partId?, vehicleId?, categoryId?) |
#### Blog
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| `GET` | `/api/blog/posts` | Public | List published posts |
| `GET` | `/api/blog/posts/:slug` | Public | Get post by slug |
| `POST` | `/api/blog/posts/internal` | Token (Bearer) | Create/update post via automation |
#### Internal Admin — "Supe Panel" (founder-only, `InternalTokenGuard`)
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/internal/admin/users/:id/impersonate-readonly` | Mint a signed read-only impersonation consume-URL |
| `GET` | `/api/admin/impersonate/consume?t=` | Consume token → set read-only session cookie |
| `POST` | `/api/internal/admin/users/:id/{suspend,reactivate,ban}` | Account lifecycle |
| `POST` | `/api/internal/admin/subscriptions/:id/{trial/extend,activate,change-plan,cancel,resume,brands}` | Billing / subscription overrides |
| `POST` | `/api/internal/admin/payments/:id/refund` | Refund a payment |
| `POST` | `/api/internal/admin/vehicles/:vin/cache-clear` | Clear cached catalog data for a VIN |
| `DELETE` | `/api/internal/admin/vehicles/:vin` | Force-delete a vehicle |
#### Changelog
| Method | Path | Auth | Description |
|--------|------|------|-------------|
@@ -562,7 +612,9 @@ Flow:
6. User injected into request via @CurrentUser() decorator
```
**User model extensions:** `role` (default: "user"), `referralCode`, `referredBy`
**User model extensions:** `role` (default: "user"), `referralCode`, `referredBy`, account `status` (active/suspended/banned — non-active users are rejected by `AuthGuard`).
**Internal/founder access:** the `/api/internal/admin/*` "Supe Panel" routes are gated by `InternalTokenGuard` (`INTERNAL_API_TOKEN` bearer), separate from user auth. Read-only impersonation issues an HMAC-signed consume token (`INTERNAL_IMPERSONATION_SECRET`); the resulting session is mutation-blocked by `ImpersonationReadonlyGuard`.
---
@@ -570,7 +622,7 @@ Flow:
### Routes & Pages
**Router:** TanStack Router (file-based, auto-generated route tree — 36 files)
**Router:** TanStack Router (file-based, auto-generated route tree — 42 files)
#### Public
| Path | Route File | Description |
@@ -600,9 +652,8 @@ Flow:
| `/dashboard` | `routes/dashboard/index.tsx` | Dashboard home |
| `/dashboard/search` | `routes/dashboard/search.tsx` | VIN Search — main VIN decoder input |
| `/dashboard/history` | `routes/dashboard/history.tsx` | Past VIN decode searches |
| `/dashboard/subscription` | `routes/dashboard/subscription/index.tsx` | Plan selection & brand picker |
| `/dashboard/subscription/pay` | `routes/dashboard/subscription/pay.tsx` | Card (Stripe) or EFT payment |
| `/dashboard/billing` | `routes/dashboard/billing.tsx` | Payment history & receipts |
| `/dashboard/subscription` | `routes/dashboard/subscription/index.tsx` | Plan selection & brand picker (Stripe checkout redirect) |
| `/dashboard/billing` | `routes/dashboard/billing.tsx` | Payment history |
| `/dashboard/settings` | `routes/dashboard/settings.tsx` | Profile, Security, Connections, Referral, Account, Changelog tabs |
| `/dashboard/vehicles/$id` | `routes/dashboard/vehicles_/$id/index.tsx` | Vehicle details |
| `/dashboard/vehicles/$id/categories/$categoryId` | `routes/dashboard/vehicles_/$id/categories_/$categoryId.tsx` | Interactive schema + parts table |
@@ -615,12 +666,13 @@ Flow:
| `/dashboard/catalog/$brandName/$modelId` | `routes/dashboard/catalog_/$brandName_/$modelId/index.tsx` | Vehicle details + category tree (grid/tree toggle) |
| `/dashboard/catalog/$brandName/$modelId/categories/$categoryId` | `routes/dashboard/catalog_/$brandName_/$modelId/categories_/$categoryId.tsx` | Category sub-groups or schema+parts (lazy PL24 fetch) |
> Source-specific sub-browsers also exist: `/dashboard/catalog/emex/$catalogCode[/$vehicleId]` (EMEX group navigation) and `/dashboard/catalog/pcat/$catalogId` (PartsCatalogs). A `/dashboard/service-test` page surfaces catalog-source status.
#### Admin (Role-based)
| Path | Route File | Description |
|------|------------|-------------|
| `/dashboard/admin` | `routes/dashboard/admin/index.tsx` | Stats overview with charts |
| `/dashboard/admin/users` | `routes/dashboard/admin/users.tsx` | User management |
| `/dashboard/admin/payments` | `routes/dashboard/admin/payments.tsx` | EFT approval workflow |
| `/dashboard/admin/referrals` | `routes/dashboard/admin/referrals.tsx` | Referral program tracking |
| `/dashboard/admin/analytics` | `routes/dashboard/admin/analytics.tsx` | Daily query statistics |
| `/dashboard/admin/copy-logs` | `routes/dashboard/admin/copy-logs.tsx` | OEM code copy tracking |
@@ -705,7 +757,7 @@ Flow:
| Directory | Exports |
|-----------|---------|
| `types/` | User, UserProfile, UserSubscriptionSummary, Vehicle, VinDecodeResult, CategoryNode, VehicleSource, Brand, Plan, Subscription, UserBrand, CreateSubscriptionInput, SubscriptionStatus, Payment, PaymentMethod, PaymentStatus, EftPaymentInput, Part, PartSource, PartSearchResult, Category, CategoryWithSchema, SchemaPic, Hotspot, ApiResponse, ApiError, PaginationMeta, PaginationInput, PaginatedResult, ChangelogEntry, CreateChangelogEntry, UpdateChangelogEntry, ChangelogChangeType |
| `types/` | User, UserProfile, UserSubscriptionSummary, Vehicle, VinDecodeResult, CategoryNode, VehicleSource, Brand, Plan, Subscription, UserBrand, CreateSubscriptionInput, SubscriptionStatus, Payment, PaymentMethod, PaymentStatus, Part, PartSource, PartSearchResult, Category, CategoryWithSchema, SchemaPic, Hotspot, ApiResponse, ApiError, PaginationMeta, PaginationInput, PaginatedResult, ChangelogEntry, CreateChangelogEntry, UpdateChangelogEntry, ChangelogChangeType |
| `schemas/` | loginSchema, registerSchema, forgotPasswordSchema, resetPasswordSchema, vinSchema, paginationSchema, changelogEntrySchema, createChangelogEntrySchema, updateChangelogEntrySchema, changelogChangeTypeEnum (Zod) |
| `constants/` | ERROR_CODES (30+, prefixed AUTH/VIN/SUB/PAY), PLANS (Single/Double/Triple/Full), REFERRAL_REWARDS (Tier 1: 3→7d, Tier 2: 5→30d), VIN_REGEX, EMAIL_REGEX, OEM_CODE_REGEX, CURRENCY |
| `utils/` | VIN validator (check digit, WMI extraction, model year decode), currency (formatTRY, kurus↔lira), formatters (VIN, date, datetime, Turkish slug, referral code) |
@@ -762,6 +814,8 @@ Dependencies: Radix UI (accordion, dialog, dropdown-menu, label, popover, select
| Redis 7.4 | `redis:7.4-alpine` | 127.0.0.1:6379 | `redis_data` |
| MinIO | `minio/minio` | 9000 (API), 9001 (Console) | `minio_data` |
> **Staging / prod:** `docker-compose.coolify.yml` (repo root) defines the deployed services — `api`, `worker`, and `sase-redis` (Redis 7.4). PostgreSQL and MinIO are external/managed in those environments.
### SEO Infrastructure
- `apps/web/scripts/prerender.mjs` — Pre-renders public pages to static HTML (landing, blog posts, pricing, etc.) for crawler/bot visibility
@@ -774,21 +828,15 @@ Dependencies: Radix UI (accordion, dialog, dropdown-menu, label, popover, select
- `sase.tr.conf` — Frontend SPA + `/api` proxy + `/collect/` Faro telemetry CORS proxy + gzip (level 6) + 1-year asset cache + security headers
- `api.sase.tr.conf` — NestJS proxy (60s timeout for VIN decode) + SSL + blocked paths (.git, .env, node_modules)
### CI/CD (GitHub Actions)
### CI/CD
**`ci.yml`** — Runs on all branches & PRs to main (15min timeout):
1. Biome lint
2. TypeScript type check
3. Vitest unit tests
4. Full build
**GitHub Actions — `.github/workflows/qa-gate.yml`** (PR gate, 15min timeout):
- Triggers on PRs touching `apps/api/**` or `apps/web/**`; cancels superseded runs for the same PR.
- Detects which app changed, then runs `pnpm --filter <app> test` only for the changed app(s) (Node 22 + pnpm).
**`deploy.yml`** — Runs on push to `main` (10min timeout):
1. SSH into production
2. `git pull origin main`
3. `pnpm install`
4. `pnpm build`
5. `pnpm db:migrate` (migrations)
6. PM2 reload all
**Deploy — Coolify (staging) + GitHub Actions → PM2 (production):**
- **`dev` branch → Coolify → https://dev.sase.tr** via Gitea/GitHub webhook. The container is built from the root `Dockerfile`; runtime services are defined in `docker-compose.coolify.yml` (`api`, `worker`, `sase-redis`). Migrations run on container start via `apps/api/start.sh`, before the server boots.
- **`main` branch → production (https://sase.tr)** is promoted manually by the maintainer (merge `dev → main`), then shipped via GitHub Actions → SSH → PM2 (`pnpm build` + `pnpm db:migrate` + `pm2 reload`). See `AGENTS.md` for the dev-only deploy policy.
### PM2 Configuration
@@ -933,9 +981,16 @@ pm2 reload all
## Documentation
| File | Topic |
| File / Dir | Topic |
|------|-------|
| `docs/INDEX.md` | This file — comprehensive project reference |
| `README.md` | Quick stack overview & commands (Turkish) |
| `CLAUDE.md` | Claude Code project guide |
| `.claude/product-marketing-context.md` | Marketing context |
| `docs/00-overview.md``docs/13-analytics-posthog.md` | Detailed topic docs |
| `AGENTS.md` | Fusion agent deploy policy (dev-only; prod is human-merged) |
| `knowledge.md` | Long-form knowledge base (Dify.ai export) |
| `docs/pl24-catalog/*.md` | Per-brand PL24 catalog notes (BMW, Mercedes, Ford, …) + `_summary.md` |
| `docs/product/*.md` | CRO funnel audits & product shortlists |
| `docs/design-specs/*.md` | UX/design specs (e.g. VIN-decode error branches) |
| `docs/clarification/*.md` | Open requirement clarifications |
| `docs/00-testing.md` · `docs/TASK_REGISTRY_GUIDELINES.md` | Testing guide · task-registry conventions |
| `docs/analytics-queries.sql` | Saved analytics SQL |