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

Reviewed-on: #66
This commit was merged in pull request #66.
This commit is contained in:
2026-05-31 20:56:44 +00:00
21 changed files with 2972 additions and 1016 deletions

View File

@@ -1,5 +1,6 @@
import { api } from "@/lib/api-client";
import { getCategoryIcon } from "@/lib/category-icons";
import { useTranslation } from "@/lib/i18n";
import { Badge, Input } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
@@ -71,6 +72,7 @@ export function CatalogSearch({
vehicleId: string;
onActiveChange?: (active: boolean) => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const [input, setInput] = useState("");
const debounced = useDebounced(input.trim(), 300);
@@ -107,8 +109,8 @@ export function CatalogSearch({
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Tüm katalogda ara — ör. fren balatası veya OEM kodu"
aria-label="Tüm katalogda ara"
placeholder={t("catalogSearch.placeholder")}
aria-label={t("catalogSearch.ariaLabel")}
className="pl-9 pr-9"
/>
{active && isFetching ? (
@@ -117,7 +119,7 @@ export function CatalogSearch({
<button
type="button"
onClick={() => setInput("")}
aria-label="Aramayı temizle"
aria-label={t("catalogSearch.clearAria")}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
@@ -129,33 +131,33 @@ export function CatalogSearch({
<div className="space-y-6">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Aranıyor
<Loader2 className="h-4 w-4 animate-spin" /> {t("catalogSearch.searching")}
</div>
) : (
<>
{data?.treeIncomplete && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500" />
<span>
Bu aracın kataloğu henüz tam indirilmemiş. Arama yalnızca şu ana kadar ılmış
kategorileri kapsar kategorileri gezdikçe kapsam genişler.
</span>
<span>{t("catalogSearch.treeIncomplete")}</span>
</div>
)}
{nothing ? (
<p className="py-8 text-center text-sm text-muted-foreground">
"{debounced}" ile eşleşen kategori veya parça bulunamadı.
{t("catalogSearch.noMatch", { query: debounced })}
</p>
) : (
<>
{/* Section 1 — leaf categories */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Kategoriler {categories.length > 0 && `(${categories.length})`}
{t("catalogSearch.categoriesHeading")}{" "}
{categories.length > 0 && `(${categories.length})`}
</h3>
{categories.length === 0 ? (
<p className="text-sm text-muted-foreground">Eşleşen kategori yok.</p>
<p className="text-sm text-muted-foreground">
{t("catalogSearch.noCategoryMatch")}
</p>
) : (
<ul className="space-y-1.5">
{categories.map((c) => {
@@ -179,7 +181,9 @@ export function CatalogSearch({
variant="secondary"
className="flex-shrink-0 text-[11px]"
>
{c.matchingPartCount} parça
{t("catalogSearch.partsCount", {
count: c.matchingPartCount,
})}
</Badge>
)}
</span>
@@ -197,11 +201,11 @@ export function CatalogSearch({
{/* Section 2 — parts (OEM) */}
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Parçalar (OEM) {parts.length > 0 && `(${parts.length})`}
{t("catalogSearch.partsHeading")} {parts.length > 0 && `(${parts.length})`}
</h3>
{parts.length === 0 ? (
<p className="text-sm text-muted-foreground">
Eşleşen parça yok. (Yalnızca ılmış kategorilerin parçaları aranır.)
{t("catalogSearch.noPartMatch")}
</p>
) : (
<ul className="space-y-1.5">

View File

@@ -1,4 +1,5 @@
import { api } from "@/lib/api-client";
import { openChatwoot } from "@/lib/chatwoot";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { getUserSettings } from "@/lib/user-settings";
@@ -56,12 +57,19 @@ export function WelcomeOnboardingModal({
refCode: string | null;
onFinished: () => void;
}) {
const { t } = useTranslation();
const { t, locale } = useTranslation();
const queryClient = useQueryClient();
const [phase, setPhase] = useState<"provisioning" | "completed">("provisioning");
const [animationEnded, setAnimationEnded] = useState(false);
const [failureCount, setFailureCount] = useState(0);
// The user gets a way out (close + contact support) once the trial has either
// failed twice or just hung for too long — never trap them inside the modal.
const [escapeHatchEnabled, setEscapeHatchEnabled] = useState(false);
const provisionedRef = useRef(false);
const refAppliedRef = useRef(false);
const completeCtaRef = useRef<HTMLButtonElement>(null);
const MAX_FAILURES = 2;
const STUCK_TIMEOUT_MS = 30_000;
const [isDark] = useState(() => {
const theme = getUserSettings().theme ?? "dark";
return theme === "system"
@@ -78,8 +86,14 @@ export function WelcomeOnboardingModal({
const trialMutation = useMutation({
mutationFn: () => api.post("/subscriptions/trial"),
onSuccess: () => {},
onError: () => {},
onError: (err) => {
setFailureCount((c) => c + 1);
capture("trial_failed", {
source: "welcome_onboarding",
error: err instanceof Error ? err.message : String(err),
attempt: failureCount + 1,
});
},
});
// Apply a carried-over referral code once (covers the Google OAuth signup path,
@@ -103,12 +117,40 @@ export function WelcomeOnboardingModal({
trialMutation.mutate();
}, [subData, eligibleForTrial, onFinished, trialMutation.mutate]);
// The animation runs ~7s; mark it ended a touch after.
// The animation runs ~7s; mark it ended a touch after. Background tabs
// throttle setTimeout, so if the tab was hidden during the window we
// re-check on visibilitychange and force the flag if the animation should
// already be over.
useEffect(() => {
const start = Date.now();
const timer = setTimeout(() => setAnimationEnded(true), 7500);
return () => clearTimeout(timer);
const onVisibility = () => {
if (document.visibilityState === "visible" && Date.now() - start >= 7500) {
setAnimationEnded(true);
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisibility);
};
}, []);
// Safety net: if we've been stuck on provisioning past the threshold, OR the
// trial mutation has failed enough times, surface an escape hatch so the user
// can leave the modal and reach support instead of staring at a stuck loader.
useEffect(() => {
if (escapeHatchEnabled || phase === "completed") return;
const timer = setTimeout(() => setEscapeHatchEnabled(true), STUCK_TIMEOUT_MS);
return () => clearTimeout(timer);
}, [escapeHatchEnabled, phase]);
useEffect(() => {
if (failureCount >= MAX_FAILURES) setEscapeHatchEnabled(true);
}, [failureCount]);
const canDismiss = phase === "completed" || escapeHatchEnabled;
// Move to the completed state once the animation finished AND the trial provisioned.
useEffect(() => {
if (phase !== "provisioning") return;
@@ -120,25 +162,37 @@ export function WelcomeOnboardingModal({
});
}, [phase, animationEnded, trialMutation.isSuccess, queryClient]);
// Auto-focus the primary CTA when the completed card appears so keyboard
// users can confirm with Enter without having to tab through the card.
useEffect(() => {
if (phase === "completed") {
// Wait a tick so the element is in the DOM and Radix's focus trap has
// already moved focus into the dialog.
requestAnimationFrame(() => completeCtaRef.current?.focus());
}
}, [phase]);
return (
<Dialog
open
onOpenChange={(open) => {
// Only the completed-state CTA / Esc may dismiss; ignore closes mid-provisioning.
if (!open && phase === "completed") onFinished();
// Completed or escape-hatch may dismiss; everything else ignores closes.
if (!open && canDismiss) onFinished();
}}
>
<DialogContent
className="max-w-[calc(100vw-2rem)] overflow-hidden border-brand/25 [&>button]:hidden sm:max-w-lg"
className={`max-w-[calc(100vw-2rem)] overflow-hidden border-brand/25 sm:max-w-lg ${
canDismiss ? "" : "[&>button]:hidden"
}`}
onInteractOutside={(e) => {
if (phase !== "completed") e.preventDefault();
if (!canDismiss) e.preventDefault();
}}
onEscapeKeyDown={(e) => {
if (phase !== "completed") e.preventDefault();
if (!canDismiss) e.preventDefault();
}}
>
{phase === "provisioning" ? (
<div className="flex flex-col items-center gap-6 py-4">
<div className="flex min-h-[300px] 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" />
@@ -181,9 +235,23 @@ export function WelcomeOnboardingModal({
{t("subscription.onboarding.step4")}...
</div>
)}
{trialMutation.isError && (
{/* Once the trial is real, let impatient or returning users skip
the rest of the animation and jump to the confirmation card. */}
{trialMutation.isSuccess && !animationEnded && (
<button
type="button"
onClick={() => {
capture("onboarding_skipped", { source: "welcome_onboarding" });
setAnimationEnded(true);
}}
className="text-xs text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
{t("subscription.onboarding.skip")}
</button>
)}
{trialMutation.isError && !escapeHatchEnabled && (
<div className="flex flex-col items-center gap-3">
<p className="text-sm leading-5 text-red-600 dark:text-red-400">
<p className="text-sm leading-5 text-destructive">
{t("subscription.onboarding.error")}
</p>
<Button variant="outline" onClick={() => trialMutation.mutate()}>
@@ -191,6 +259,38 @@ export function WelcomeOnboardingModal({
</Button>
</div>
)}
{escapeHatchEnabled && (
<div className="flex w-full flex-col items-center gap-3 rounded-xl border border-border bg-muted/40 p-4">
<p className="text-sm font-medium leading-5">
{t("subscription.onboarding.stillTryingTitle")}
</p>
<p className="text-center text-xs leading-relaxed text-muted-foreground">
{t("subscription.onboarding.stillTryingHint")}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => {
openChatwoot();
capture("onboarding_support_clicked", { failures: failureCount });
}}
>
{t("subscription.onboarding.contactSupport")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
capture("onboarding_escaped", { failures: failureCount });
onFinished();
}}
>
{t("subscription.onboarding.closeAnyway")}
</Button>
</div>
</div>
)}
</div>
) : (
<div className="flex flex-col items-center gap-6 py-4">
@@ -209,7 +309,7 @@ export function WelcomeOnboardingModal({
{t("subscription.currentPlan")}
</span>
<Badge className="bg-brand text-brand-foreground">
{t("subscription.plans.full.name")}
{t(`subscription.plans.${subscription?.plan?.key ?? "full"}.name`)}
</Badge>
</div>
<div className="flex items-center justify-between">
@@ -226,7 +326,9 @@ export function WelcomeOnboardingModal({
{t("subscription.endDate")}
</span>
<span className="text-sm font-medium leading-5">
{new Date(subscription.endDate).toLocaleDateString("tr-TR")}
{new Date(subscription.endDate).toLocaleDateString(
locale === "tr" ? "tr-TR" : "en-US",
)}
</span>
</div>
)}
@@ -241,9 +343,13 @@ export function WelcomeOnboardingModal({
</ul>
</div>
<Button
ref={completeCtaRef}
size="lg"
className="bg-brand text-brand-foreground hover:bg-brand/90"
onClick={onFinished}
onClick={() => {
capture("onboarding_completed", { source: "welcome_onboarding" });
onFinished();
}}
>
{t("subscription.onboarding.startSearching")}
<ArrowRight className="ml-2 h-4 w-4" />

View File

@@ -7,7 +7,7 @@ import { toast } from "@/lib/toast";
import { formatTRY } from "@sase/shared";
import { Button } from "@sase/ui";
import { useMutation } from "@tanstack/react-query";
import { Clock, CreditCard, Lock } from "lucide-react";
import { AlertCircle, CreditCard, Loader2, Lock } from "lucide-react";
interface StripeCheckoutButtonProps {
planKey: string;
@@ -89,7 +89,7 @@ export function StripeCheckoutButton({
>
{mutation.isPending ? (
<>
<Clock className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("payment.paying")}
</>
) : (
@@ -99,6 +99,41 @@ export function StripeCheckoutButton({
</>
)}
</Button>
{/* Inline error + retry — a toast alone vanishes in 4s and a user
retrying via the main button gets no clue what failed. */}
{mutation.isError && !mutation.isPending && (
<div
role="alert"
className="flex items-start gap-2 rounded-xl border border-destructive/30 bg-destructive/5 p-3 text-sm"
>
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="flex flex-1 flex-col gap-2">
<p className="leading-5 text-foreground">{t("payment.initializeFailed")}</p>
<Button variant="outline" size="sm" onClick={handleClick}>
{t("payment.stripe.retry")}
</Button>
</div>
</div>
)}
{/* Accepted-card row — somatic trust beats abstract padlock copy.
Plain text marks keep the bundle lean (no logo SVG cost). */}
<div className="flex flex-wrap items-center justify-center gap-2 text-[11px] uppercase tracking-wider text-muted-foreground">
<span>{t("payment.stripe.cardsAccepted")}</span>
<span className="rounded border border-border bg-muted px-1.5 py-0.5 font-semibold">
VISA
</span>
<span className="rounded border border-border bg-muted px-1.5 py-0.5 font-semibold">
MASTER&shy;CARD
</span>
<span className="rounded border border-border bg-muted px-1.5 py-0.5 font-semibold">
TROY
</span>
<span className="rounded border border-border bg-muted px-1.5 py-0.5 font-semibold">
3D&nbsp;SECURE
</span>
</div>
</div>
);
}

View File

@@ -6,8 +6,8 @@ import { Badge } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { cn } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Check } from "lucide-react";
import { useEffect, useState } from "react";
import { Check, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
interface Brand {
id: string;
@@ -30,39 +30,54 @@ export function BrandSelector({
isFullPlan = false,
}: BrandSelectorProps) {
const { t } = useTranslation();
const [selected, setSelected] = useState<string[]>(selectedBrandIds);
const [query, setQuery] = useState("");
const { data: brands, isLoading } = useQuery({
queryKey: ["brands"],
queryFn: () => api.get<Brand[]>("/brands"),
});
// Stash the callback so the full-plan auto-select effect doesn't refire on
// every parent render — the parent may pass a fresh function each time.
const onSelectionChangeRef = useRef(onSelectionChange);
useEffect(() => {
setSelected(selectedBrandIds);
}, [selectedBrandIds]);
onSelectionChangeRef.current = onSelectionChange;
}, [onSelectionChange]);
// For the full plan, mirror every brand into the parent's selection exactly
// once per `brands` payload (not per render). Bail if it's already aligned.
useEffect(() => {
if (isFullPlan && brands) {
const allIds = brands.map((b) => b.id);
setSelected(allIds);
onSelectionChange(allIds);
if (!isFullPlan || !brands) return;
const allIds = brands.map((b) => b.id);
if (
allIds.length === selectedBrandIds.length &&
allIds.every((id) => selectedBrandIds.includes(id))
) {
return;
}
}, [isFullPlan, brands, onSelectionChange]);
onSelectionChangeRef.current(allIds);
}, [isFullPlan, brands, selectedBrandIds]);
function toggleBrand(brandId: string) {
if (isFullPlan) return;
const isCurrentlySelected = selectedBrandIds.includes(brandId);
let next: string[];
if (selected.includes(brandId)) {
next = selected.filter((id) => id !== brandId);
if (isCurrentlySelected) {
next = selectedBrandIds.filter((id) => id !== brandId);
} else {
if (selected.length >= maxBrands) return;
next = [...selected, brandId];
if (selectedBrandIds.length >= maxBrands) return;
next = [...selectedBrandIds, brandId];
}
setSelected(next);
onSelectionChange(next);
}
const filteredBrands = useMemo(() => {
if (!brands) return [];
const q = query.trim().toLocaleLowerCase("tr");
if (!q) return brands;
return brands.filter((b) => b.name.toLocaleLowerCase("tr").includes(q));
}, [brands, query]);
if (isLoading) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
@@ -77,59 +92,92 @@ export function BrandSelector({
return <p className="text-sm text-muted-foreground">{t("common.noData")}</p>;
}
const isMaxReached = !isFullPlan && selected.length >= maxBrands;
const isMaxReached = !isFullPlan && selectedBrandIds.length >= maxBrands;
const showSearch = !isFullPlan && brands.length > 12;
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-medium">{t("subscription.selectBrands")}</p>
<Badge variant={isFullPlan ? "default" : "secondary"}>
<Badge variant={isFullPlan ? "default" : "secondary"} className="tabular-nums">
{isFullPlan
? t("subscription.allBrandsSelected")
: `${selected.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
: `${selectedBrandIds.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
</Badge>
</div>
{isMaxReached && (
<p className="text-xs text-amber-600">{t("subscription.maxBrandsReached")}</p>
{showSearch && (
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("subscription.brandSearchPlaceholder")}
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label={t("subscription.brandSearchPlaceholder")}
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label={t("common.cancel")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{brands.map((brand) => {
const isSelected = selected.includes(brand.id) || isFullPlan;
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
{isMaxReached && (
<p className="text-xs text-amber-600 dark:text-amber-400">
{t("subscription.maxBrandsReached")}
</p>
)}
return (
<Card
key={brand.id}
className={cn(
"cursor-pointer transition-all hover:shadow-md",
isSelected && "border-primary ring-2 ring-primary/20",
isDisabled && "cursor-not-allowed opacity-50",
isFullPlan && "cursor-default",
)}
onClick={() => !isDisabled && toggleBrand(brand.id)}
>
<CardContent className="flex flex-col items-center justify-center p-4">
<div className="relative">
<CarBrandLogo
brandName={brand.name}
logoUrl={brand.logoUrl}
size={48}
className="mb-2"
/>
{isSelected && (
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
<span className="text-center text-sm font-medium">{brand.name}</span>
</CardContent>
</Card>
);
})}
</div>
{filteredBrands.length === 0 ? (
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
{t("subscription.brandSearchNoMatch", { query })}
</p>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{filteredBrands.map((brand) => {
const isSelected = selectedBrandIds.includes(brand.id) || isFullPlan;
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
return (
<Card
key={brand.id}
className={cn(
"cursor-pointer transition-all hover:border-primary/40",
isSelected && "border-primary ring-2 ring-primary/20",
isDisabled && "cursor-not-allowed opacity-50",
isFullPlan && "cursor-default",
)}
onClick={() => !isDisabled && toggleBrand(brand.id)}
>
<CardContent className="flex flex-col items-center justify-center p-4">
<div className="relative">
<CarBrandLogo
brandName={brand.name}
logoUrl={brand.logoUrl}
size={48}
className="mb-2"
/>
{isSelected && (
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Check className="h-3 w-3" />
</div>
)}
</div>
<span className="text-center text-sm font-medium">{brand.name}</span>
</CardContent>
</Card>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { Button } from "@sase/ui";
@@ -15,14 +16,77 @@ interface Subscription {
endDate?: string;
}
const DISMISS_STORAGE_PREFIX = "trialBannerDismissed";
// Show the banner inside the final week. Two tighter tiers (3, 1 days) push the
// visual urgency without spamming earlier.
const VISIBLE_AT_DAYS = 7;
const URGENT_AT_DAYS = 3;
const CRITICAL_AT_DAYS = 1;
const TRIAL_TOTAL_DAYS = 30;
function getDismissKey(endDate: string): string {
return `${DISMISS_STORAGE_PREFIX}-${endDate}`;
type Tier = "soft" | "urgent" | "critical";
function tierFor(days: number): Tier {
if (days <= CRITICAL_AT_DAYS) return "critical";
if (days <= URGENT_AT_DAYS) return "urgent";
return "soft";
}
// Per-tier classes. Kept inline (not via tokens) because they map to brand
// urgency colors that don't yet exist in @theme — Phase-5 token sweep can
// move these to bg-warning / bg-destructive.
const tierClasses: Record<
Tier,
{
bar: string;
cta: string;
icon: string;
title: string;
body: string;
close: string;
track: string;
fill: string;
}
> = {
soft: {
bar: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
cta: "bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400",
icon: "text-amber-600 dark:text-amber-400",
title: "text-amber-900 dark:text-amber-100",
body: "text-amber-700 dark:text-amber-300",
close: "text-amber-600 hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-900",
track: "bg-amber-200/60 dark:bg-amber-800/40",
fill: "bg-amber-500 dark:bg-amber-400",
},
urgent: {
bar: "border-orange-300 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/40",
cta: "bg-orange-600 text-white hover:bg-orange-700 dark:bg-orange-500 dark:text-orange-950 dark:hover:bg-orange-400",
icon: "text-orange-600 dark:text-orange-400",
title: "text-orange-900 dark:text-orange-100",
body: "text-orange-700 dark:text-orange-300",
close: "text-orange-600 hover:bg-orange-100 dark:text-orange-400 dark:hover:bg-orange-900",
track: "bg-orange-200/60 dark:bg-orange-800/40",
fill: "bg-orange-500 dark:bg-orange-400",
},
critical: {
bar: "border-destructive/40 bg-destructive/5",
cta: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
icon: "text-destructive",
title: "text-foreground",
body: "text-muted-foreground",
close: "text-destructive/80 hover:bg-destructive/10",
track: "bg-destructive/15",
fill: "bg-destructive",
},
};
function trialBannerKey(userId: string | null | undefined, endDate: string): string {
return `trialBannerDismissed-${userId ?? "anon"}-${endDate}`;
}
export function TrialUrgencyBanner() {
const { t } = useTranslation();
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
const [dismissed, setDismissed] = useState(false);
const [mounted, setMounted] = useState(false);
const viewedRef = useRef(false);
@@ -41,8 +105,8 @@ export function TrialUrgencyBanner() {
subscription?.status === "trial" && endDate
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
: null;
const visible = !isLoading && days !== null && days <= 7;
const dismissKey = endDate ? getDismissKey(endDate) : null;
const visible = !isLoading && days !== null && days <= VISIBLE_AT_DAYS;
const dismissKey = endDate ? trialBannerKey(userId, endDate) : null;
useEffect(() => {
setMounted(true);
@@ -56,30 +120,35 @@ export function TrialUrgencyBanner() {
}
}, [mounted, dismissKey]);
const tier: Tier | null = days !== null ? tierFor(days) : null;
useEffect(() => {
if (!visible || dismissed || viewedRef.current || days === null) return;
if (!visible || dismissed || viewedRef.current || days === null || !tier) return;
viewedRef.current = true;
capture("trial_urgency_banner_viewed", {
days_remaining: days,
tier,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
}, [visible, dismissed, days, subscription?.plan?.name, subscription?.billingPeriod]);
}, [visible, dismissed, days, tier, subscription?.plan?.name, subscription?.billingPeriod]);
const handleCTAClick = useCallback(() => {
if (days === null) return;
capture("trial_urgency_banner_cta_clicked", {
days_remaining: days,
tier,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
}, [days, subscription?.plan?.name, subscription?.billingPeriod]);
}, [days, tier, subscription?.plan?.name, subscription?.billingPeriod]);
if (!visible || dismissed || days === null || !dismissKey) return null;
if (!visible || dismissed || days === null || !dismissKey || !tier) return null;
const handleDismiss = () => {
capture("trial_urgency_banner_dismissed", {
days_remaining: days,
tier,
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
@@ -87,41 +156,59 @@ export function TrialUrgencyBanner() {
setDismissed(true);
};
return (
<div
role="alert"
className="flex items-center gap-3 border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-950 sm:gap-4 sm:px-6"
>
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-400" />
// Progress bar reflects elapsed share of the trial — a number alone undersells
// urgency at e.g. day 27/30, while the bar reads visually as "nearly done".
const elapsed = Math.max(0, Math.min(TRIAL_TOTAL_DAYS, TRIAL_TOTAL_DAYS - days));
const elapsedPct = Math.round((elapsed / TRIAL_TOTAL_DAYS) * 100);
const titleKey =
tier === "critical"
? "subscription.trialBanner.titleCritical"
: tier === "urgent"
? "subscription.trialBanner.titleUrgent"
: "subscription.trialBanner.title";
const c = tierClasses[tier];
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div>
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">
{t("subscription.trialBanner.title")}
</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
{t("subscription.trialBanner.description", { days })}
</p>
return (
<div role="alert" className={`flex flex-col gap-2 border-b px-4 py-3 sm:px-6 ${c.bar}`}>
<div className="flex items-start gap-3 sm:items-center">
<AlertTriangle className={`mt-0.5 size-5 shrink-0 sm:mt-0 ${c.icon}`} />
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
<div>
<p className={`text-sm font-semibold ${c.title}`}>{t(titleKey, { days })}</p>
<p className={`text-xs ${c.body}`}>
{t("subscription.trialBanner.description", { days })}
</p>
</div>
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
<Button size="sm" className={c.cta}>
{t("subscription.trialBanner.cta")}
</Button>
</Link>
</div>
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
<Button
size="sm"
className="bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
>
{t("subscription.trialBanner.cta")}
</Button>
</Link>
<button
type="button"
onClick={handleDismiss}
className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors ${c.close}`}
aria-label={t("common.close")}
>
<X className="size-4" />
</button>
</div>
<button
type="button"
onClick={handleDismiss}
className="flex size-8 shrink-0 items-center justify-center rounded-md text-amber-600 transition-colors hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-900"
aria-label={t("common.close")}
<div
className={`h-1 w-full overflow-hidden rounded-full ${c.track}`}
role="progressbar"
tabIndex={-1}
aria-valuenow={elapsedPct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t("subscription.trialBanner.progressLabel", { percent: elapsedPct })}
>
<X className="size-4" />
</button>
<div className={`h-full transition-all ${c.fill}`} style={{ width: `${elapsedPct}%` }} />
</div>
</div>
);
}

View File

@@ -24,6 +24,7 @@ interface ChatwootApi {
setCustomAttributes: (attrs: Record<string, string | number | boolean>) => void;
deleteCustomAttribute: (key: string) => void;
reset: () => void;
toggle: (state?: "open" | "close") => void;
}
declare global {
@@ -117,3 +118,18 @@ export function setChatwootVehicle(vehicle: {
window.$chatwoot?.setCustomAttributes(attrs);
});
}
/** Drop the vehicle context — call on unmount so a later support chat doesn't
* carry the previously-viewed car. */
export function clearChatwootVehicle(): void {
whenReady(() => {
window.$chatwoot?.deleteCustomAttribute("son_arac_id");
window.$chatwoot?.deleteCustomAttribute("son_arac_vin");
window.$chatwoot?.deleteCustomAttribute("son_arac");
});
}
/** Programmatically open the support chat widget (no-op if it failed to load). */
export function openChatwoot(): void {
whenReady(() => window.$chatwoot?.toggle("open"));
}

View File

@@ -4,11 +4,16 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { initChatwoot } from "./lib/chatwoot";
import { initFaro } from "./lib/faro";
import { initLocale } from "./lib/i18n";
import { initMetaPixel } from "./lib/meta-pixel";
import { initPostHog } from "./lib/posthog";
import { routeTree } from "./routeTree.gen";
import "./globals.css";
// Read persisted locale from localStorage before first render — without this
// a reload always falls back to the TR default even if the user picked EN.
initLocale();
// Initialize frontend observability (async, non-blocking)
initFaro();

View File

@@ -28,7 +28,11 @@
"perMonth": "/mo",
"perYear": "/yr",
"login": "Log In",
"register": "Sign Up"
"register": "Sign Up",
"reload": "Reload page"
},
"a11y": {
"skipToContent": "Skip to content"
},
"nav": {
"dashboard": "Dashboard",
@@ -195,8 +199,10 @@
"stickyCta": {
"toBrands": "Choose brands",
"toPayment": "Go to payment",
"selectBrands": "Select {count} more brand(s)"
"selectBrands": "Select {count} more brand(s)",
"disabledHint": "Select {count} more brand(s) to continue"
},
"brandSelectorSlow": "Brand list is taking longer than usual. Reload the page if you want to retry.",
"noSubscription": "You don't have an active subscription.",
"choosePlan": "Choose Plan",
"subscribe": "Subscribe",
@@ -231,6 +237,8 @@
"brandsSelected": "brands selected",
"allBrandsSelected": "All selected",
"maxBrandsReached": "Maximum number of brands reached.",
"brandSearchPlaceholder": "Search brand — e.g. Volkswagen",
"brandSearchNoMatch": "No brand matches \"{query}\".",
"features": {
"vinSearch": "Unlimited VIN search",
"partsCatalog": "Parts catalog",
@@ -288,7 +296,12 @@
"trialDuration": "30-Day Trial",
"startSearching": "Start Searching",
"error": "An error occurred while starting your trial.",
"retry": "Try Again"
"retry": "Try Again",
"stillTryingTitle": "This is taking longer than expected",
"stillTryingHint": "We can't confirm your subscription right now. You can close this and start searching, or reach out to support.",
"contactSupport": "Contact support",
"closeAnyway": "Close anyway",
"skip": "Skip"
},
"downgradeOffer": {
"title": "Downgrade instead of cancelling",
@@ -309,9 +322,12 @@
"paymentTrustKVKK": "KVKK compliant",
"trialDaysLeft": "{days} days left",
"trialBanner": {
"title": "Your trial is ending soon!",
"title": "Your trial is ending soon",
"titleUrgent": "Only {days} days left",
"titleCritical": "Last day — access ends tomorrow",
"description": "{days} days left. Upgrade now to continue accessing all features.",
"cta": "Upgrade Plan"
"cta": "Upgrade Plan",
"progressLabel": "{percent}% of trial used"
},
"trialProgress": {
"title": "{days} days left in your Full Package trial",
@@ -359,7 +375,9 @@
"cancelled": "Payment cancelled. You can try again.",
"verifyingTitle": "Verifying your payment",
"verifyingDescription": "Your subscription will activate as soon as Stripe confirms. Stay on this page for a few seconds.",
"trustProvider": "Stripe payment infrastructure"
"trustProvider": "Stripe payment infrastructure",
"retry": "Retry",
"cardsAccepted": "We accept:"
}
},
"billing": {
@@ -377,6 +395,7 @@
"downloadReceipt": "Download Receipt",
"viewReceipt": "Your receipt",
"receiptUnavailable": "No receipt available for this payment.",
"failedHint": "Card declined. Contact your bank or retry with a different card.",
"filterByStatus": "Filter by Status",
"filterByMethod": "Filter by Method",
"statusLabels": {
@@ -517,7 +536,24 @@
"popular": "Popular",
"orYearly": "or {price}/year",
"yearlySave": "· 2 months free",
"yearlySavePercent": "· save {percent}%",
"yearlySaveAmount": "save {percent}% — {amount}/year",
"yearlyBilled": "billed {price} per year",
"getStarted": "Start Free",
"heroCta": "Start the 30-day trial",
"heroSecondary": "See the plans",
"billingPeriodLabel": "Billing period",
"billing": {
"monthly": "Monthly",
"yearly": "Yearly"
},
"yearlyBadge": "2 months free",
"stats": {
"brands": { "value": "27", "label": "brand catalogs" },
"parts": { "value": "1M+", "label": "OEM & alternative parts" },
"trial": { "value": "30 days", "label": "full access, free" },
"refund": { "value": "7 days", "label": "no-questions refund" }
},
"pageTitle": "Pricing — Sase.tr | Chassis Search Plans",
"how": {
"title": "How it works",
@@ -551,5 +587,484 @@
},
"categories": {
"root": "Root"
},
"search": {
"title": "VIN Search",
"subtitle": "Enter the vehicle's VIN to access the parts catalog",
"inputLabel": "VIN",
"inputPlaceholder": "Enter VIN (17 characters)",
"counter": "{count}/17 characters",
"tryExample": "Try an example VIN →",
"submit": "Decode VIN",
"errorTitle": "Couldn't decode VIN",
"errorSubscriptionPrefix": "You have no active subscription. To access vehicle data,",
"subscribeLink": "subscribe",
"reportVin": "VIN is correct — send to administrator",
"reportSent": "Report sent. We'll review it shortly.",
"reportToastTitle": "Report sent",
"reportToastDesc": "The VIN was forwarded to the administrator.",
"reportToastError": "Report failed",
"previewLoading": "Fetching vehicle info...",
"vehicleIdentified": "Vehicle identified",
"previewHint": "Click Decode VIN to access the full parts catalog.",
"recent": "Recent searches",
"seeAll": "See all →",
"openHint": "Click to reopen the vehicle with its parts catalog",
"historyAria": "{brand} {model} — VIN {vin}, open from history",
"errorInvalidVin": "Invalid VIN. Must be 17 characters; I, O, Q are not allowed.",
"errorGeneric": "An error occurred. Please try again.",
"errorToast": "VIN search failed",
"decodingSkeleton": "Loading vehicle info…",
"pasteInvalidChars": "VIN cannot contain I, O, Q",
"candidateSelectFailed": "Vehicle selection failed",
"autoCorrected": "Auto-corrected: {list}"
},
"vehicle": {
"title": "Vehicle",
"back": "Back",
"notFoundTitle": "Vehicle not found",
"errorTitle": "Couldn't load vehicle",
"notFoundMsg": "This vehicle record was not found",
"errorMsg": "Couldn't load vehicle details",
"notFoundHint": "The link may be invalid or the record removed.",
"errorHint": "An error occurred loading the data.",
"retry": "Try again",
"info": "Vehicle details",
"categories": "Spare parts categories",
"noCategoriesTitle": "No categories for this vehicle",
"noCategoriesHint": "The vehicle was decoded but the catalog may not be ready yet. Reach out to support to expedite it.",
"noAttrs": "No detailed info available for this vehicle.",
"labelModel": "Model",
"labelYear": "Model year",
"labelEngineCode": "Engine code",
"equipmentTitle": "Equipment codes ({count})",
"showAll": "Show all ({count})",
"showLess": "Show less",
"categoryDetail": "Category detail",
"invalidLinkTitle": "Invalid vehicle link",
"invalidLinkHint": "This link doesn't point to a known vehicle. Go back to search and try the VIN again.",
"goToSearch": "Go to search",
"categoryNotLoadedTitle": "Couldn't load category",
"catalogUnavailableTitle": "Catalog isn't available right now",
"catalogUnavailableHint": "Couldn't fetch this category from the supplier catalog. Please try again shortly."
},
"catalogSearch": {
"placeholder": "Search the whole catalog — e.g. brake pads or OEM code",
"ariaLabel": "Search the whole catalog",
"clearAria": "Clear search",
"searching": "Searching…",
"treeIncomplete": "This vehicle's catalog hasn't been fully indexed yet. Search covers only categories opened so far — browsing more categories expands the scope.",
"noMatch": "No categories or parts match \"{query}\".",
"categoriesHeading": "Categories",
"noCategoryMatch": "No matching categories.",
"partsHeading": "Parts (OEM)",
"noPartMatch": "No matching parts. (Only parts in opened categories are searched.)",
"partsCount": "{count} part(s)"
},
"landing": {
"meta": {
"title": "VIN Lookup & OEM Parts Catalog | Sase.tr",
"description": "Enter a vehicle VIN and get OEM part numbers in seconds. 27 brands, 1M+ parts. Order the right part the first time.",
"schemaOrgDescription": "Turkey's VIN lookup and OEM spare parts catalog platform.",
"schemaSoftwareDescription": "OEM spare parts lookup platform powered by VIN."
},
"nav": {
"features": "Features",
"howItWorks": "How It Works",
"pricing": "Pricing",
"blog": "Blog",
"contact": "Contact",
"login": "Log In",
"register": "Try Free for 30 Days",
"dashboard": "Go to Dashboard"
},
"aria": {
"themeToggle": "Toggle theme",
"showBrand": "Show {brand}"
},
"hero": {
"lossPill": "Every wrong-part order costs 200-500 TL",
"ratingLabel": "4.9/5",
"socialProofLabel": "500+ Businesses Trust Us",
"title": "Find the Right Part Instantly",
"subtitle": "Cross-referenced OEM codes from multiple catalogs, always up to date. Enter a VIN and ship the right part the first time.",
"vinPlaceholder": "Example: WVWZZZ1JZ3W597935",
"searchCta": "Search",
"decoding": "Decoding...",
"loadingVehicle": "Fetching vehicle info...",
"ctaOpenCatalog": "Open parts catalog",
"ctaAccessFullCatalog": "Access the full parts catalog",
"vinNotFound": "VIN not found. Please check and try again.",
"noVinPrompt": "Don't have a VIN?",
"tryExample": "Try an example search →",
"metricSpeed": "1.2s Average Result",
"metricBrands": "27 Brands Supported",
"metricParts": "1M+ OEM Parts"
},
"brandStrip": {
"supported": "27 Supported Car Brands"
},
"testimonialStrip": {
"title": "What our users say",
"items": {
"0": {
"text": "With Sase.tr our wrong-part orders dropped 80%. We order with confidence now.",
"name": "Ahmet Y.",
"role": "Spare Parts Store"
},
"1": {
"text": "We save over 40 hours a month. Entering a VIN and getting the right code instantly is incredible.",
"name": "Mehmet K.",
"role": "Auto Service Owner"
},
"2": {
"text": "We integrated it into our online store and returns fell by half. Customer satisfaction is visibly higher.",
"name": "Özge D.",
"role": "E-Commerce Manager"
}
}
},
"features": {
"badge": "Our Solutions",
"title": "VIN Decoding & Parts Catalog",
"subtitle": "From vehicle identification to part ordering — the whole flow on one platform.",
"loading": "Loading…",
"ctaTry": "Try It Now",
"vin": {
"title": "VIN Decoding",
"description": "Customer not sure about the vehicle? Enter the VIN — brand, model, engine and trim show up in seconds.",
"bullet1": "Brand, model and year detection",
"bullet2": "Engine and transmission details",
"bullet3": "Trim package information",
"mockupTitle": "VIN Decoding — Vehicle Details",
"labelMake": "Brand",
"labelModel": "Model",
"labelYear": "Year",
"labelEngine": "Engine",
"labelBody": "Body",
"valueBody": "Hatchback"
},
"schema": {
"title": "Interactive Diagrams",
"description": "Instead of getting lost in parts catalogs, find the part visually on clickable diagrams.",
"bullet1": "Clickable part diagrams",
"bullet2": "Zoom & pan navigation",
"bullet3": "Sub-category depth",
"mockupTitle": "Interactive Diagram — Engine Bay"
},
"oem": {
"title": "OEM Part Numbers",
"description": "Cross-references multiple catalogs so OEM codes are always current and correct — no more wrong parts, no more returns.",
"bullet1": "Multi-catalog cross-referencing",
"bullet2": "Always-current OEM codes",
"bullet3": "Price comparison",
"mockupTitle": "OEM Catalog — Parts List",
"part1": "AC Compressor",
"part2": "Heater Blower Motor",
"part3": "Front Brake Pad"
}
},
"compare": {
"badge": "Comparison",
"title": "Manual Workflow vs Sase.tr",
"manualHeader": "Manual",
"saseHeader": "Sase.tr",
"rows": {
"0": {
"label": "Part lookup time",
"manual": "~15 minutes",
"sase": "30 seconds"
},
"1": {
"label": "Wrong-part risk",
"manual": "High",
"sase": "Near zero"
},
"2": {
"label": "Finding the OEM code",
"manual": "Single catalog, may be outdated",
"sase": "Multi-catalog cross-query"
},
"3": {
"label": "Price comparison",
"manual": "Impossible",
"sase": "On a single screen"
},
"4": {
"label": "Monthly time saved",
"manual": "—",
"sase": "~40 hours"
}
}
},
"howItWorks": {
"badge": "How It Works",
"title": "Get to the Right OEM Part in 3 Steps",
"items": {
"0": {
"title": "Enter VIN",
"description": "Enter the 17-character VIN and the vehicle is identified instantly."
},
"1": {
"title": "27 Brands",
"description": "European, Asian and American brands under one roof."
},
"2": {
"title": "Pick a Category",
"description": "Engine, chassis, electrical… drill into the category you need."
},
"3": {
"title": "Inspect the Diagram",
"description": "Find part positions visually on interactive technical diagrams."
},
"4": {
"title": "Get the OEM Code",
"description": "Copy the original manufacturer part number, ready to order."
},
"5": {
"title": "Place the Order",
"description": "Send suppliers an order with the exact part number."
}
}
},
"useCases": {
"badge": "Use Cases",
"title": "Who It's For",
"items": {
"0": {
"title": "Spare Parts Lookup",
"description": "Find genuine parts that fit the vehicle from its VIN, instantly."
},
"1": {
"title": "Original Part Numbers",
"description": "Use OEM codes to order the right part the first time."
},
"2": {
"title": "Vehicle Identification",
"description": "Decode the VIN to get full technical and trim details for any car."
},
"3": {
"title": "Used-Part Verification",
"description": "Verify authenticity and fitment of salvage parts via OEM codes."
},
"4": {
"title": "Service & Maintenance",
"description": "Speed up service jobs with parts lists tailored to maintenance schedules."
}
},
"ctaCard": {
"title": "Have a different need?",
"description": "Reach out if you need a custom integration or bulk lookup.",
"cta": "Contact Us"
}
},
"stats": {
"badge": "Platform",
"titleLine1": "Sase.tr",
"titleLine2": "in Numbers",
"items": {
"0": { "value": "1.2sn", "label": "Average lookup time" },
"1": { "value": "27", "label": "Supported brands" },
"2": { "value": "1M+", "label": "OEM part numbers" },
"3": { "value": "%99.9", "label": "Platform uptime" }
}
},
"dashboard": {
"title": "All Your Data, One Platform",
"subtitle": "VIN decoding, parts catalog and interactive diagrams in a single interface.",
"frameTitle": "sase.tr/dashboard",
"loading": "Loading…",
"floatVin": "VIN Search",
"floatSchema": "Interactive Diagram",
"floatOem": "OEM Codes"
},
"dashboardDemo": {
"sidebar": {
"dashboard": "Dashboard",
"vinSearch": "VIN Search",
"catalog": "Catalog",
"diagrams": "Diagrams",
"settings": "Settings"
},
"categories": {
"engine": "Engine",
"chassis": "Chassis",
"electrical": "Electrical",
"body": "Body",
"brakes": "Brakes"
},
"parts": {
"acCompressor": "A/C Compressor",
"brakePads": "Brake Pads",
"heaterBlower": "Heater Blower Motor"
},
"vinPlaceholder": "Enter VIN...",
"categorySelectTitle": "Select Category",
"schemaViewTitle": "Engine — Schematic View",
"oemListTitle": "OEM Parts List"
},
"ecommerceDemo": {
"products": {
"brakePads": "Brake Pads",
"acCompressor": "A/C Compressor",
"shockAbsorber": "Shock Absorber",
"oilFilter": "Oil Filter",
"wiperMotor": "Wiper Motor",
"headlight": "Headlight"
},
"searchPlaceholder": "Search part...",
"preReturnBadge": "23% return rate",
"addToCart": "Add to Cart",
"cartAddedToast": "Brake Pads added to cart",
"conversionOverlay": "Conversion: 94%",
"statReturnRate": "Return rate",
"statConversionRate": "Conversion rate",
"statSatisfaction": "Customer satisfaction"
},
"ecommerce": {
"badge": "E-Commerce Integration",
"title": "Add VIN Decoding to Your Own Site",
"subtitle": "Customers ordering wrong parts and opening returns? Add VIN-based filtering with Sase.tr — returns drop, conversion rises.",
"frameTitle": "otoyedekparca.co — Sase.tr Integration",
"loading": "Loading…",
"easyBadge": "Easy Integration",
"cardTitle": "A Few Lines of Code, Big Difference",
"cardBody": "Embed Sase.tr's VIN decoding engine into your e-commerce site. Customers enter the VIN and see only parts that fit their vehicle.",
"bullet1": "API & Widget — embed in a few lines of code",
"bullet2": "VIN-based filtering — only compatible parts appear",
"bullet3": "Lower returns — the right part, the first time",
"bullet4": "White-label — the widget matches your site's design",
"cta": "Get in Touch",
"stats": {
"0": { "value": "%42", "label": "Fewer Returns" },
"1": { "value": "%35", "label": "Higher Conversion" },
"2": { "value": "<30dk", "label": "Integration Time" }
}
},
"testimonials": {
"badge": "Customer Stories",
"title": "What Business Owners Say",
"items": {
"0": {
"name": "Ahmet Y.",
"role": "Spare Parts Store Owner",
"text": "I used to open 3-4 catalogs for one part. With Sase.tr I enter the VIN and find the right OEM code in 30 seconds. Our return rate dropped 80%."
},
"1": {
"name": "Mehmet K.",
"role": "Auto Service Owner",
"text": "Wrong-part orders almost went to zero since we switched to Sase.tr. We save over 40 hours a month."
},
"2": {
"name": "Özge D.",
"role": "E-Commerce Manager",
"text": "We added VIN integration to our online store. Customers enter the VIN and only compatible parts show up. Returns fell 42%."
},
"3": {
"name": "Burak T.",
"role": "Fleet Manager",
"text": "Parts management for our 200+ vehicle fleet is now effortless. The interactive diagrams speed up installs a lot."
}
}
},
"pricing": {
"badge": "Pricing",
"titleLine1": "From 7 TL a day",
"titleLine2": "and up.",
"subtitle": "Less than the cost of a single wrong-part return. Every plan comes with a 30-day free trial, no card required.",
"perMonth": "/mo",
"currency": "TL",
"yearlyPrice": "{amount} TL/year",
"tryFree": "Try free for 30 days",
"plans": {
"brand1": {
"name": "1 Brand",
"description": "Access for a single brand",
"feature1": "1 brand selection",
"feature2": "Unlimited VIN search",
"feature3": "Parts catalog",
"feature4": "Diagram viewer"
},
"brand2": {
"name": "2 Brands",
"description": "Two different brands",
"feature1": "2 brand selections",
"feature2": "Unlimited VIN search",
"feature3": "Parts catalog",
"feature4": "Priority support"
},
"brand3": {
"name": "3 Brands",
"description": "Three brands, broad coverage",
"feature1": "3 brand selections",
"feature2": "Unlimited VIN search",
"feature3": "Parts catalog",
"feature4": "Priority support"
}
},
"full": {
"badge": "Most popular",
"name": "Full Package",
"description": "Unlimited access to every brand. All in a single subscription.",
"priceUnit": "TL/mo",
"yearlyNote": "9,990 TL/year — works out to 832 TL/mo",
"ctaTry": "Try free for 30 days",
"ctaFineprint": "No card required. Cancel anytime.",
"included": "Included",
"feature1": "Access to all 27 brands",
"feature2": "Unlimited VIN search",
"feature3": "OEM parts catalog",
"feature4": "Interactive diagram viewer",
"feature5": "Search history & favorites",
"feature6": "Priority email support",
"feature7": "API access (on request)",
"feature8": "Bulk lookup feature"
},
"detailedComparison": "See the detailed comparison"
},
"faq": {
"badge": "FAQ",
"title": "Frequently Asked Questions",
"items": {
"0": {
"q": "Which brands do you support?",
"a": "We currently support 27 automotive brands and the list keeps growing. Enter your VIN in the search box to see right away whether your vehicle is covered."
},
"1": {
"q": "Is my data safe?",
"a": "All traffic is encrypted with SSL and your data is processed in line with KVKK. Your search history is private to you."
},
"2": {
"q": "Do you need card details?",
"a": "No. We don't ask for a credit card for the 30-day free trial; you can stop anytime."
},
"3": {
"q": "Can I cancel anytime?",
"a": "Yes, no commitment. You can cancel your subscription from the dashboard in one click."
},
"4": {
"q": "Does it integrate with my existing e-commerce or service system?",
"a": "Full Package includes API access and bulk lookup so you can plug it into your systems (white-glove setup on request)."
},
"5": {
"q": "What if I don't know the VIN?",
"a": "You can browse the catalog by brand and model, or try the example search on the home page to test the platform."
}
}
},
"finalCta": {
"title": "Every Wrong Part Costs You 200-500 TL",
"subtitle": "Return shipping, lost customers, lost time. Decode the VIN and find the right part the first time.",
"cta": "Start 30 Days Free — No Card Required",
"trust": "KVKK compliant · SSL encrypted · No card required"
},
"referral": {
"title": "Invite a Friend",
"description": "3 invites = +7 days free, 5 invites = +1 month free",
"cta": "Get Your Invite Code"
},
"errors": {
"decodeGeneric": "Something went wrong. Please try again."
}
}
}

View File

@@ -28,7 +28,11 @@
"perMonth": "/ay",
"perYear": "/yıl",
"login": "Giriş Yap",
"register": "Kayıt Ol"
"register": "Kayıt Ol",
"reload": "Sayfayı yenile"
},
"a11y": {
"skipToContent": "İçeriğe atla"
},
"nav": {
"dashboard": "Gösterge Paneli",
@@ -195,8 +199,10 @@
"stickyCta": {
"toBrands": "Markaları seç",
"toPayment": "Ödeme yöntemine geç",
"selectBrands": "{count} marka daha seç"
"selectBrands": "{count} marka daha seç",
"disabledHint": "Devam etmek için {count} marka daha seç"
},
"brandSelectorSlow": "Marka listesi her zamankinden geç yükleniyor. Yeniden denemek istersen sayfayı yenileyebilirsin.",
"noSubscription": "Aktif aboneliğiniz yok.",
"choosePlan": "Plan Seç",
"subscribe": "Abone Ol",
@@ -231,6 +237,8 @@
"brandsSelected": "marka seçildi",
"allBrandsSelected": "Tümü seçildi",
"maxBrandsReached": "Maksimum marka sayısına ulaştınız.",
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
"features": {
"vinSearch": "Sınırsız VIN arama",
"partsCatalog": "Parça kataloğu",
@@ -288,7 +296,12 @@
"trialDuration": "30 Gün Deneme",
"startSearching": "Şase Aramaya Başla",
"error": "Deneme başlatılırken bir hata oluştu.",
"retry": "Tekrar Dene"
"retry": "Tekrar Dene",
"stillTryingTitle": "Beklenenden uzun sürdü",
"stillTryingHint": "Aboneliği şu an doğrulayamıyoruz. Modal'ı kapatıp aramaya geçebilir ya da destek ekibimize yazabilirsiniz.",
"contactSupport": "Destek ile konuş",
"closeAnyway": "Yine de kapat",
"skip": "Atla"
},
"downgradeOffer": {
"title": "Planınızı düşürün, iptal etmeyin",
@@ -309,9 +322,12 @@
"paymentTrustKVKK": "KVKK uyumlu",
"trialDaysLeft": "{days} gün kaldı",
"trialBanner": {
"title": "Deneme süreniz dolmak üzere!",
"title": "Denemen bitmek üzere",
"titleUrgent": "Sadece {days} gün kaldı",
"titleCritical": "Son gün — yarın erişimini kaybedeceksin",
"description": "{days} gününüz kaldı. Tüm özelliklere erişmeye devam etmek için hemen yükseltin.",
"cta": "Plana Yükselt"
"cta": "Plana Yükselt",
"progressLabel": "Denemenin %{percent}'i kullanıldı"
},
"trialProgress": {
"title": "Full Paket denemende {days} gün kaldı",
@@ -359,7 +375,9 @@
"cancelled": "Ödeme iptal edildi. Tekrar deneyebilirsin.",
"verifyingTitle": "Ödemen doğrulanıyor",
"verifyingDescription": "Stripe onayı geldiğinde aboneliğin otomatik aktifleşecek. Bu sayfayı kapatmadan birkaç saniye bekle.",
"trustProvider": "Stripe ödeme altyapısı"
"trustProvider": "Stripe ödeme altyapısı",
"retry": "Yeniden dene",
"cardsAccepted": "Kabul edilen:"
}
},
"billing": {
@@ -377,6 +395,7 @@
"downloadReceipt": "Dekontu İndir",
"viewReceipt": "Makbuzunuz",
"receiptUnavailable": "Bu ödeme için makbuz bulunamadı.",
"failedHint": "Kart ödemesi reddedildi. Bankanızı arayabilir veya farklı bir kartla yeniden deneyebilirsiniz.",
"filterByStatus": "Duruma Göre Filtrele",
"filterByMethod": "Yönteme Göre Filtrele",
"statusLabels": {
@@ -517,7 +536,24 @@
"popular": "Popüler",
"orYearly": "veya {price}/yıl",
"yearlySave": "· 2 ay bedava",
"yearlySavePercent": "· %{percent} tasarruf",
"yearlySaveAmount": "%{percent} tasarruf — yılda {amount} kazanç",
"yearlyBilled": "yılda {price} tek seferde",
"getStarted": "Ücretsiz Başla",
"heroCta": "30 günlük denemeyi başlat",
"heroSecondary": "Planları gör",
"billingPeriodLabel": "Fatura dönemi",
"billing": {
"monthly": "Aylık",
"yearly": "Yıllık"
},
"yearlyBadge": "2 ay bedava",
"stats": {
"brands": { "value": "27", "label": "marka kataloğu" },
"parts": { "value": "1M+", "label": "OEM ve alternatif parça" },
"trial": { "value": "30 gün", "label": "tüm özellikler ücretsiz" },
"refund": { "value": "7 gün", "label": "koşulsuz iade" }
},
"pageTitle": "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
"how": {
"title": "Nasıl çalışır",
@@ -551,5 +587,484 @@
},
"categories": {
"root": "Kök"
},
"search": {
"title": "Şase Arama",
"subtitle": "Araç şase numarasını girerek yedek parça kataloğuna erişin",
"inputLabel": "Şase numarası",
"inputPlaceholder": "Şase numarasını girin (17 karakter)",
"counter": "{count}/17 karakter",
"tryExample": "Örnek şase deneyin →",
"submit": "Şase Çöz",
"errorTitle": "Şase çözümlenemedi",
"errorSubscriptionPrefix": "Aktif aboneliğiniz yok. Araç verilerine erişmek için",
"subscribeLink": "abone olun",
"reportVin": "Şase no doğru, sistem yöneticisine gönder",
"reportSent": "Bildirim gönderildi. En kısa sürede incelenecektir.",
"reportToastTitle": "Bildirim gönderildi",
"reportToastDesc": "Şase numarası sistem yöneticisine iletildi.",
"reportToastError": "Bildirim gönderilemedi",
"previewLoading": "Araç bilgileri alınıyor...",
"vehicleIdentified": "Araç tanımlandı",
"previewHint": "Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.",
"recent": "Son Aramalar",
"seeAll": "Tümünü Gör →",
"openHint": "Aracı parça kataloğuyla birlikte yeniden açmak için tıklayın",
"historyAria": "{brand} {model} — şase {vin}, geçmişten aç",
"errorInvalidVin": "Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.",
"errorGeneric": "Bir hata oluştu. Lütfen tekrar deneyin.",
"errorToast": "Şase arama başarısız",
"decodingSkeleton": "Araç bilgileri yükleniyor…",
"pasteInvalidChars": "Şase numarasında I, O, Q harfleri kullanılamaz",
"candidateSelectFailed": "Araç seçimi başarısız",
"autoCorrected": "Otomatik düzeltildi: {list}"
},
"vehicle": {
"title": "Araç",
"back": "Geri dön",
"notFoundTitle": "Araç bulunamadı",
"errorTitle": "Araç yüklenemedi",
"notFoundMsg": "Bu araç kaydı bulunamadı",
"errorMsg": "Araç bilgileri yüklenemedi",
"notFoundHint": "Bağlantı geçersiz olabilir ya da kayıt kaldırılmış olabilir.",
"errorHint": "Veriler yüklenirken bir hata oluştu.",
"retry": "Tekrar dene",
"info": "Araç Bilgileri",
"categories": "Yedek Parça Kategorileri",
"noCategoriesTitle": "Bu araç için kategori bulunamadı",
"noCategoriesHint": "Araç decode edildi ama parça kataloğu henüz hazırlanmamış olabilir. Destek ekibimize bildirirseniz katalog hızlandırılır.",
"noAttrs": "Bu araç için ayrıntı bilgisi bulunamadı.",
"labelModel": "Model",
"labelYear": "Model yılı",
"labelEngineCode": "Motor kodu",
"equipmentTitle": "Donanım kodları ({count})",
"showAll": "Tümünü göster ({count})",
"showLess": "Daha az göster",
"categoryDetail": "Kategori Detayı",
"invalidLinkTitle": "Geçersiz araç bağlantısı",
"invalidLinkHint": "Bu bağlantı tanımlı bir araca işaret etmiyor. Aramaya dönüp şase numarasını tekrar girebilirsiniz.",
"goToSearch": "Aramaya git",
"categoryNotLoadedTitle": "Kategori yüklenemedi",
"catalogUnavailableTitle": "Katalog şu an yüklenemedi",
"catalogUnavailableHint": "Bu kategori tedarikçi katalogundan alınamadı. Lütfen birazdan tekrar deneyin."
},
"catalogSearch": {
"placeholder": "Tüm katalogda ara — ör. fren balatası veya OEM kodu",
"ariaLabel": "Tüm katalogda ara",
"clearAria": "Aramayı temizle",
"searching": "Aranıyor…",
"treeIncomplete": "Bu aracın kataloğu henüz tam indirilmemiş. Arama yalnızca şu ana kadar açılmış kategorileri kapsar — kategorileri gezdikçe kapsam genişler.",
"noMatch": "\"{query}\" ile eşleşen kategori veya parça bulunamadı.",
"categoriesHeading": "Kategoriler",
"noCategoryMatch": "Eşleşen kategori yok.",
"partsHeading": "Parçalar (OEM)",
"noPartMatch": "Eşleşen parça yok. (Yalnızca açılmış kategorilerin parçaları aranır.)",
"partsCount": "{count} parça"
},
"landing": {
"meta": {
"title": "Şase Numarası Sorgulama & OEM Parça Kataloğu | Sase.tr",
"description": "Araç şase numarasını (VIN) girin, saniyeler içinde OEM parça kodlarına ulaşın. 27 marka, 1M+ parça. Yanlış sipariş yapmadan doğru parçayı bulun.",
"schemaOrgDescription": "Türkiye'nin VIN/şase numarası sorgulama ve OEM yedek parça kataloğu platformu.",
"schemaSoftwareDescription": "Araç şase numarası ile OEM yedek parça sorgulama platformu."
},
"nav": {
"features": "Özellikler",
"howItWorks": "Nasıl Çalışır",
"pricing": "Fiyatlar",
"blog": "Blog",
"contact": "İletişim",
"login": "Giriş Yap",
"register": "30 Gün Ücretsiz Deneyin",
"dashboard": "Panele Git"
},
"aria": {
"themeToggle": "Tema değiştir",
"showBrand": "{brand} markasını göster"
},
"hero": {
"lossPill": "Her yanlış parça siparişi 200-500 TL'ye mal olur",
"ratingLabel": "4.9/5",
"socialProofLabel": "500+ İşletme Güveniyor",
"title": "Doğru Parçayı Anında Bulun",
"subtitle": "Birden fazla katalogda çapraz sorgulama ile her zaman en güncel OEM kodları. Şase numarasını girin, doğru parçayı ilk seferde gönderin.",
"vinPlaceholder": "Örnek: WVWZZZ1JZ3W597935",
"searchCta": "Ara",
"decoding": "Çözülüyor...",
"loadingVehicle": "Araç bilgileri alınıyor...",
"ctaOpenCatalog": "Parça kataloğunu aç",
"ctaAccessFullCatalog": "Tam parça kataloğuna eriş",
"vinNotFound": "Şase bilgisi bulunamadı. Lütfen kontrol edip tekrar deneyin.",
"noVinPrompt": "Şase numaranız yok mu?",
"tryExample": "Örnek aramayı deneyin →",
"metricSpeed": "Ortalama 1.2sn Sonuç",
"metricBrands": "27 Marka Desteği",
"metricParts": "1M+ OEM Parça"
},
"brandStrip": {
"supported": "Desteklenen 27 Otomobil Markası"
},
"testimonialStrip": {
"title": "Kullanıcılarımız ne diyor?",
"items": {
"0": {
"text": "Sase.tr ile yanlış parça siparişlerimiz %80 azaldı. Artık gönül rahatlığıyla sipariş veriyoruz.",
"name": "Ahmet Y.",
"role": "Yedek Parça Mağazası"
},
"1": {
"text": "Ayda 40 saatten fazla zaman kazanıyoruz. Şase numarasını girip anında doğru koda ulaşmak inanılmaz.",
"name": "Mehmet K.",
"role": "Oto Servis İşletmecisi"
},
"2": {
"text": "Online mağazamıza entegre ettik, iade oranımız yarıya düştü. Müşteri memnuniyeti gözle görülür arttı.",
"name": "Özge D.",
"role": "E-Ticaret Yöneticisi"
}
}
},
"features": {
"badge": "Çözümlerimiz",
"title": "Şase Çözme & Parça Kataloğu",
"subtitle": "Araç tanımlamadan parça siparişine kadar tüm süreç tek platformda.",
"loading": "Yükleniyor…",
"ctaTry": "Hemen Deneyin",
"vin": {
"title": "Şase Çözme",
"description": "Müşteri araç bilgisini tam bilmiyor mu? Şase numarasını girin — marka, model, motor, donanım saniyeler içinde karşınızda.",
"bullet1": "Marka, model ve yıl tespiti",
"bullet2": "Motor ve şanzıman detayları",
"bullet3": "Donanım paketi bilgileri",
"mockupTitle": "Şase Çözme — Araç Bilgileri",
"labelMake": "Marka",
"labelModel": "Model",
"labelYear": "Yıl",
"labelEngine": "Motor",
"labelBody": "Kasa",
"valueBody": "Hatchback"
},
"schema": {
"title": "İnteraktif Şema",
"description": "Parça kataloglarında kaybolmak yerine, tıklanabilir diyagramlarda parçayı görsel olarak bulun.",
"bullet1": "Tıklanabilir parça diyagramları",
"bullet2": "Zoom & pan navigasyon",
"bullet3": "Alt kategori derinliği",
"mockupTitle": "İnteraktif Şema — Motor Bölgesi"
},
"oem": {
"title": "OEM Parça Numaraları",
"description": "Birden fazla katalogda çapraz sorgulama yaparak her zaman en güncel ve doğru OEM kodlarını sunar — yanlış parça, iade derdi tarih olur.",
"bullet1": "Çoklu katalog çapraz sorgulama",
"bullet2": "Her zaman güncel OEM kodları",
"bullet3": "Fiyat karşılaştırma",
"mockupTitle": "OEM Katalog — Parça Listesi",
"part1": "Klima Kompresörü",
"part2": "Kalorifer Motoru",
"part3": "Ön Fren Balatası"
}
},
"compare": {
"badge": "Karşılaştırma",
"title": "Manuel Yöntem vs Sase.tr",
"manualHeader": "Manuel",
"saseHeader": "Sase.tr",
"rows": {
"0": {
"label": "Parça arama süresi",
"manual": "~15 dakika",
"sase": "30 saniye"
},
"1": {
"label": "Yanlış parça riski",
"manual": "Yüksek",
"sase": "Sıfıra yakın"
},
"2": {
"label": "OEM kodu bulma",
"manual": "Tek katalog, güncel olmayabilir",
"sase": "Çoklu katalog çapraz sorgu"
},
"3": {
"label": "Fiyat karşılaştırma",
"manual": "İmkansız",
"sase": "Tek ekranda"
},
"4": {
"label": "Aylık zaman tasarrufu",
"manual": "—",
"sase": "~40 saat"
}
}
},
"howItWorks": {
"badge": "Nasıl Çalışır",
"title": "3 Adımda Orijinal Parçaya Ulaşın",
"items": {
"0": {
"title": "Şase Girin",
"description": "17 haneli şase numarasını girin, araç anında tanımlansın."
},
"1": {
"title": "27 Marka",
"description": "Avrupa, Asya ve Amerikan markaları tek çatı altında."
},
"2": {
"title": "Kategorileri Seçin",
"description": "Motor, şasi, elektrik... İstediğiniz parça kategorisine dalın."
},
"3": {
"title": "Şema İnceleyin",
"description": "İnteraktif teknik diyagramlarda parça konumlarını görsel olarak keşfedin."
},
"4": {
"title": "OEM Kod Alın",
"description": "Orijinal üretici parça numarasını kopyalayın, siparişe hazır olun."
},
"5": {
"title": "Sipariş Verin",
"description": "Doğru parça numarasıyla tedarikçilere hızlıca sipariş oluşturun."
}
}
},
"useCases": {
"badge": "Kullanım Alanları",
"title": "Kimler İçin?",
"items": {
"0": {
"title": "Yedek Parça Arama",
"description": "Şase numarası ile araç modelinize uygun orijinal parçaları anında bulun."
},
"1": {
"title": "Orijinal Parça Numarası",
"description": "OEM parça kodlarına erişerek doğru parçayı ilk seferde sipariş edin."
},
"2": {
"title": "Araç Tanımlama",
"description": "Şase çözme ile aracın tüm teknik detaylarını ve donanım bilgilerini öğrenin."
},
"3": {
"title": ıkma Parça Doğrulama",
"description": "OEM kodlarıyla çıkma parçaların orijinalliğini ve uyumluluğunu kontrol edin."
},
"4": {
"title": "Servis & Bakım",
"description": "Bakım planlarına uygun parça listeleriyle servis süreçlerini hızlandırın."
}
},
"ctaCard": {
"title": "Farklı bir ihtiyacınız mı var?",
"description": "Özel entegrasyon veya toplu sorgulama ihtiyacınız varsa bize ulaşın.",
"cta": "Bize Ulaşın"
}
},
"stats": {
"badge": "Platform",
"titleLine1": "Rakamlarla",
"titleLine2": "Sase.tr",
"items": {
"0": { "value": "1.2sn", "label": "Ortalama sorgu süresi" },
"1": { "value": "27", "label": "Desteklenen marka" },
"2": { "value": "1M+", "label": "OEM parça numarası" },
"3": { "value": "%99.9", "label": "Platform erişilebilirlik" }
}
},
"dashboard": {
"title": "Tüm Veriler, Tek Platform",
"subtitle": "Şase çözme, parça kataloğu ve interaktif şemalar tek bir arayüzde.",
"frameTitle": "sase.tr/dashboard",
"loading": "Yükleniyor…",
"floatVin": "Şase Arama",
"floatSchema": "İnteraktif Şema",
"floatOem": "OEM Kodları"
},
"dashboardDemo": {
"sidebar": {
"dashboard": "Dashboard",
"vinSearch": "Şase Arama",
"catalog": "Katalog",
"diagrams": "Şemalar",
"settings": "Ayarlar"
},
"categories": {
"engine": "Motor",
"chassis": "Şasi",
"electrical": "Elektrik",
"body": "Karoseri",
"brakes": "Fren"
},
"parts": {
"acCompressor": "Klima Kompresörü",
"brakePads": "Fren Balatası",
"heaterBlower": "Kalorifer Motoru"
},
"vinPlaceholder": "Şase numarası girin...",
"categorySelectTitle": "Kategori Seçin",
"schemaViewTitle": "Motor — Şema Görünümü",
"oemListTitle": "OEM Parça Listesi"
},
"ecommerceDemo": {
"products": {
"brakePads": "Fren Balatası",
"acCompressor": "Klima Kompresörü",
"shockAbsorber": "Amortisör",
"oilFilter": "Yağ Filtresi",
"wiperMotor": "Silecek Motoru",
"headlight": "Ön Far"
},
"searchPlaceholder": "Parça ara...",
"preReturnBadge": "%23 iade oranı",
"addToCart": "Sepete Ekle",
"cartAddedToast": "Fren Balatası sepete eklendi",
"conversionOverlay": "Dönüşüm: %94",
"statReturnRate": "İade oranı",
"statConversionRate": "Dönüşüm oranı",
"statSatisfaction": "Müşteri memnuniyeti"
},
"ecommerce": {
"badge": "E-Ticaret Entegrasyonu",
"title": "Kendi Sitenize Şase Çözme Gücü Katın",
"subtitle": "Müşterileriniz yanlış parça sipariş edip iade mi açıyor? Sase.tr entegrasyonu ile VIN bazlı filtreleme ekleyin — iadeler düşsün, dönüşüm artsın.",
"frameTitle": "otoyedekparca.co — Sase.tr Entegrasyonu",
"loading": "Yükleniyor…",
"easyBadge": "Kolay Entegrasyon",
"cardTitle": "Birkaç Satır Kod, Büyük Fark",
"cardBody": "E-ticaret sitenize Sase.tr'nin VIN çözme motorunu entegre edin. Müşterileriniz şase numarasını girsin — sadece araçlarına uygun parçalar listelensin.",
"bullet1": "API & Widget — Birkaç satır kodla sitenize entegre edin",
"bullet2": "VIN Bazlı Filtreleme — Sadece uyumlu parçalar görünsün",
"bullet3": "İade Oranını Düşürün — Doğru parça, ilk seferde",
"bullet4": "White-Label — Widget sitenizin tasarımına uyum sağlar",
"cta": "İletişime Geçin",
"stats": {
"0": { "value": "%42", "label": "Daha Az İade" },
"1": { "value": "%35", "label": "Daha Yüksek Dönüşüm" },
"2": { "value": "<30dk", "label": "Entegrasyon Süresi" }
}
},
"testimonials": {
"badge": "Müşteri Yorumları",
"title": "İşletme Sahipleri Ne Diyor?",
"items": {
"0": {
"name": "Ahmet Y.",
"role": "Yedek Parça Mağazası Sahibi",
"text": "Eskiden bir parça aramak için 3-4 katalog açıyordum. Sase.tr ile şase numarasını giriyorum, 30 saniyede doğru OEM kodunu buluyorum. İade oranımız %80 düştü."
},
"1": {
"name": "Mehmet K.",
"role": "Oto Servis İşletmecisi",
"text": "Sase.tr'ye geçtiğimizden beri yanlış parça siparişlerimiz neredeyse sıfıra indi. Aylık 40 saatin üzerinde zaman tasarrufu sağlıyoruz."
},
"2": {
"name": "Özge D.",
"role": "E-Ticaret Yöneticisi",
"text": "Online mağazamıza VIN entegrasyonu ekledik. Müşteriler şase numarasını giriyor, sadece uyumlu parçalar listeleniyor. İade oranımız %42 düştü."
},
"3": {
"name": "Burak T.",
"role": "Filo Yöneticisi",
"text": "200+ araçlık filomuz için parça yönetimi çok kolay hale geldi. Özellikle interaktif şema özelliği montaj sürecini çok hızlandırıyor."
}
}
},
"pricing": {
"badge": "Fiyatlandırma",
"titleLine1": "Günde 7 TL'den",
"titleLine2": "başlayan fiyatlar.",
"subtitle": "Tek bir yanlış parça iadesinin maliyetinden daha az. Tüm planlarda 30 gün ücretsiz deneme var, kart bilgisi gerekmez.",
"perMonth": "/ay",
"currency": "TL",
"yearlyPrice": "yıllık {amount} TL",
"tryFree": "30 gün ücretsiz dene",
"plans": {
"brand1": {
"name": "1 Marka",
"description": "Tek marka için erişim",
"feature1": "1 marka seçimi",
"feature2": "Sınırsız VIN arama",
"feature3": "Parça kataloğu",
"feature4": "Şema görüntüleyici"
},
"brand2": {
"name": "2 Marka",
"description": "İki farklı marka",
"feature1": "2 marka seçimi",
"feature2": "Sınırsız VIN arama",
"feature3": "Parça kataloğu",
"feature4": "Öncelikli destek"
},
"brand3": {
"name": "3 Marka",
"description": "Üç marka kapsamlı",
"feature1": "3 marka seçimi",
"feature2": "Sınırsız VIN arama",
"feature3": "Parça kataloğu",
"feature4": "Öncelikli destek"
}
},
"full": {
"badge": "En çok tercih edilen",
"name": "Full Paket",
"description": "Tüm markalara sınırsız erişim. Tek bir aboneliğe sığdırdık.",
"priceUnit": "TL/ay",
"yearlyNote": "yıllık 9.990 TL — ayda 832 TL'ye denk gelir",
"ctaTry": "30 gün ücretsiz dene",
"ctaFineprint": "Kart bilgisi gerekmez. İstediğin zaman iptal et.",
"included": "Pakete dahil",
"feature1": "Tüm 27 markaya erişim",
"feature2": "Sınırsız VIN arama",
"feature3": "OEM parça kataloğu",
"feature4": "İnteraktif şema görüntüleyici",
"feature5": "Geçmiş sorgular & favoriler",
"feature6": "Öncelikli e-posta desteği",
"feature7": "API erişimi (talep üzerine)",
"feature8": "Toplu sorgu özelliği"
},
"detailedComparison": "Detaylı karşılaştırmayı gör"
},
"faq": {
"badge": "SSS",
"title": "Sık Sorulan Sorular",
"items": {
"0": {
"q": "Hangi markaları destekliyorsunuz?",
"a": "Şu an 27 otomobil markası destekleniyor ve liste sürekli genişliyor. Şase numaranızı arama kutusuna girerek aracınızın desteklenip desteklenmediğini hemen görebilirsiniz."
},
"1": {
"q": "Verilerim güvende mi?",
"a": "Tüm trafik SSL ile şifrelenir ve verileriniz KVKK'ya uygun şekilde işlenir. Sorgu geçmişiniz yalnızca size özeldir."
},
"2": {
"q": "Kart bilgisi gerekiyor mu?",
"a": "Hayır. 30 günlük ücretsiz deneme için kredi kartı bilgisi istemiyoruz; dilediğiniz an vazgeçebilirsiniz."
},
"3": {
"q": "İstediğim zaman iptal edebilir miyim?",
"a": "Evet, taahhüt yok. Aboneliğinizi panelden tek tıkla iptal edebilirsiniz."
},
"4": {
"q": "Mevcut e-ticaret veya servis sistemime entegre olur mu?",
"a": "Full Paket'te API erişimi ve toplu sorgu ile sistemlerinize entegre edebilirsiniz (talep üzerine kurulum desteği)."
},
"5": {
"q": "Şase numarasını bilmiyorsam ne olur?",
"a": "Marka ve model üzerinden de katalogda gezebilir, ya da ana sayfadaki örnek aramayı deneyerek platformu test edebilirsiniz."
}
}
},
"finalCta": {
"title": "Her Yanlış Parça Size 200-500 TL'ye Mal Oluyor",
"subtitle": "İade kargosu, müşteri kaybı, zaman kaybı. Şase çözme ile ilk seferde doğru parçayı bulun.",
"cta": "30 Gün Ücretsiz Başla — Kart Gerekmez",
"trust": "KVKK uyumlu · SSL şifreli · Kart bilgisi gerekmez"
},
"referral": {
"title": "Arkadaşını Davet Et",
"description": "3 davet = +7 gün ücretsiz, 5 davet = +1 ay ücretsiz",
"cta": "Davet Kodunuzu Alın"
},
"errors": {
"decodeGeneric": "Bir hata oluştu. Lütfen tekrar deneyin."
}
}
}

View File

@@ -21,17 +21,38 @@ function getColors(isDark: boolean) {
}
const VIN_TEXT = "WVWZZZ1JZ3W...";
const SIDEBAR_ITEMS = ["Dashboard", "Şase Arama", "Katalog", "Şemalar", "Ayarlar"];
const CATEGORIES = ["Motor", "Şasi", "Elektrik", "Karoseri", "Fren"];
const PARTS = [
{ code: "1J0 820 803F", name: "Klima Kompresörü" },
{ code: "1J0 698 151G", name: "Fren Balatası" },
{ code: "1J0 819 031A", name: "Kalorifer Motoru" },
];
// All translatable copy threaded through the Remotion composition. Parent
// supplies labels from useTranslation(); falls back to TR if nothing is passed
// (keeps the standalone Remotion Studio preview working).
export interface DashboardDemoLabels {
sidebar: [string, string, string, string, string];
categories: [string, string, string, string, string];
parts: [string, string, string];
vinPlaceholder: string;
categorySelectTitle: string;
schemaViewTitle: string;
oemListTitle: string;
}
const DEFAULT_LABELS: DashboardDemoLabels = {
sidebar: ["Dashboard", "Şase Arama", "Katalog", "Şemalar", "Ayarlar"],
categories: ["Motor", "Şasi", "Elektrik", "Karoseri", "Fren"],
parts: ["Klima Kompresörü", "Fren Balatası", "Kalorifer Motoru"],
vinPlaceholder: "Şase numarası girin...",
categorySelectTitle: "Kategori Seçin",
schemaViewTitle: "Motor — Şema Görünümü",
oemListTitle: "OEM Parça Listesi",
};
const PART_CODES = ["1J0 820 803F", "1J0 698 151G", "1J0 819 031A"];
// ─── Scene 1: VIN Input ─────────────────────────────────────────────────────
const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const VinInputScene: React.FC<{ isDark: boolean; labels: DashboardDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -61,7 +82,7 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
transform: `translateX(${sidebarX}px)`,
}}
>
{SIDEBAR_ITEMS.map((item, i) => (
{labels.sidebar.map((item, i) => (
<div
key={item}
style={{
@@ -114,7 +135,7 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
color: typedText ? c.fg : c.mutedFg,
}}
>
{typedText || "Şase numarası girin..."}
{typedText || labels.vinPlaceholder}
</span>
{/* Blinking cursor */}
{frame > typeStart && charsToShow < VIN_TEXT.length && (
@@ -135,7 +156,10 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Scene 2: Vehicle Info ──────────────────────────────────────────────────
const VehicleInfoScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const VehicleInfoScene: React.FC<{ isDark: boolean; labels: DashboardDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -159,7 +183,7 @@ const VehicleInfoScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
padding: 12,
}}
>
{SIDEBAR_ITEMS.map((item, i) => (
{labels.sidebar.map((item, i) => (
<div
key={item}
style={{
@@ -269,7 +293,10 @@ const VehicleInfoScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Scene 3: Category Selection ────────────────────────────────────────────
const CategoryScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const CategoryScene: React.FC<{ isDark: boolean; labels: DashboardDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -285,7 +312,7 @@ const CategoryScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
padding: 12,
}}
>
{SIDEBAR_ITEMS.map((item, i) => (
{labels.sidebar.map((item, i) => (
<div
key={item}
style={{
@@ -314,10 +341,10 @@ const CategoryScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
fontFamily: "system-ui, sans-serif",
}}
>
Kategori Seçin
{labels.categorySelectTitle}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{CATEGORIES.map((cat, i) => {
{labels.categories.map((cat, i) => {
const delay = i * 5;
const itemSpring = spring({
frame: frame - delay,
@@ -364,7 +391,10 @@ const CategoryScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Scene 4: Schema View ───────────────────────────────────────────────────
const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const SchemaViewScene: React.FC<{ isDark: boolean; labels: DashboardDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -387,7 +417,7 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
padding: 12,
}}
>
{SIDEBAR_ITEMS.map((item, i) => (
{labels.sidebar.map((item, i) => (
<div
key={item}
style={{
@@ -416,7 +446,7 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
fontFamily: "system-ui, sans-serif",
}}
>
Motor Şema Görünümü
{labels.schemaViewTitle}
</div>
<div
style={{
@@ -515,7 +545,10 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Scene 5: OEM Codes ─────────────────────────────────────────────────────
const OemCodesScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const OemCodesScene: React.FC<{ isDark: boolean; labels: DashboardDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -537,7 +570,7 @@ const OemCodesScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
padding: 12,
}}
>
{SIDEBAR_ITEMS.map((item, i) => (
{labels.sidebar.map((item, i) => (
<div
key={item}
style={{
@@ -566,10 +599,12 @@ const OemCodesScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
fontFamily: "system-ui, sans-serif",
}}
>
OEM Parça Listesi
{labels.oemListTitle}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{PARTS.map((part, i) => {
{PART_CODES.map((code, i) => {
const partName = labels.parts[i] ?? "";
const part = { code, name: partName };
const delay = i * 8;
const slideSpring = spring({
frame: frame - delay,
@@ -634,25 +669,28 @@ const OemCodesScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Main Composition ───────────────────────────────────────────────────────
export const DashboardDemo: React.FC<{ isDark: boolean }> = ({ isDark }) => {
export const DashboardDemo: React.FC<{ isDark: boolean; labels?: DashboardDemoLabels }> = ({
isDark,
labels = DEFAULT_LABELS,
}) => {
const c = getColors(isDark);
return (
<AbsoluteFill style={{ backgroundColor: c.bg }}>
<Sequence from={0} durationInFrames={90}>
<VinInputScene isDark={isDark} />
<VinInputScene isDark={isDark} labels={labels} />
</Sequence>
<Sequence from={90} durationInFrames={90}>
<VehicleInfoScene isDark={isDark} />
<VehicleInfoScene isDark={isDark} labels={labels} />
</Sequence>
<Sequence from={180} durationInFrames={90}>
<CategoryScene isDark={isDark} />
<CategoryScene isDark={isDark} labels={labels} />
</Sequence>
<Sequence from={270} durationInFrames={90}>
<SchemaViewScene isDark={isDark} />
<SchemaViewScene isDark={isDark} labels={labels} />
</Sequence>
<Sequence from={360} durationInFrames={90}>
<OemCodesScene isDark={isDark} />
<OemCodesScene isDark={isDark} labels={labels} />
</Sequence>
</AbsoluteFill>
);

View File

@@ -23,20 +23,57 @@ function getColors(isDark: boolean) {
const STORE_NAME = "OtoYedekParca.co";
const VIN_DISPLAY = "WVWZZZ1JZ3W...";
const PRODUCTS = [
{ name: "Fren Balatası", price: "450 TL", compatible: true },
{ name: "Klima Kompresörü", price: "1.200 TL", compatible: true },
{ name: "Amortisör", price: "680 TL", compatible: false },
{ name: "Yağ Filtresi", price: "120 TL", compatible: true },
{ name: "Silecek Motoru", price: "350 TL", compatible: false },
{ name: "Ön Far", price: "890 TL", compatible: false },
];
export interface EcommerceDemoLabels {
productNames: [string, string, string, string, string, string];
searchPlaceholder: string;
preReturnBadge: string;
addToCart: string;
cartAddedToast: string;
conversionOverlay: string;
statReturnRate: string;
statConversionRate: string;
statSatisfaction: string;
}
const DEFAULT_LABELS: EcommerceDemoLabels = {
productNames: [
"Fren Balatası",
"Klima Kompresörü",
"Amortisör",
"Yağ Filtresi",
"Silecek Motoru",
"Ön Far",
],
searchPlaceholder: "Parça ara...",
preReturnBadge: "%23 iade oranı",
addToCart: "Sepete Ekle",
cartAddedToast: "Fren Balatası sepete eklendi",
conversionOverlay: "Dönüşüm: %94",
statReturnRate: "İade oranı",
statConversionRate: "Dönüşüm oranı",
statSatisfaction: "Müşteri memnuniyeti",
};
const PRODUCT_PRICES = ["450 TL", "1.200 TL", "680 TL", "120 TL", "350 TL", "890 TL"];
const PRODUCT_COMPAT = [true, true, false, true, false, false];
function buildProducts(labels: EcommerceDemoLabels) {
return labels.productNames.map((name, i) => ({
name,
price: PRODUCT_PRICES[i],
compatible: PRODUCT_COMPAT[i],
}));
}
const FONT = "system-ui, sans-serif";
// ─── Scene 1: Storefront ─────────────────────────────────────────────────────
const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const StorefrontScene: React.FC<{ isDark: boolean; labels: EcommerceDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -106,7 +143,7 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
fontFamily: FONT,
}}
>
Parça ara...
{labels.searchPlaceholder}
</span>
</div>
{/* Cart icon */}
@@ -169,7 +206,7 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
gap: 8,
}}
>
{PRODUCTS.map((product, i) => {
{buildProducts(labels).map((product, i) => {
const delay = i * 5;
const itemSpring = spring({
frame: frame - delay,
@@ -253,7 +290,7 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
fontWeight: 600,
}}
>
%23 iade oranı
{labels.preReturnBadge}
</span>
</div>
</div>
@@ -263,7 +300,10 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Scene 2: Widget Integration ────────────────────────────────────────────
const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const WidgetIntegrationScene: React.FC<{ isDark: boolean; labels: EcommerceDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -357,7 +397,7 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
whiteSpace: "nowrap",
}}
>
Parça ara...
{labels.searchPlaceholder}
</span>
</div>
@@ -466,7 +506,7 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
gap: 8,
}}
>
{PRODUCTS.map((product) => (
{buildProducts(labels).map((product) => (
<div
key={product.name}
style={{
@@ -516,7 +556,10 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Scene 3: VIN Filter ────────────────────────────────────────────────────
const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const VinFilterScene: React.FC<{ isDark: boolean; labels: EcommerceDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -605,7 +648,7 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
whiteSpace: "nowrap",
}}
>
Parça ara...
{labels.searchPlaceholder}
</span>
</div>
@@ -722,7 +765,7 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
gap: 8,
}}
>
{PRODUCTS.map((product, i) => {
{buildProducts(labels).map((product, i) => {
const isIncompatible = incompatibleIndices.includes(i);
const cardOpacity = isIncompatible ? interpolate(filterProgress, [0, 1], [1, 0.15]) : 1;
const borderColor = !isIncompatible && filterProgress > 0.5 ? c.emerald : c.border;
@@ -809,7 +852,10 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Scene 4: Add To Cart ───────────────────────────────────────────────────
const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const AddToCartScene: React.FC<{ isDark: boolean; labels: EcommerceDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -988,7 +1034,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
fontWeight: 500,
}}
>
Fren Balatası sepete eklendi
{labels.cartAddedToast}
</span>
</div>
</div>
@@ -1022,7 +1068,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
gap: 8,
}}
>
{PRODUCTS.map((product, i) => {
{buildProducts(labels).map((product, i) => {
const isIncompatible = incompatibleIndices.includes(i);
return (
<div
@@ -1115,7 +1161,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
textAlign: "center",
}}
>
Sepete Ekle
{labels.addToCart}
</div>
</div>
)}
@@ -1140,7 +1186,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
fontWeight: 600,
}}
>
Dönüşüm: %94
{labels.conversionOverlay}
</span>
</div>
</div>
@@ -1150,15 +1196,18 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Scene 5: Stats Overlay ─────────────────────────────────────────────────
const StatsOverlayScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
const StatsOverlayScene: React.FC<{ isDark: boolean; labels: EcommerceDemoLabels }> = ({
isDark,
labels,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
const stats = [
{ arrow: "↓", value: "%42", label: "İade oranı" },
{ arrow: "↑", value: "%35", label: "Dönüşüm oranı" },
{ arrow: "↑", value: "%98", label: "Müşteri memnuniyeti" },
{ arrow: "↓", value: "%42", label: labels.statReturnRate },
{ arrow: "↑", value: "%35", label: labels.statConversionRate },
{ arrow: "↑", value: "%98", label: labels.statSatisfaction },
];
// Fade out at end
@@ -1231,25 +1280,28 @@ const StatsOverlayScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
// ─── Main Composition ───────────────────────────────────────────────────────
export const EcommerceDemo: React.FC<{ isDark: boolean }> = ({ isDark }) => {
export const EcommerceDemo: React.FC<{ isDark: boolean; labels?: EcommerceDemoLabels }> = ({
isDark,
labels = DEFAULT_LABELS,
}) => {
const c = getColors(isDark);
return (
<AbsoluteFill style={{ backgroundColor: c.bg }}>
<Sequence from={0} durationInFrames={90}>
<StorefrontScene isDark={isDark} />
<StorefrontScene isDark={isDark} labels={labels} />
</Sequence>
<Sequence from={90} durationInFrames={90}>
<WidgetIntegrationScene isDark={isDark} />
<WidgetIntegrationScene isDark={isDark} labels={labels} />
</Sequence>
<Sequence from={180} durationInFrames={90}>
<VinFilterScene isDark={isDark} />
<VinFilterScene isDark={isDark} labels={labels} />
</Sequence>
<Sequence from={270} durationInFrames={90}>
<AddToCartScene isDark={isDark} />
<AddToCartScene isDark={isDark} labels={labels} />
</Sequence>
<Sequence from={360} durationInFrames={60}>
<StatsOverlayScene isDark={isDark} />
<StatsOverlayScene isDark={isDark} labels={labels} />
</Sequence>
</AbsoluteFill>
);

View File

@@ -21,7 +21,10 @@ function getColors(isDark: boolean) {
};
}
export const SchemaDemo: React.FC<{ isDark: boolean }> = ({ isDark }) => {
export const SchemaDemo: React.FC<{ isDark: boolean; partLabel?: string }> = ({
isDark,
partLabel = "Klima Kompresörü",
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const c = getColors(isDark);
@@ -175,7 +178,7 @@ export const SchemaDemo: React.FC<{ isDark: boolean }> = ({ isDark }) => {
marginTop: 2,
}}
>
Klima Kompresörü
{partLabel}
</div>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { useAuth } from "@/hooks/use-auth";
import { api } from "@/lib/api-client";
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
import { useTranslation } from "@/lib/i18n";
import { trackPageView as trackMetaPageView } from "@/lib/meta-pixel";
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
import { Toaster } from "@/lib/toast";
@@ -92,6 +93,7 @@ function applyTheme(theme: "light" | "dark" | "system") {
function RootComponent() {
const location = useLocation();
const { user } = useAuth();
const { t } = useTranslation();
// Pageview tracking
useEffect(() => {
@@ -135,7 +137,7 @@ function RootComponent() {
return (
<>
<a href="#main-content" className="skip-link">
İçeriğe atla
{t("a11y.skipToContent")}
</a>
<Outlet />
<Toaster position="top-center" />

View File

@@ -16,10 +16,11 @@ export const Route = createFileRoute("/_auth/register")({
component: RegisterPage,
validateSearch: (
search: Record<string, unknown>,
): { vin?: string; ref?: string; plan?: string } => ({
): { vin?: string; ref?: string; plan?: string; example?: string } => ({
vin: (search.vin as string) || undefined,
ref: (search.ref as string) || undefined,
plan: (search.plan as string) || undefined,
example: (search.example as string) || undefined,
}),
});
@@ -28,7 +29,7 @@ export const Route = createFileRoute("/_auth/register")({
const PENDING_PLAN_KEY = "sase-pending-plan";
function RegisterPage() {
const { vin, ref, plan } = Route.useSearch();
const { vin, ref, plan, example } = Route.useSearch();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -50,6 +51,7 @@ function RegisterPage() {
"/dashboard/search?welcome=1",
vin ? `&vin=${encodeURIComponent(vin)}` : "",
refCode.trim() ? `&ref=${encodeURIComponent(refCode.trim().toUpperCase())}` : "",
example === "1" ? "&example=1" : "",
].join("");
async function handleSubmit(e: React.FormEvent) {

View File

@@ -8,7 +8,7 @@ import { Button } from "@sase/ui";
import { Skeleton } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { ArrowRight, Download, Filter, Receipt } from "lucide-react";
import { ArrowRight, Download, Filter, HelpCircle, Receipt, ReceiptText } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/dashboard/billing")({
@@ -237,8 +237,21 @@ function BillingPage() {
</Card>
) : !filteredPayments || filteredPayments.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center text-muted-foreground">
<p>{hasActiveFilter ? t("billing.noResults") : t("billing.noPayments")}</p>
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
<ReceiptText className="h-6 w-6" />
</div>
<p className="text-sm text-muted-foreground">
{hasActiveFilter ? t("billing.noResults") : t("billing.noPayments")}
</p>
{!hasActiveFilter && !subscription && (
<Button asChild size="sm">
<Link to="/dashboard/subscription">
{t("billing.summary.choosePlan")}
<ArrowRight className="ml-1.5 size-4" />
</Link>
</Button>
)}
{hasActiveFilter && (
<Button variant="outline" size="sm" onClick={clearFilters}>
{t("billing.clearFilters")}
@@ -300,13 +313,24 @@ function BillingPage() {
</Badge>
</td>
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
<Badge variant={statusVariants[payment.status] || "secondary"}>
{t(`billing.statusLabels.${payment.status}`)}
</Badge>
<span className="inline-flex items-center gap-1.5">
<Badge variant={statusVariants[payment.status] || "secondary"}>
{t(`billing.statusLabels.${payment.status}`)}
</Badge>
{payment.status === "failed" && (
<span
className="inline-flex cursor-help text-muted-foreground"
title={t("billing.failedHint")}
aria-label={t("billing.failedHint")}
>
<HelpCircle className="h-3.5 w-3.5" />
</span>
)}
</span>
</td>
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
{payment.eftReceiptUrl ? (
<Button variant="ghost" size="sm" asChild>
<Button variant="outline" size="sm" asChild>
<a
href={payment.eftReceiptUrl}
target="_blank"
@@ -319,7 +343,7 @@ function BillingPage() {
</Button>
) : payment.hasStripeReceipt ? (
<Button
variant="ghost"
variant="outline"
size="sm"
disabled={receiptLoadingId === payment.id}
onClick={() => openReceipt(payment.id)}

View File

@@ -3,10 +3,11 @@ import { VinBrandIcon } from "@/components/ui/vin-brand-icon";
import { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
import { ApiError, api } from "@/lib/api-client";
import { startAction } from "@/lib/faro";
import { useTranslation } from "@/lib/i18n";
import { KEYS_6, KEYS_17 } from "@/lib/keys";
import { capture } from "@/lib/posthog";
import { toast } from "@/lib/toast";
import { Badge, Button, Input, Separator } from "@sase/ui";
import { Badge, Button, Input, Label, Separator } from "@sase/ui";
import { useQuery } from "@tanstack/react-query";
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle, Car, Clock, Loader2, Search, Send } from "lucide-react";
@@ -16,6 +17,15 @@ import { useCallback, useEffect, useRef, useState } from "react";
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
interface VehicleHistoryItem {
id: string;
vin: string;
brandName: string;
model: string | null;
year: number | null;
lastAccessedAt: string | null;
}
function isValidVin(vin: string): boolean {
if (!vin || vin.length !== 17) return false;
return VIN_REGEX.test(vin.toUpperCase());
@@ -54,9 +64,10 @@ function ShimmerBox({ className }: { className?: string }) {
* a vehicle-page-shaped skeleton so the wait feels like the page is loading
* rather than an unresponsive button. */
function DecodingSkeleton() {
const { t } = useTranslation();
return (
<div className="space-y-6" aria-busy="true" aria-live="polite">
<span className="sr-only">Araç bilgileri yükleniyor</span>
<span className="sr-only">{t("search.decodingSkeleton")}</span>
{/* Header: back + brand logo + 2-line title */}
<div className="flex items-center gap-3">
<ShimmerBox className="h-9 w-9 rounded-md" />
@@ -90,16 +101,23 @@ export const Route = createFileRoute("/dashboard/search")({
component: SearchPage,
validateSearch: (
search: Record<string, unknown>,
): { vin?: string; welcome?: string; ref?: string } => ({
): { vin?: string; welcome?: string; ref?: string; example?: string } => ({
vin: search.vin ? String(search.vin) : undefined,
welcome: search.welcome ? String(search.welcome) : undefined,
ref: search.ref ? String(search.ref) : undefined,
example: search.example ? String(search.example) : undefined,
}),
});
function SearchPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { vin: vinParam, welcome: welcomeParam, ref: refParam } = Route.useSearch();
const {
vin: vinParam,
welcome: welcomeParam,
ref: refParam,
example: exampleParam,
} = Route.useSearch();
const inputRef = useRef<HTMLInputElement>(null);
const focusFiredRef = useRef(false);
const querySourceRef = useRef<"manual" | "paste" | "history" | "landing">("manual");
@@ -139,7 +157,7 @@ function SearchPage() {
const { data: history } = useQuery({
queryKey: ["vehicles", "history"],
queryFn: () => api.get<any[]>("/vehicles/history?limit=20"),
queryFn: () => api.get<VehicleHistoryItem[]>("/vehicles/history?limit=6"),
});
// ─── Ctrl+K shortcut ───────────────────────────────────────────────────────
@@ -271,8 +289,7 @@ function SearchPage() {
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
} catch (err) {
const responseTimeMs = Math.round(performance.now() - decodeStart);
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
capture("vin_decode_error", {
vin: cleanVin,
error: message,
@@ -282,7 +299,7 @@ function SearchPage() {
attempt,
});
setError(message);
toast.error("Şase arama başarısız");
toast.error(t("search.errorToast"));
} finally {
setLoading(false);
}
@@ -304,7 +321,7 @@ function SearchPage() {
error_type: cleanVin.length !== 17 ? "invalid_length" : "invalid_chars",
input_length: cleanVin.length,
});
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
setError(t("search.errorInvalidVin"));
return;
}
@@ -319,7 +336,7 @@ function SearchPage() {
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.");
setError(t("search.errorInvalidVin"));
return;
}
lastAttemptedVinRef.current = cleanVin;
@@ -342,15 +359,20 @@ function SearchPage() {
if (didInitFromUrlRef.current) return;
didInitFromUrlRef.current = true;
const carried = vinParam?.toUpperCase().trim();
const isExample = exampleParam === "1";
if (carried) {
setVin(carried);
if (welcomeParam === "1") {
pendingVinRef.current = carried;
} else {
autoDecode(carried);
// Example VINs from the landing should just prefill — the visitor wanted
// to explore, not commit. User-typed VINs are decoded straight through.
if (!isExample) {
if (welcomeParam === "1") {
pendingVinRef.current = carried;
} else {
autoDecode(carried);
}
}
}
if (vinParam || welcomeParam || refParam) {
if (vinParam || welcomeParam || refParam || exampleParam) {
navigate({ to: "/dashboard/search", replace: true, search: {} });
}
}, []);
@@ -360,11 +382,11 @@ function SearchPage() {
try {
await api.post("/vehicles/report-vin", { vin: vin.toUpperCase().trim() });
setReportSent(true);
toast.success("Bildirim gönderildi", {
description: "Şase numarası sistem yöneticisine iletildi.",
toast.success(t("search.reportToastTitle"), {
description: t("search.reportToastDesc"),
});
} catch {
toast.error("Bildirim gönderilemedi");
toast.error(t("search.reportToastError"));
} finally {
setReportSending(false);
}
@@ -382,11 +404,11 @@ function SearchPage() {
if (correctionsRef.current.size === 0) return;
const list = [...correctionsRef.current];
correctionsRef.current = new Set();
toast.info(`Otomatik düzeltildi: ${list.join(", ")}`, {
description: "Şase numarasında I, O, Q harfleri kullanılamaz",
toast.info(t("search.autoCorrected", { list: list.join(", ") }), {
description: t("search.pasteInvalidChars"),
duration: 2500,
});
}, []);
}, [t]);
useEffect(() => {
return () => {
@@ -445,12 +467,11 @@ function SearchPage() {
params: { id: data.id },
});
} catch (err) {
const message =
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
setError(message);
setCandidates(null);
setCandidateSource(null);
toast.error("Araç seçimi başarısız");
toast.error(t("search.candidateSelectFailed"));
} finally {
setSelectLoading(false);
}
@@ -488,11 +509,9 @@ function SearchPage() {
</div>
<div className="min-w-0">
<h2 className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
Şase Arama
{t("search.title")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
Araç şase numarasını girerek yedek parça kataloğuna erişin
</p>
<p className="mt-1 text-sm text-muted-foreground">{t("search.subtitle")}</p>
</div>
</header>
@@ -500,11 +519,15 @@ function SearchPage() {
{/* Form */}
<form onSubmit={handleSearch} className="space-y-4">
{/* Input */}
<Label htmlFor="vin" className="sr-only">
{t("search.inputLabel")}
</Label>
<div className="relative">
<VinBrandIcon vin={vin} className="absolute left-4 top-1/2 -translate-y-1/2" />
<Input
id="vin"
ref={inputRef}
placeholder="Şase numarasını girin (17 karakter)"
placeholder={t("search.inputPlaceholder")}
value={vin}
onChange={(e) => handleVinChange(e.target.value)}
onFocus={() => {
@@ -556,14 +579,16 @@ function SearchPage() {
{/* Counter + Example VIN */}
<div className="flex items-center justify-between text-sm">
<span className="tabular-nums text-muted-foreground">{vin.length}/17 karakter</span>
<span className="tabular-nums text-muted-foreground">
{t("search.counter", { count: vin.length })}
</span>
<button
type="button"
onClick={fillExampleVin}
data-faro-user-action-name="fill-example-vin"
className="text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
>
Örnek şase deneyin &rarr;
{t("search.tryExample")}
</button>
</div>
@@ -578,7 +603,7 @@ function SearchPage() {
) : (
<Search className="mr-2 size-4" />
)}
Şase Çöz
{t("search.submit")}
</Button>
{/* Error banner — the main submit button above doubles as the retry,
@@ -592,16 +617,16 @@ function SearchPage() {
>
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="font-medium text-destructive">Şase çözümlenemedi</p>
<p className="font-medium text-destructive">{t("search.errorTitle")}</p>
<p className="mt-1 text-sm text-destructive/90">
{error.includes("abone olun") ? (
<>
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
{t("search.errorSubscriptionPrefix")}{" "}
<Link
to="/dashboard/subscription"
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
>
abone olun
{t("search.subscribeLink")}
</Link>
.
</>
@@ -627,13 +652,11 @@ function SearchPage() {
) : (
<Send className="mr-2 size-4" />
)}
Şase no doğru, sistem yöneticisine gönder
{t("search.reportVin")}
</Button>
)}
{reportSent && (
<p className="text-center text-sm text-muted-foreground">
Bildirim gönderildi. En kısa sürede incelenecektir.
</p>
<p className="text-center text-sm text-muted-foreground">{t("search.reportSent")}</p>
)}
</form>
</div>
@@ -642,7 +665,7 @@ function SearchPage() {
{previewLoading && (
<div className="flex items-center justify-center gap-3 rounded-2xl border border-border bg-background p-6">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Araç bilgileri alınıyor...</span>
<span className="text-sm text-muted-foreground">{t("search.previewLoading")}</span>
</div>
)}
@@ -665,15 +688,13 @@ function SearchPage() {
variant="default"
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
>
Araç tanımlandı
{t("search.vehicleIdentified")}
</Badge>
</div>
</div>
</div>
<Separator className="my-4 bg-border" />
<p className="text-sm text-muted-foreground">
Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.
</p>
<p className="text-sm text-muted-foreground">{t("search.previewHint")}</p>
</div>
)}
@@ -684,22 +705,27 @@ function SearchPage() {
<div className="flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
<h3 className="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight">
Son Aramalar
{t("search.recent")}
</h3>
</div>
<Link
to="/dashboard/history"
className="text-sm text-muted-foreground transition hover:text-foreground"
>
Tümünü Gör &rarr;
{t("search.seeAll")}
</Link>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{history.slice(0, 6).map((v: any, idx: number) => (
{history.map((v, idx) => (
<button
key={v.id}
type="button"
aria-label={t("search.historyAria", {
brand: v.brandName,
model: v.model ?? "",
vin: v.vin,
})}
onClick={() => {
querySourceRef.current = "history";
const ageDays = v.lastAccessedAt
@@ -711,8 +737,7 @@ function SearchPage() {
position: idx,
age_days: ageDays,
});
setVin(v.vin);
inputRef.current?.focus();
navigate({ to: "/dashboard/vehicles/$id", params: { id: v.id } });
}}
data-faro-user-action-name="select-history-vin"
className="group flex items-center gap-4 rounded-2xl border border-border bg-background p-4 text-left transition-colors hover:bg-accent"
@@ -733,9 +758,7 @@ function SearchPage() {
))}
</div>
<p className="text-center text-xs text-muted-foreground">
Geçmiş aramayı tıklayın şase otomatik doldurulur
</p>
<p className="text-center text-xs text-muted-foreground">{t("search.openHint")}</p>
</div>
)}

View File

@@ -2,6 +2,7 @@ import { CredibilityStrip } from "@/components/credibility-strip";
import { StripeCheckoutButton } from "@/components/payment/stripe-checkout-button";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { api } from "@/lib/api-client";
import { useSession } from "@/lib/auth-client";
import { startAction } from "@/lib/faro";
import { useTranslation } from "@/lib/i18n";
import { BRAND_SKELETON_KEYS } from "@/lib/keys";
@@ -70,6 +71,42 @@ function BrandSelectorFallback() {
);
}
// If the dynamic-import for BrandSelector hangs past 8s, the skeleton on its
// own looks identical to a stalled UI. Surface a reload affordance so users
// can recover without thinking they're stuck.
function BrandSelectorFallbackWithTimeout() {
const [slow, setSlow] = useState(false);
useEffect(() => {
const id = setTimeout(() => setSlow(true), 8000);
return () => clearTimeout(id);
}, []);
return (
<div className="space-y-3">
<BrandSelectorFallback />
{slow && (
<div className="flex flex-col items-center gap-2 rounded-xl border border-border bg-muted/40 p-3 text-center">
<p className="text-xs leading-5 text-muted-foreground">
<SlowLoadHint />
</p>
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
<SlowLoadRetryLabel />
</Button>
</div>
)}
</div>
);
}
function SlowLoadHint() {
const { t } = useTranslation();
return <>{t("subscription.brandSelectorSlow")}</>;
}
function SlowLoadRetryLabel() {
const { t } = useTranslation();
return <>{t("common.reload")}</>;
}
interface SubscriptionSearch {
welcome?: string;
ref?: string | null;
@@ -174,11 +211,20 @@ function getDowngradePlan(currentPlanKey: string) {
type CheckoutStep = "plan" | "brands" | "payment" | "confirmation";
// LocalStorage key scoped to user + trial end-date — prevents a second user
// on the same browser from inheriting the first user's dismissal, and lets a
// re-issued trial re-show the banner (different endDate, different key).
function trialBannerKey(userId: string | null | undefined, endDate: string | null | undefined) {
return `trialBannerDismissed-${userId ?? "anon"}-${endDate ?? "none"}`;
}
export function SubscriptionPage() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const navigate = useNavigate();
const search = Route.useSearch();
const { data: session } = useSession();
const userId = session?.user?.id ?? null;
// ─── Search-param-driven flags (read once) ─────────────────────────────────
const [welcome] = useState(() => search.welcome === "1");
@@ -379,9 +425,9 @@ export function SubscriptionPage() {
useEffect(() => {
if (!subscription?.endDate) return;
const key = `trialBannerDismissed-${subscription.endDate}`;
const key = trialBannerKey(userId, subscription.endDate);
if (localStorage.getItem(key) === "true") setTrialBannerDismissed(true);
}, [subscription?.endDate]);
}, [subscription?.endDate, userId]);
useEffect(() => {
if (
@@ -432,6 +478,13 @@ export function SubscriptionPage() {
if (!planKey || !plans.some((p) => p.key === planKey)) return;
hasPreselectedRef.current = true;
localStorage.removeItem(PENDING_PLAN_KEY);
// Period chosen on the public pricing toggle (Phase 1) — apply if present.
const storedPeriod =
typeof window !== "undefined" ? localStorage.getItem("sase-pending-period") : null;
if (storedPeriod === "yearly" || storedPeriod === "monthly") {
setBillingPeriod(storedPeriod);
localStorage.removeItem("sase-pending-period");
}
capture("plan_selected", { plan: planKey, source: "marketing_preselect" });
setSelectedPlanKey(planKey);
setSelectedBrandIds([]);
@@ -519,7 +572,7 @@ export function SubscriptionPage() {
plan_name: subscription?.plan?.name,
billing_period: subscription?.billingPeriod,
});
localStorage.setItem(`trialBannerDismissed-${subscription?.endDate}`, "true");
localStorage.setItem(trialBannerKey(userId, subscription?.endDate), "true");
setTrialBannerDismissed(true);
}
@@ -691,7 +744,7 @@ export function SubscriptionPage() {
count: selectedPlan?.brandLimit ?? 0,
})}
</p>
<Suspense fallback={<BrandSelectorFallback />}>
<Suspense fallback={<BrandSelectorFallbackWithTimeout />}>
<BrandSelector
maxBrands={selectedPlan?.brandLimit ?? 1}
selectedBrandIds={selectedBrandIds}
@@ -717,6 +770,7 @@ export function SubscriptionPage() {
period={billingPeriod}
brandIds={selectedBrandIds}
totalAmount={totalAmount}
onStaleBrandsFiltered={setSelectedBrandIds}
/>
</StepCard>
)}
@@ -1040,7 +1094,11 @@ export function PlanGrid({
}) {
const { t } = useTranslation();
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div
role="radiogroup"
aria-label={t("subscription.steps.plan.title")}
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
{plans.map((plan) => {
const isSelected = selectedPlanKey === plan.key;
const price = billingPeriod === "monthly" ? plan.priceMonthly : plan.priceYearly;
@@ -1051,9 +1109,12 @@ export function PlanGrid({
key={plan.key}
data-faro-user-action-name={`select-plan-${plan.key}`}
onClick={() => !isCurrentPlan && onSelect(plan.key)}
aria-pressed={isSelected}
// biome-ignore lint/a11y/useSemanticElements: rich card UI; a native <input type="radio"> would force us to rebuild the visuals while losing focus/keyboard behavior we already get from <button>.
role="radio"
aria-checked={isSelected}
aria-label={t(`subscription.plans.${plan.key}.name`)}
disabled={isCurrentPlan}
className={`relative flex flex-col rounded-2xl border bg-background p-5 text-left transition-all ${
className={`relative flex flex-col rounded-2xl border bg-background p-5 text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
isCurrentPlan
? "cursor-not-allowed border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-950/20"
: isSelected
@@ -1186,11 +1247,13 @@ function PaymentMethodSection({
period,
brandIds,
totalAmount,
onStaleBrandsFiltered,
}: {
planKey: string;
period: "monthly" | "yearly";
brandIds: string[];
totalAmount: number;
onStaleBrandsFiltered?: (validIds: string[]) => void;
}) {
const { t } = useTranslation();
const isFullPlan = planKey === "full";
@@ -1200,6 +1263,23 @@ function PaymentMethodSection({
staleTime: 5 * 60 * 1000,
enabled: !isFullPlan && brandIds.length > 0,
});
// Strip any locally-cached brand IDs that no longer exist server-side.
// Without this, Stripe checkout would 4xx on a stale ID and the user would
// see "payment failed" with no obvious cause.
useEffect(() => {
if (isFullPlan || !brands || brandIds.length === 0 || !onStaleBrandsFiltered) return;
const valid = new Set(brands.map((b) => b.id));
const filtered = brandIds.filter((id) => valid.has(id));
if (filtered.length !== brandIds.length) {
capture("stale_brand_ids_filtered", {
original: brandIds.length,
kept: filtered.length,
});
onStaleBrandsFiltered(filtered);
}
}, [brands, brandIds, isFullPlan, onStaleBrandsFiltered]);
const selectedBrandNames = (brands ?? [])
.filter((b) => brandIds.includes(b.id))
.map((b) => b.name);
@@ -1330,6 +1410,11 @@ function StickyCta({
}
}
const disabledHint = ctaDisabled
? t("subscription.stickyCta.disabledHint", {
count: Math.max(0, requiredBrands - selectedBrandIds.length),
})
: null;
return (
<div className="fixed inset-x-0 bottom-0 z-40 animate-fade-in-up border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 pb-[env(safe-area-inset-bottom,0px)]">
<div className="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-3 px-6 py-3">
@@ -1344,7 +1429,14 @@ function StickyCta({
</span>
</span>
</div>
<Button size="lg" className="min-w-[200px]" onClick={onAdvance} disabled={ctaDisabled}>
<Button
size="lg"
className="min-w-[200px] disabled:cursor-not-allowed disabled:opacity-60"
onClick={onAdvance}
disabled={ctaDisabled}
title={disabledHint ?? undefined}
aria-disabled={ctaDisabled}
>
{ctaLabel}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
@@ -1719,7 +1811,7 @@ function ProvisioningView({
)}
{trialMutation.isError && (
<div className="flex flex-col items-center gap-3">
<p className="text-sm leading-5 text-red-600 dark:text-red-400">
<p className="text-sm leading-5 text-destructive">
{t("subscription.onboarding.error")}
</p>
<Button variant="outline" onClick={() => trialMutation.mutate()}>
@@ -1727,12 +1819,52 @@ function ProvisioningView({
</Button>
</div>
)}
<ProvisioningStuckHint trialMutation={trialMutation} />
</CardContent>
</Card>
</div>
);
}
// If trial provisioning hasn't resolved (success or error) within 30s, surface
// a hint with a retry + contact-support escape so the user isn't trapped
// staring at a spinner. Mirrors the modal-side guard in welcome-onboarding.
function ProvisioningStuckHint({ trialMutation }: { trialMutation: TrialMutation }) {
const { t } = useTranslation();
const [stuck, setStuck] = useState(false);
useEffect(() => {
if (trialMutation.isSuccess || trialMutation.isError) return;
const timer = setTimeout(() => setStuck(true), 30_000);
return () => clearTimeout(timer);
}, [trialMutation.isSuccess, trialMutation.isError]);
if (!stuck || trialMutation.isSuccess || trialMutation.isError) return null;
return (
<div className="flex flex-col items-center gap-3 rounded-xl border border-border bg-muted/40 p-4 text-center">
<p className="text-sm font-medium leading-5 text-foreground">
{t("subscription.onboarding.stillTryingTitle")}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{t("subscription.onboarding.stillTryingHint")}
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
capture("onboarding_support_clicked", { source: "subscription_page" });
import("@/lib/chatwoot").then((m) => m.openChatwoot());
}}
>
{t("subscription.onboarding.contactSupport")}
</Button>
<Button variant="ghost" size="sm" onClick={() => trialMutation.mutate()}>
{t("subscription.onboarding.retry")}
</Button>
</div>
</div>
);
}
function OnboardingCompletedView({
subscription,
navigate,
@@ -1756,7 +1888,7 @@ function OnboardingCompletedView({
{t("subscription.currentPlan")}
</span>
<Badge className="bg-brand text-brand-foreground">
{t("subscription.plans.full.name")}
{subscription?.plan?.name ?? t("subscription.plans.full.name")}
</Badge>
</div>
<div className="flex items-center justify-between">
@@ -1937,75 +2069,85 @@ export function FeatureMatrix() {
return (
<div>
<h3 className="mb-4 text-xl font-semibold leading-8">{t("subscription.featureMatrix")}</h3>
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="w-48 px-4 py-3 text-left font-semibold leading-5 text-muted-foreground" />
{plans.map((plan) => (
<th
key={plan.key}
className={`relative px-4 py-3 text-center align-bottom font-semibold leading-5 ${
plan.popular ? "text-primary" : "text-foreground"
}`}
>
<div className="flex flex-col items-center gap-1">
{plan.popular && (
<Badge className="px-1.5 py-0 text-[10px]">{t("subscription.popular")}</Badge>
)}
<span>{t(`subscription.plans.${plan.key}.name`)}</span>
<span className="text-xs font-normal text-muted-foreground">
{formatTRY(plan.priceMonthly)}
{t("common.perMonth")}
</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-b bg-muted/40">
<td className="px-4 py-3 font-medium leading-5 text-foreground">
{t("subscription.matrixBrandCount")}
</td>
{plans.map((plan) => (
<td
key={plan.key}
className="px-4 py-3 text-center font-semibold leading-5 text-foreground"
>
{plan.brandLimit === FULL_PLAN_BRAND_LIMIT
? t("subscription.brandCountAll")
: plan.brandLimit}
</td>
))}
</tr>
{ALL_FEATURES.map((feature, i) => (
<tr key={feature} className={i % 2 === 0 ? "bg-background" : "bg-muted/25"}>
<td className="px-4 py-3 font-medium leading-5 text-foreground">
{t(`subscription.features.${feature}`)}
</td>
{plans.map((plan) => {
const has = (plan.features as readonly string[]).includes(feature);
return (
<td key={plan.key} className="px-4 py-3 text-center leading-5">
{has ? (
<Check
className="mx-auto h-4 w-4 text-primary"
aria-label={t("common.yes")}
/>
) : (
<Minus
className="mx-auto h-4 w-4 text-muted-foreground/40"
aria-label={t("common.no")}
/>
{/* Right-fade gradient hints horizontal scroll on narrow viewports.
Sticky thead keeps plan names + prices in view while the rows scroll. */}
<div className="relative">
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-background">
<tr className="border-b bg-muted/50">
<th className="w-48 px-4 py-3 text-left font-semibold leading-5 text-muted-foreground" />
{plans.map((plan) => (
<th
key={plan.key}
className={`relative px-4 py-3 text-center align-bottom font-semibold leading-5 ${
plan.popular ? "text-primary" : "text-foreground"
}`}
>
<div className="flex flex-col items-center gap-1">
{plan.popular && (
<Badge className="px-1.5 py-0 text-[10px]">
{t("subscription.popular")}
</Badge>
)}
</td>
);
})}
<span>{t(`subscription.plans.${plan.key}.name`)}</span>
<span className="text-xs font-normal text-muted-foreground">
{formatTRY(plan.priceMonthly)}
{t("common.perMonth")}
</span>
</div>
</th>
))}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
<tr className="border-b bg-muted/40">
<td className="px-4 py-3 font-medium leading-5 text-foreground">
{t("subscription.matrixBrandCount")}
</td>
{plans.map((plan) => (
<td
key={plan.key}
className="px-4 py-3 text-center font-semibold leading-5 text-foreground"
>
{plan.brandLimit === FULL_PLAN_BRAND_LIMIT
? t("subscription.brandCountAll")
: plan.brandLimit}
</td>
))}
</tr>
{ALL_FEATURES.map((feature, i) => (
<tr key={feature} className={i % 2 === 0 ? "bg-background" : "bg-muted/25"}>
<td className="px-4 py-3 font-medium leading-5 text-foreground">
{t(`subscription.features.${feature}`)}
</td>
{plans.map((plan) => {
const has = (plan.features as readonly string[]).includes(feature);
return (
<td key={plan.key} className="px-4 py-3 text-center leading-5">
{has ? (
<Check
className="mx-auto h-4 w-4 text-primary"
aria-label={t("common.yes")}
/>
) : (
<Minus
className="mx-auto h-4 w-4 text-muted-foreground/40"
aria-label={t("common.no")}
/>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent sm:hidden"
/>
</div>
</div>
);

View File

@@ -5,6 +5,7 @@ import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
import { useCategoryParts } from "@/hooks/use-parts";
import { api } from "@/lib/api-client";
import { useTranslation } from "@/lib/i18n";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { cleanModelName } from "@/lib/vehicle";
import { Button, Skeleton } from "@sase/ui";
@@ -51,6 +52,7 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/categories_/$cate
});
function VehicleCategoryPage() {
const { t } = useTranslation();
const { id, categoryId } = Route.useParams();
const navigate = useNavigate();
const { data, isLoading, error, refetch, isFetching } = useCategoryParts(id, categoryId);
@@ -93,7 +95,7 @@ function VehicleCategoryPage() {
const cleanModel = cleanModelName(vehicle?.model);
const vehicleLabel = vehicle?.brandName
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
: "Araç";
: t("vehicle.title");
return (
<div className="space-y-4">
@@ -111,8 +113,8 @@ function VehicleCategoryPage() {
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
title={t("vehicle.back")}
aria-label={t("vehicle.back")}
data-faro-user-action-name="category-back"
>
<ArrowLeft className="h-4 w-4" />
@@ -123,7 +125,7 @@ function VehicleCategoryPage() {
(isLoading ? (
<span className="inline-block h-6 w-48 animate-pulse rounded-md bg-primary/10 align-middle" />
) : (
"Kategori Detayı"
t("vehicle.categoryDetail")
))}
</h1>
{data?.description && (
@@ -141,9 +143,9 @@ function VehicleCategoryPage() {
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
>
<div>
<p className="font-medium text-destructive">Kategori yüklenemedi</p>
<p className="font-medium text-destructive">{t("vehicle.categoryNotLoadedTitle")}</p>
<p className="mt-1 text-muted-foreground">
{error instanceof Error ? error.message : "Veriler yüklenirken bir hata oluştu."}
{error instanceof Error ? error.message : t("vehicle.errorHint")}
</p>
</div>
<Button
@@ -154,7 +156,7 @@ function VehicleCategoryPage() {
disabled={isFetching}
data-faro-user-action-name="category-retry"
>
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
{isFetching ? t("common.loading") : t("vehicle.retry")}
</Button>
</div>
)}
@@ -183,10 +185,8 @@ function VehicleCategoryPage() {
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
>
<div>
<p className="font-medium text-destructive">Katalog şu an yüklenemedi</p>
<p className="mt-1 text-muted-foreground">
Bu kategori tedarikçi katalogundan alınamadı. Lütfen birazdan tekrar deneyin.
</p>
<p className="font-medium text-destructive">{t("vehicle.catalogUnavailableTitle")}</p>
<p className="mt-1 text-muted-foreground">{t("vehicle.catalogUnavailableHint")}</p>
</div>
<Button
type="button"
@@ -196,7 +196,7 @@ function VehicleCategoryPage() {
disabled={isFetching}
data-faro-user-action-name="category-loaderror-retry"
>
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
{isFetching ? t("common.loading") : t("vehicle.retry")}
</Button>
</div>
) : (

View File

@@ -6,9 +6,12 @@ import { CategoryTree } from "@/components/categories/category-tree";
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
import { ApiError, api } from "@/lib/api-client";
import { setChatwootVehicle } from "@/lib/chatwoot";
import { clearChatwootVehicle, setChatwootVehicle } from "@/lib/chatwoot";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
import { cleanModelName } from "@/lib/vehicle";
import type { CategoryNode, Vehicle } from "@sase/shared";
import {
Accordion,
AccordionContent,
@@ -31,8 +34,12 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
component: VehicleDetailPage,
});
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function VehicleDetailPage() {
const { t } = useTranslation();
const { id } = Route.useParams();
const idValid = UUID_RE.test(id);
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
() => getUserSettings().categoryViewMode ?? "grid",
);
@@ -43,6 +50,7 @@ function VehicleDetailPage() {
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
setViewMode(mode);
setUserSetting("categoryViewMode", mode);
capture("category_view_changed", { mode, vehicle_id: id });
};
const router = useRouter();
@@ -65,8 +73,8 @@ function VehicleDetailPage() {
isFetching: vehicleFetching,
} = useQuery({
queryKey: ["vehicle", id],
queryFn: () => api.get<any>(`/vehicles/${id}`),
enabled: !!id,
queryFn: () => api.get<Vehicle>(`/vehicles/${id}`),
enabled: !!id && idValid,
// A missing vehicle (404) is final — don't burn retries before showing the
// not-found state. Transient errors still retry.
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
@@ -74,13 +82,13 @@ function VehicleDetailPage() {
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
queryKey: ["category-tree", id],
queryFn: () => api.get<any[]>(`/categories/tree/${id}`),
enabled: !!id,
queryFn: () => api.get<CategoryNode[]>(`/categories/tree/${id}`),
enabled: !!id && idValid,
});
const vehicleLabel = vehicle?.brandName
? `${vehicle.brandName}${cleanModelName(vehicle?.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
: "Araç";
const vehicleLabel: string = vehicle?.brandName
? `${vehicle.brandName}${cleanModelName(vehicle.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
: t("vehicle.title");
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
// so agents have the car context for part-compatibility questions.
@@ -94,8 +102,46 @@ function VehicleDetailPage() {
year: vehicle.year,
});
}
return () => clearChatwootVehicle();
}, [vehicle, id]);
// The vehicle id comes from the URL — guard the obvious "undefined" /
// "null" / malformed cases (e.g. landing handed us a decode response with no
// id) before they hit the backend and surface a raw uuid parse error.
if (!idValid) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={handleBack}
title={t("vehicle.back")}
aria-label={t("vehicle.back")}
className="shrink-0"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-xl font-bold">{t("vehicle.invalidLinkTitle")}</h2>
</div>
<div
role="alert"
className="flex flex-col items-start gap-3 rounded-lg border border-border bg-muted/30 p-5 text-sm"
>
<p className="text-muted-foreground">{t("vehicle.invalidLinkHint")}</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" onClick={() => navigate({ to: "/dashboard/search" })}>
{t("vehicle.goToSearch")}
</Button>
<Button type="button" size="sm" variant="ghost" onClick={handleBack}>
{t("vehicle.back")}
</Button>
</div>
</div>
</div>
);
}
if (vehicleLoading) {
return (
<div className="space-y-6">
@@ -135,13 +181,15 @@ function VehicleDetailPage() {
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
title={t("vehicle.back")}
aria-label={t("vehicle.back")}
data-faro-user-action-name="vehicle-back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-xl font-bold">{is404 ? "Araç bulunamadı" : "Araç yüklenemedi"}</h2>
<h2 className="text-xl font-bold">
{is404 ? t("vehicle.notFoundTitle") : t("vehicle.errorTitle")}
</h2>
</div>
<div
role="alert"
@@ -149,14 +197,10 @@ function VehicleDetailPage() {
>
<div>
<p className="font-medium text-destructive">
{is404 ? "Bu araç kaydı bulunamadı" : "Araç bilgileri yüklenemedi"}
{is404 ? t("vehicle.notFoundMsg") : t("vehicle.errorMsg")}
</p>
<p className="mt-1 text-muted-foreground">
{is404
? "Bağlantı geçersiz olabilir ya da kayıt kaldırılmış olabilir."
: vehicleErrorObj instanceof Error
? vehicleErrorObj.message
: "Veriler yüklenirken bir hata oluştu."}
{is404 ? t("vehicle.notFoundHint") : t("vehicle.errorHint")}
</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -169,11 +213,11 @@ function VehicleDetailPage() {
disabled={vehicleFetching}
data-faro-user-action-name="vehicle-retry"
>
{vehicleFetching ? "Yükleniyor…" : "Tekrar dene"}
{vehicleFetching ? t("common.loading") : t("vehicle.retry")}
</Button>
)}
<Button type="button" size="sm" variant="ghost" onClick={handleBack}>
Geri dön
{t("vehicle.back")}
</Button>
</div>
</div>
@@ -181,6 +225,10 @@ function VehicleDetailPage() {
);
}
// After the loading and error guards above, the query has resolved; this
// catches the edge case where the request succeeded with no payload.
if (!vehicle) return null;
return (
<div className="space-y-6">
<CategoryBreadcrumb vehicleId={id} vehicleLabel={vehicleLabel} />
@@ -191,23 +239,22 @@ function VehicleDetailPage() {
variant="ghost"
size="icon"
onClick={handleBack}
title="Geri dön"
aria-label="Geri dön"
title={t("vehicle.back")}
aria-label={t("vehicle.back")}
className="shrink-0"
data-faro-user-action-name="vehicle-back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-3">
{vehicle?.brandName && (
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
)}
<div>
<h2 className="text-2xl font-bold">
{vehicle?.brandName} {cleanModelName(vehicle?.model)}{" "}
{vehicle?.year && `(${vehicle.year})`}
</h2>
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
</div>
{vehicle?.brandName && (
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
)}
<div className="min-w-0 flex-1">
<h2 className="break-words font-[family-name:var(--font-display)] text-2xl font-bold leading-tight tracking-tight">
{vehicle?.brandName} {cleanModelName(vehicle?.model)}{" "}
{vehicle?.year && `(${vehicle.year})`}
</h2>
<p className="truncate font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
</div>
</div>
@@ -218,7 +265,7 @@ function VehicleDetailPage() {
<AccordionItem value="info" className="border-b-0">
<AccordionTrigger className="px-6 hover:no-underline">
<div className="flex flex-1 flex-col items-start gap-1.5 pr-3 text-left">
<span className="text-base font-semibold">Araç Bilgileri</span>
<span className="text-base font-semibold">{t("vehicle.info")}</span>
<VehicleSummary vehicle={vehicle} />
</div>
</AccordionTrigger>
@@ -233,7 +280,7 @@ function VehicleDetailPage() {
{/* Categories */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Yedek Parça Kategorileri</CardTitle>
<CardTitle className="text-base">{t("vehicle.categories")}</CardTitle>
{!searchActive && <CategoryViewToggle value={viewMode} onChange={changeViewMode} />}
</CardHeader>
<CardContent
@@ -244,21 +291,44 @@ function VehicleDetailPage() {
{/* Search box keeps its own padding when the columns view zeroes out
CardContent padding. */}
<div className={viewMode === "columns" && !searchActive ? "p-6 pb-0" : undefined}>
<CatalogSearch vehicleId={id} onActiveChange={setSearchActive} />
<CatalogSearch
vehicleId={id}
onActiveChange={(active) => {
setSearchActive(active);
if (active) capture("catalog_search_opened", { vehicle_id: id });
}}
/>
</div>
{!searchActive &&
(categoriesLoading ? (
<div className="space-y-2">
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-8 w-full" />
))}
viewMode === "grid" ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-24 w-full rounded-lg" />
))}
</div>
) : (
<div className="space-y-2">
{KEYS_8.map((__k) => (
<Skeleton key={__k} className="h-8 w-full" />
))}
</div>
)
) : !categoryTree || categoryTree.length === 0 ? (
<div className="rounded-lg border border-dashed border-border bg-muted/30 px-6 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{t("vehicle.noCategoriesTitle")}
</p>
<p className="mx-auto mt-1 max-w-md text-xs text-muted-foreground">
{t("vehicle.noCategoriesHint")}
</p>
</div>
) : viewMode === "grid" ? (
<CategoryGrid categories={categoryTree || []} vehicleId={id} hideFilter />
<CategoryGrid categories={categoryTree} vehicleId={id} hideFilter />
) : viewMode === "tree" ? (
<CategoryTree categories={categoryTree || []} vehicleId={id} />
<CategoryTree categories={categoryTree} vehicleId={id} />
) : (
<CategoryColumns categories={categoryTree || []} vehicleId={id} />
<CategoryColumns categories={categoryTree} vehicleId={id} />
))}
</CardContent>
</Card>
@@ -266,6 +336,45 @@ function VehicleDetailPage() {
);
}
// Source-specific shape of vehicle.rawData. The DB stores it as
// Record<string, unknown>; the per-source helpers below cast to this interface
// rather than reaching through `any`. Each field is optional because only one
// source populates any given branch.
interface PcatParam {
key?: string;
name?: string;
value?: unknown;
sortOrder?: number;
}
interface VinfoRecord {
values?: { description?: string; value?: string };
}
interface PrNrRecord {
values?: { col1?: string; col2?: string; col3?: string };
}
interface RawData {
// pl24
engineCode?: string;
driveType?: string;
raw?: {
segments?: {
vinfoBasic?: { records?: VinfoRecord[] };
prNr?: { records?: PrNrRecord[] };
};
};
// parts-catalogs
pcatCar?: { parameters?: PcatParam[] };
// emex
parsedOptions?: Record<string, string | undefined>;
// vin-api (flat fields)
engineModel?: string;
bodyClass?: string;
transmissionStyle?: string;
plantCountry?: string;
// misc — keep open for shape-specific lookups via index access
[k: string]: unknown;
}
/** Known PL24 raw labels (missing Turkish chars) → clean Turkish. Unknown
* labels pass through unchanged. */
const LABEL_OVERRIDES: Record<string, string> = {
@@ -280,25 +389,26 @@ const LABEL_OVERRIDES: Record<string, string> = {
const HEADER_LABELS = new Set(["model", "model yili", "model yılı"]);
/** Extract vinfoBasic records from rawData, dropping VIN and header dupes. */
function getVehicleAttributes(vehicle: any): Array<{ label: string; value: string }> {
const records = vehicle?.rawData?.raw?.segments?.vinfoBasic?.records;
function getVehicleAttributes(vehicle: Vehicle): Array<{ label: string; value: string }> {
const rd = vehicle.rawData as RawData | null;
const records = rd?.raw?.segments?.vinfoBasic?.records;
if (!Array.isArray(records)) return [];
const vinLower = (vehicle?.vin || "").toLowerCase();
const vinLower = (vehicle.vin || "").toLowerCase();
return records
.map((r: any) => ({
.map((r) => ({
label: (r.values?.description || "").trim(),
value: (r.values?.value || "").replace(/\r?\n/g, " ").trim(),
}))
.filter(
(attr: { label: string; value: string }) =>
(attr) =>
attr.label &&
attr.value &&
attr.value.toLowerCase() !== vinLower &&
!HEADER_LABELS.has(attr.label.toLowerCase()),
)
.map((attr: { label: string; value: string }) => ({
.map((attr) => ({
label: LABEL_OVERRIDES[attr.label] ?? attr.label,
value: attr.value,
}));
@@ -340,16 +450,17 @@ const PCAT_LABELS: Record<string, string> = {
"Car name": "Model",
};
function fromPcat(vehicle: any): Attr[] {
const params = vehicle?.rawData?.pcatCar?.parameters;
function fromPcat(vehicle: Vehicle): Attr[] {
const rd = vehicle.rawData as RawData | null;
const params = rd?.pcatCar?.parameters;
if (!Array.isArray(params)) return [];
return [...params]
.sort((a: any, b: any) => (a?.sortOrder ?? 999) - (b?.sortOrder ?? 999))
.map((p: any) => ({
label: PCAT_LABELS[p?.name] ?? String(p?.name ?? "").trim(),
.sort((a, b) => (a?.sortOrder ?? 999) - (b?.sortOrder ?? 999))
.map((p) => ({
label: (p?.name && PCAT_LABELS[p.name]) ?? String(p?.name ?? "").trim(),
value: String(p?.value ?? "").trim(),
}))
.filter((a: Attr) => Boolean(a.label && a.value));
.filter((a) => Boolean(a.label && a.value));
}
/** emex parsedOptions — bare codes; map the snake_case keys to Turkish. */
@@ -359,8 +470,9 @@ const EMEX_LABELS: Record<string, string> = {
vehicle_type: "Araç tipi",
};
function fromEmex(vehicle: any): Attr[] {
const opts = vehicle?.rawData?.parsedOptions;
function fromEmex(vehicle: Vehicle): Attr[] {
const rd = vehicle.rawData as RawData | null;
const opts = rd?.parsedOptions;
if (!opts || typeof opts !== "object") return [];
return Object.entries(EMEX_LABELS)
.map(([key, label]) => ({ label, value: String(opts[key] ?? "").trim() }))
@@ -375,8 +487,8 @@ const VINAPI_FIELDS: Array<[string, string]> = [
["plantCountry", "Üretim ülkesi"],
];
function fromVinApi(vehicle: any): Attr[] {
const rd = vehicle?.rawData;
function fromVinApi(vehicle: Vehicle): Attr[] {
const rd = vehicle.rawData as RawData | null;
if (!rd) return [];
return VINAPI_FIELDS.map(([key, label]) => ({
label,
@@ -385,49 +497,52 @@ function fromVinApi(vehicle: any): Attr[] {
}
/** Plain DB columns — last resort when no source payload is recognised. */
function getFallbackAttributes(vehicle: any): Attr[] {
function getFallbackAttributes(vehicle: Vehicle): Attr[] {
return [
{ label: "Marka", value: vehicle?.brandName },
{ label: "Model", value: vehicle?.model },
{ label: "Yıl", value: vehicle?.year },
{ label: "Motor", value: vehicle?.engine },
{ label: "Vites", value: vehicle?.transmission },
{ label: "Kasa", value: vehicle?.bodyType },
{ label: "Marka", value: vehicle.brandName },
{ label: "Model", value: vehicle.model },
{ label: "Yıl", value: vehicle.year },
{ label: "Motor", value: vehicle.engine },
{ label: "Vites", value: vehicle.transmission },
{ label: "Kasa", value: vehicle.bodyType },
].filter(isAttr);
}
/** Resolve the attribute list for the vehicle's decode source. Each catalog
* ships its detail in a different shape; surface the richest one available. */
function getDisplayAttributes(vehicle: any): Attr[] {
function getDisplayAttributes(vehicle: Vehicle): Attr[] {
const rd = vehicle.rawData as RawData | null;
const source: string = vehicle.source;
// PL24 — readable extras lead, then the decoded VIN codes.
const vinfo = getVehicleAttributes(vehicle);
if (vinfo.length > 0) {
const extras = [
{ label: "Motor", value: vehicle?.engine },
{ label: "Çekiş", value: vehicle?.rawData?.driveType },
{ label: "Kasa", value: vehicle?.bodyType },
{ label: "Motor", value: vehicle.engine },
{ label: "Çekiş", value: rd?.driveType },
{ label: "Kasa", value: vehicle.bodyType },
].filter(isAttr);
return [...extras, ...vinfo];
}
// parts-catalogs — structured parameter list.
if (vehicle?.source === "parts-catalogs") {
if (source === "parts-catalogs") {
const a = dropHeaderDupes(fromPcat(vehicle));
if (a.length > 0) return a;
}
// emex — lead with any readable DB columns, then the option codes.
if (vehicle?.source === "emex") {
if (source === "emex") {
const dbExtras = [
{ label: "Vites", value: vehicle?.transmission },
{ label: "Kasa", value: vehicle?.bodyType },
{ label: "Vites", value: vehicle.transmission },
{ label: "Kasa", value: vehicle.bodyType },
].filter(isAttr);
const a = dropHeaderDupes([...dbExtras, ...fromEmex(vehicle)]);
if (a.length > 0) return a;
}
// vin-api — flat readable fields.
if (vehicle?.source === "vin-api") {
if (source === "vin-api") {
const a = dropHeaderDupes(fromVinApi(vehicle));
if (a.length > 0) return a;
}
@@ -435,13 +550,12 @@ function getDisplayAttributes(vehicle: any): Attr[] {
return dropHeaderDupes(getFallbackAttributes(vehicle));
}
function VehicleAttributes({ vehicle }: { vehicle: any }) {
function VehicleAttributes({ vehicle }: { vehicle: Vehicle }) {
const { t } = useTranslation();
const attrs = getDisplayAttributes(vehicle);
if (attrs.length === 0) {
return (
<p className="text-sm text-muted-foreground">Bu araç için ayrıntı bilgisi bulunamadı.</p>
);
return <p className="text-sm text-muted-foreground">{t("vehicle.noAttrs")}</p>;
}
return (
@@ -465,24 +579,23 @@ function VehicleAttributes({ vehicle }: { vehicle: any }) {
}
/** The engine code lives in a different spot per decode source. */
function getEngineCode(vehicle: any): string | undefined {
const rd = vehicle?.rawData;
function getEngineCode(vehicle: Vehicle): string | undefined {
const rd = vehicle.rawData as RawData | null;
if (rd?.engineCode) return rd.engineCode; // pl24
if (rd?.parsedOptions?.engine_type) return rd.parsedOptions.engine_type; // emex
if (rd?.engineModel) return rd.engineModel; // vin-api
// pcat — the "engine" parameter.
const param = rd?.pcatCar?.parameters?.find(
(p: any) => p?.key === "engine" || p?.name === "Engine",
);
const param = rd?.pcatCar?.parameters?.find((p) => p?.key === "engine" || p?.name === "Engine");
return param?.value ? String(param.value) : undefined;
}
/** Compact at-a-glance fields shown on the collapsed Araç Bilgileri header. */
function VehicleSummary({ vehicle }: { vehicle: any }) {
function VehicleSummary({ vehicle }: { vehicle: Vehicle }) {
const { t } = useTranslation();
const fields = [
{ label: "Model", value: cleanModelName(vehicle?.model) },
{ label: "Model yılı", value: vehicle?.year },
{ label: "Motor kodu", value: getEngineCode(vehicle) },
{ label: t("vehicle.labelModel"), value: cleanModelName(vehicle.model) },
{ label: t("vehicle.labelYear"), value: vehicle.year },
{ label: t("vehicle.labelEngineCode"), value: getEngineCode(vehicle) },
].filter((f) => f.value != null && f.value !== "");
if (fields.length === 0) return null;
@@ -512,30 +625,37 @@ interface Equipment {
/** Decode the prNr (PR-code / equipment) segment — PL24 already ships a
* readable description per code, so this is the equipment "dictionary". */
function getEquipment(vehicle: any): Equipment[] {
const records = vehicle?.rawData?.raw?.segments?.prNr?.records;
function getEquipment(vehicle: Vehicle): Equipment[] {
const rd = vehicle.rawData as RawData | null;
const records = rd?.raw?.segments?.prNr?.records;
if (!Array.isArray(records)) return [];
return records
.map((r: any) => ({
.map((r) => ({
code: (r.values?.col2 || "").trim(),
group: cleanText(r.values?.col1 || ""),
desc: cleanText(r.values?.col3 || ""),
}))
.filter((e: Equipment) => e.code || e.desc);
.filter((e) => e.code || e.desc);
}
/** Full decoded equipment / PR-code list, shown inside the expanded card. */
function VehicleEquipment({ vehicle }: { vehicle: any }) {
const EQUIPMENT_PREVIEW = 8;
function VehicleEquipment({ vehicle }: { vehicle: Vehicle }) {
const { t } = useTranslation();
const items = getEquipment(vehicle);
const [expanded, setExpanded] = useState(false);
if (items.length === 0) return null;
const hasMore = items.length > EQUIPMENT_PREVIEW;
const visible = expanded || !hasMore ? items : items.slice(0, EQUIPMENT_PREVIEW);
return (
<div className="mt-6 border-t border-border pt-4">
<p className="mb-3 text-xs font-semibold text-muted-foreground">
Donanım kodları ({items.length})
{t("vehicle.equipmentTitle", { count: items.length })}
</p>
<ul className="grid max-h-80 grid-cols-1 gap-x-6 gap-y-2 overflow-y-auto pr-1 sm:grid-cols-2">
{items.map((it, i) => (
<ul className="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
{visible.map((it, i) => (
<li key={`${it.code}-${i}`} className="flex items-baseline gap-2">
{it.code && (
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] leading-tight text-muted-foreground">
@@ -546,6 +666,17 @@ function VehicleEquipment({ vehicle }: { vehicle: any }) {
</li>
))}
</ul>
{hasMore && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setExpanded((v) => !v)}
className="mt-3 h-8 px-2 text-xs text-muted-foreground hover:text-foreground"
>
{expanded ? t("vehicle.showLess") : t("vehicle.showAll", { count: items.length })}
</Button>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,22 @@ import { CredibilityStrip } from "@/components/credibility-strip";
import { SiteHeader } from "@/components/site-header";
import { usePageMeta } from "@/hooks/use-page-meta";
import { useTranslation } from "@/lib/i18n";
import { capture } from "@/lib/posthog";
import { FULL_PLAN_BRAND_LIMIT, formatTRY } from "@sase/shared";
import { Button } from "@sase/ui";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
import { Badge } from "@sase/ui";
import { Link, createFileRoute } from "@tanstack/react-router";
import { Check, ChevronDown, Info } from "lucide-react";
import {
ArrowDown,
CalendarClock,
Check,
ChevronDown,
CreditCard,
Info,
RotateCcw,
} from "lucide-react";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/pricing")({
component: PricingPage,
@@ -52,8 +62,16 @@ export const plans = [
},
];
type BillingPeriod = "monthly" | "yearly";
function scrollToPlans() {
const el = document.getElementById("plans");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}
function PricingPage() {
const { t } = useTranslation();
const [period, setPeriod] = useState<BillingPeriod>("monthly");
usePageMeta({
title: t("pricing.pageTitle"),
@@ -61,6 +79,12 @@ function PricingPage() {
canonical: "https://sase.tr/pricing",
});
function changePeriod(next: BillingPeriod) {
if (next === period) return;
setPeriod(next);
capture("pricing_period_toggled", { period: next });
}
return (
<div className="min-h-screen">
<SiteHeader />
@@ -74,69 +98,57 @@ function PricingPage() {
<p className="mt-4 text-lg text-muted-foreground">{t("pricing.heroSubtitle")}</p>
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-sm text-muted-foreground">
{[
"pricing.trialFree",
"subscription.trustNoCard",
"subscription.trustCancelAnytime",
"subscription.trustRefund",
].map((k) => (
<span key={k} className="flex items-center gap-1.5">
<Check className="h-4 w-4 text-primary" />
{t(k)}
{ key: "pricing.trialFree", icon: CalendarClock },
{ key: "subscription.trustNoCard", icon: CreditCard },
{ key: "subscription.trustCancelAnytime", icon: RotateCcw },
{ key: "subscription.trustRefund", icon: Check },
].map(({ key, icon: Icon }) => (
<span key={key} className="flex items-center gap-1.5">
<Icon className="h-4 w-4 text-primary" />
{t(key)}
</span>
))}
</div>
{/* Hero CTAs — primary trial start + jump-to-plans secondary */}
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link to="/register">
<Button
size="lg"
onClick={() => capture("pricing_hero_cta_clicked", { target: "register" })}
>
{t("pricing.heroCta")}
</Button>
</Link>
<Button
variant="ghost"
size="lg"
onClick={() => {
capture("pricing_hero_cta_clicked", { target: "plans" });
scrollToPlans();
}}
>
{t("pricing.heroSecondary")}
<ArrowDown className="ml-1.5 h-4 w-4" />
</Button>
</div>
</div>
<PricingStatsStrip />
<div className="mx-auto mt-10 flex max-w-2xl items-start justify-center gap-2 rounded-lg border border-border bg-muted/30 px-4 py-2.5 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<span>{t("subscription.planHelper")}</span>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
{/* Billing period toggle */}
<div className="mt-8 flex justify-center" id="plans">
<BillingPeriodToggle value={period} onChange={changePeriod} />
</div>
<div className="mt-8 grid items-stretch gap-6 md:grid-cols-2 lg:grid-cols-4">
{plans.map((plan) => (
<Card
key={plan.key}
className={plan.popular ? "border-primary shadow-lg relative" : ""}
>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2">
{t("subscription.popular")}
</Badge>
)}
<CardHeader>
<CardTitle>{t(`subscription.plans.${plan.key}.name`)}</CardTitle>
<CardDescription>{t(`subscription.plans.${plan.key}.description`)}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<span className="text-3xl font-bold">{formatTRY(plan.priceMonthly)}</span>
<span className="text-muted-foreground">{t("common.perMonth")}</span>
</div>
<p className="text-sm text-muted-foreground">
{t("pricing.orYearly", { price: formatTRY(plan.priceYearly) })}{" "}
<span className="font-medium text-primary">{t("pricing.yearlySave")}</span>
</p>
<ul className="space-y-2 text-sm">
{plan.features.map((f) => {
const featureLabel =
f === "brandSelection"
? t(`subscription.features.${f}`, { count: plan.brandLimit })
: t(`subscription.features.${f}`);
return (
<li key={f} className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
{featureLabel}
</li>
);
})}
</ul>
<Link to="/register">
<Button className="w-full" variant={plan.popular ? "default" : "outline"}>
{t("pricing.getStarted")}
</Button>
</Link>
</CardContent>
</Card>
<PlanCard key={plan.key} plan={plan} period={period} />
))}
</div>
@@ -152,37 +164,263 @@ function PricingPage() {
);
}
// Stat row — verifiable product/policy facts only (catalog size, trial length,
// refund window). No customer-count claims, no testimonials.
function PricingStatsStrip() {
const { t } = useTranslation();
const items = [
{ value: "pricing.stats.brands.value", label: "pricing.stats.brands.label" },
{ value: "pricing.stats.parts.value", label: "pricing.stats.parts.label" },
{ value: "pricing.stats.trial.value", label: "pricing.stats.trial.label" },
{ value: "pricing.stats.refund.value", label: "pricing.stats.refund.label" },
];
return (
<div className="mx-auto mt-12 max-w-5xl">
<dl className="grid grid-cols-2 gap-y-6 rounded-2xl border border-border bg-muted/20 p-6 sm:grid-cols-4 sm:gap-y-0 sm:divide-x sm:divide-border">
{items.map((it) => (
<div
key={it.value}
className="flex flex-col items-center justify-center px-4 text-center"
>
<dt className="text-3xl font-bold leading-none tabular-nums text-foreground sm:text-4xl">
{t(it.value)}
</dt>
<dd className="mt-2 text-xs uppercase tracking-wider text-muted-foreground sm:text-sm sm:normal-case sm:tracking-normal">
{t(it.label)}
</dd>
</div>
))}
</dl>
</div>
);
}
function BillingPeriodToggle({
value,
onChange,
}: {
value: BillingPeriod;
onChange: (next: BillingPeriod) => void;
}) {
const { t } = useTranslation();
return (
<fieldset
className="inline-flex items-center gap-1 rounded-full border border-border bg-muted p-1"
aria-label={t("pricing.billingPeriodLabel")}
>
<legend className="sr-only">{t("pricing.billingPeriodLabel")}</legend>
{(["monthly", "yearly"] as const).map((opt) => (
<button
key={opt}
type="button"
aria-pressed={value === opt}
onClick={() => onChange(opt)}
className={`relative rounded-full px-5 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background ${
value === opt
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`pricing.billing.${opt}`)}
{opt === "yearly" && (
<span className="ml-2 inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-primary">
{t("pricing.yearlyBadge")}
</span>
)}
</button>
))}
</fieldset>
);
}
function PlanCard({
plan,
period,
}: {
plan: (typeof plans)[number];
period: BillingPeriod;
}) {
const { t } = useTranslation();
const isYearly = period === "yearly";
// Yearly: show effective monthly cost (yearly / 12), plus the savings vs
// paying 12 × monthly. Saves the user mental math and surfaces the deal.
const headlineAmount = isYearly ? Math.round(plan.priceYearly / 12) : plan.priceMonthly;
const fullYearMonthly = plan.priceMonthly * 12;
const yearlySavings = fullYearMonthly - plan.priceYearly;
const yearlySavingsPercent = Math.round((yearlySavings / fullYearMonthly) * 100);
return (
<Card
className={
plan.popular
? "relative flex flex-col border-primary bg-gradient-to-b from-primary/[0.04] to-background shadow-lg ring-1 ring-primary/30 lg:-mt-4 lg:mb-4 overflow-visible"
: "relative flex flex-col overflow-visible"
}
>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 shadow-sm">
{t("subscription.popular")}
</Badge>
)}
<CardHeader>
<CardTitle>{t(`subscription.plans.${plan.key}.name`)}</CardTitle>
<CardDescription>{t(`subscription.plans.${plan.key}.description`)}</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 flex-col gap-4">
<div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold tabular-nums">{formatTRY(headlineAmount)}</span>
<span className="text-muted-foreground">{t("common.perMonth")}</span>
</div>
{isYearly ? (
<p className="mt-1 text-sm text-muted-foreground">
{t("pricing.yearlyBilled", { price: formatTRY(plan.priceYearly) })}{" "}
<span className="font-medium text-primary">
{t("pricing.yearlySaveAmount", {
percent: yearlySavingsPercent,
amount: formatTRY(yearlySavings),
})}
</span>
</p>
) : (
<p className="mt-1 text-sm text-muted-foreground">
{t("pricing.orYearly", { price: formatTRY(plan.priceYearly) })}{" "}
<span className="font-medium text-primary">
{t("pricing.yearlySavePercent", { percent: yearlySavingsPercent })}
</span>
</p>
)}
</div>
<ul className="space-y-2 text-sm">
{plan.features.map((f) => {
const featureLabel =
f === "brandSelection"
? t(`subscription.features.${f}`, { count: plan.brandLimit })
: t(`subscription.features.${f}`);
return (
<li key={f} className="flex items-center gap-2">
<Check className="h-4 w-4 text-primary" />
{featureLabel}
</li>
);
})}
</ul>
<div className="mt-auto pt-2">
<Link
to="/register"
search={{ plan: plan.key }}
onClick={() => {
if (typeof window !== "undefined") {
try {
localStorage.setItem("sase-pending-period", period);
} catch {
// localStorage blocked (private mode); we degrade silently.
}
}
capture("pricing_plan_cta_clicked", {
plan: plan.key,
period,
popular: !!plan.popular,
});
}}
>
<Button className="w-full" variant={plan.popular ? "default" : "outline"}>
{t("pricing.getStarted")}
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}
export function PricingHowItWorks() {
const { t } = useTranslation();
const steps = [1, 2, 3] as const;
return (
<section className="mx-auto mt-20 max-w-5xl">
<section className="mx-auto mt-20 max-w-4xl">
<h2 className="text-center text-2xl font-bold">{t("pricing.how.title")}</h2>
<div className="mt-8 grid gap-6 sm:grid-cols-3">
{steps.map((i) => (
<div key={i} className="rounded-2xl border border-border bg-muted/20 p-6 text-center">
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-primary font-bold text-primary-foreground">
{i}
</div>
<h3 className="mt-4 font-semibold">{t(`pricing.how.s${i}t`)}</h3>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{t(`pricing.how.s${i}d`)}
</p>
</div>
))}
</div>
{/* Vertical timeline rail (md+) ditches the "3 equal cards" cliché.
Each step alternates sides; the rail connects them. */}
<ol className="relative mt-10 space-y-10 md:space-y-12">
<div
aria-hidden="true"
className="absolute left-5 top-2 bottom-2 hidden w-px bg-gradient-to-b from-primary/40 via-primary/20 to-transparent md:left-1/2 md:block md:-translate-x-1/2"
/>
{steps.map((i) => {
const isLeft = i % 2 === 1;
return (
<li
key={i}
className={`relative md:grid md:grid-cols-2 md:items-center md:gap-10 ${
isLeft ? "" : "md:[&>*:first-child]:order-2"
}`}
>
<div className={`md:px-2 ${isLeft ? "md:text-right" : "md:text-left"}`}>
<div className="mb-3 flex items-center gap-3 md:hidden">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
{i}
</div>
<h3 className="font-semibold leading-6">{t(`pricing.how.s${i}t`)}</h3>
</div>
<h3 className="hidden text-lg font-semibold leading-7 md:block">
{t(`pricing.how.s${i}t`)}
</h3>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{t(`pricing.how.s${i}d`)}
</p>
</div>
<div className="hidden md:flex md:items-center md:justify-center">
<div
aria-hidden="true"
className="flex h-12 w-12 items-center justify-center rounded-full border border-primary/30 bg-background text-base font-bold text-primary shadow-sm"
>
{i}
</div>
</div>
</li>
);
})}
</ol>
</section>
);
}
const FAQ_IDS = [1, 2, 3, 4, 5, 6] as const;
export function PricingFaq() {
const { t } = useTranslation();
const ids = [1, 2, 3, 4, 5, 6] as const;
// Inject FAQPage JSON-LD into <head> so Google can render rich-result Q/A
// blocks in SERP. Imperative head injection keeps us off React's `script`
// path and avoids dangerouslySetInnerHTML — content is i18n-sourced anyway,
// but this is also the cleanest way to render structured data.
useEffect(() => {
const payload = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: FAQ_IDS.map((i) => ({
"@type": "Question",
name: t(`pricing.faq.q${i}`),
acceptedAnswer: { "@type": "Answer", text: t(`pricing.faq.a${i}`) },
})),
};
const script = document.createElement("script");
script.type = "application/ld+json";
script.setAttribute("data-sase-faq", "1");
script.text = JSON.stringify(payload);
document.head.appendChild(script);
return () => {
script.remove();
};
}, [t]);
return (
<section className="mx-auto mt-20 max-w-3xl">
<h2 className="text-center text-2xl font-bold">{t("pricing.faq.title")}</h2>
<div className="mt-8 divide-y divide-border overflow-hidden rounded-2xl border border-border">
{ids.map((i) => (
{FAQ_IDS.map((i) => (
<details key={i} className="group">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-5 font-medium">
{t(`pricing.faq.q${i}`)}