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:
256
apps/web/src/components/onboarding/welcome-onboarding-modal.tsx
Normal file
256
apps/web/src/components/onboarding/welcome-onboarding-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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|$)/);
|
||||
});
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
141
docs/INDEX.md
141
docs/INDEX.md
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user