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:
@@ -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 açı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 açılmış kategorilerin parçaları aranır.)
|
||||
{t("catalogSearch.noPartMatch")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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­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 SECURE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 →
|
||||
{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 →
|
||||
{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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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}`)}
|
||||
|
||||
Reference in New Issue
Block a user