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 { api } from "@/lib/api-client";
|
||||||
import { getCategoryIcon } from "@/lib/category-icons";
|
import { getCategoryIcon } from "@/lib/category-icons";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { Badge, Input } from "@sase/ui";
|
import { Badge, Input } from "@sase/ui";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
@@ -71,6 +72,7 @@ export function CatalogSearch({
|
|||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
onActiveChange?: (active: boolean) => void;
|
onActiveChange?: (active: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const debounced = useDebounced(input.trim(), 300);
|
const debounced = useDebounced(input.trim(), 300);
|
||||||
@@ -107,8 +109,8 @@ export function CatalogSearch({
|
|||||||
type="search"
|
type="search"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="Tüm katalogda ara — ör. fren balatası veya OEM kodu"
|
placeholder={t("catalogSearch.placeholder")}
|
||||||
aria-label="Tüm katalogda ara"
|
aria-label={t("catalogSearch.ariaLabel")}
|
||||||
className="pl-9 pr-9"
|
className="pl-9 pr-9"
|
||||||
/>
|
/>
|
||||||
{active && isFetching ? (
|
{active && isFetching ? (
|
||||||
@@ -117,7 +119,7 @@ export function CatalogSearch({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setInput("")}
|
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"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -129,33 +131,33 @@ export function CatalogSearch({
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{data?.treeIncomplete && (
|
{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">
|
<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" />
|
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-500" />
|
||||||
<span>
|
<span>{t("catalogSearch.treeIncomplete")}</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>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{nothing ? (
|
{nothing ? (
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Section 1 — leaf categories */}
|
{/* Section 1 — leaf categories */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<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>
|
</h3>
|
||||||
{categories.length === 0 ? (
|
{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">
|
<ul className="space-y-1.5">
|
||||||
{categories.map((c) => {
|
{categories.map((c) => {
|
||||||
@@ -179,7 +181,9 @@ export function CatalogSearch({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="flex-shrink-0 text-[11px]"
|
className="flex-shrink-0 text-[11px]"
|
||||||
>
|
>
|
||||||
{c.matchingPartCount} parça
|
{t("catalogSearch.partsCount", {
|
||||||
|
count: c.matchingPartCount,
|
||||||
|
})}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -197,11 +201,11 @@ export function CatalogSearch({
|
|||||||
{/* Section 2 — parts (OEM) */}
|
{/* Section 2 — parts (OEM) */}
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<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>
|
</h3>
|
||||||
{parts.length === 0 ? (
|
{parts.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { openChatwoot } from "@/lib/chatwoot";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { capture } from "@/lib/posthog";
|
import { capture } from "@/lib/posthog";
|
||||||
import { getUserSettings } from "@/lib/user-settings";
|
import { getUserSettings } from "@/lib/user-settings";
|
||||||
@@ -56,12 +57,19 @@ export function WelcomeOnboardingModal({
|
|||||||
refCode: string | null;
|
refCode: string | null;
|
||||||
onFinished: () => void;
|
onFinished: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t, locale } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [phase, setPhase] = useState<"provisioning" | "completed">("provisioning");
|
const [phase, setPhase] = useState<"provisioning" | "completed">("provisioning");
|
||||||
const [animationEnded, setAnimationEnded] = useState(false);
|
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 provisionedRef = useRef(false);
|
||||||
const refAppliedRef = 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 [isDark] = useState(() => {
|
||||||
const theme = getUserSettings().theme ?? "dark";
|
const theme = getUserSettings().theme ?? "dark";
|
||||||
return theme === "system"
|
return theme === "system"
|
||||||
@@ -78,8 +86,14 @@ export function WelcomeOnboardingModal({
|
|||||||
|
|
||||||
const trialMutation = useMutation({
|
const trialMutation = useMutation({
|
||||||
mutationFn: () => api.post("/subscriptions/trial"),
|
mutationFn: () => api.post("/subscriptions/trial"),
|
||||||
onSuccess: () => {},
|
onError: (err) => {
|
||||||
onError: () => {},
|
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,
|
// Apply a carried-over referral code once (covers the Google OAuth signup path,
|
||||||
@@ -103,12 +117,40 @@ export function WelcomeOnboardingModal({
|
|||||||
trialMutation.mutate();
|
trialMutation.mutate();
|
||||||
}, [subData, eligibleForTrial, onFinished, 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(() => {
|
useEffect(() => {
|
||||||
|
const start = Date.now();
|
||||||
const timer = setTimeout(() => setAnimationEnded(true), 7500);
|
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.
|
// Move to the completed state once the animation finished AND the trial provisioned.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase !== "provisioning") return;
|
if (phase !== "provisioning") return;
|
||||||
@@ -120,25 +162,37 @@ export function WelcomeOnboardingModal({
|
|||||||
});
|
});
|
||||||
}, [phase, animationEnded, trialMutation.isSuccess, queryClient]);
|
}, [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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open
|
open
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
// Only the completed-state CTA / Esc may dismiss; ignore closes mid-provisioning.
|
// Completed or escape-hatch may dismiss; everything else ignores closes.
|
||||||
if (!open && phase === "completed") onFinished();
|
if (!open && canDismiss) onFinished();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<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) => {
|
onInteractOutside={(e) => {
|
||||||
if (phase !== "completed") e.preventDefault();
|
if (!canDismiss) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
onEscapeKeyDown={(e) => {
|
onEscapeKeyDown={(e) => {
|
||||||
if (phase !== "completed") e.preventDefault();
|
if (!canDismiss) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{phase === "provisioning" ? (
|
{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">
|
<DialogHeader className="items-center">
|
||||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||||
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
|
<Sparkles className="h-6 w-6 animate-pulse text-brand" />
|
||||||
@@ -181,9 +235,23 @@ export function WelcomeOnboardingModal({
|
|||||||
{t("subscription.onboarding.step4")}...
|
{t("subscription.onboarding.step4")}...
|
||||||
</div>
|
</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">
|
<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")}
|
{t("subscription.onboarding.error")}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" onClick={() => trialMutation.mutate()}>
|
<Button variant="outline" onClick={() => trialMutation.mutate()}>
|
||||||
@@ -191,6 +259,38 @@ export function WelcomeOnboardingModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-6 py-4">
|
<div className="flex flex-col items-center gap-6 py-4">
|
||||||
@@ -209,7 +309,7 @@ export function WelcomeOnboardingModal({
|
|||||||
{t("subscription.currentPlan")}
|
{t("subscription.currentPlan")}
|
||||||
</span>
|
</span>
|
||||||
<Badge className="bg-brand text-brand-foreground">
|
<Badge className="bg-brand text-brand-foreground">
|
||||||
{t("subscription.plans.full.name")}
|
{t(`subscription.plans.${subscription?.plan?.key ?? "full"}.name`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -226,7 +326,9 @@ export function WelcomeOnboardingModal({
|
|||||||
{t("subscription.endDate")}
|
{t("subscription.endDate")}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium leading-5">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -241,9 +343,13 @@ export function WelcomeOnboardingModal({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
ref={completeCtaRef}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="bg-brand text-brand-foreground hover:bg-brand/90"
|
className="bg-brand text-brand-foreground hover:bg-brand/90"
|
||||||
onClick={onFinished}
|
onClick={() => {
|
||||||
|
capture("onboarding_completed", { source: "welcome_onboarding" });
|
||||||
|
onFinished();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("subscription.onboarding.startSearching")}
|
{t("subscription.onboarding.startSearching")}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { toast } from "@/lib/toast";
|
|||||||
import { formatTRY } from "@sase/shared";
|
import { formatTRY } from "@sase/shared";
|
||||||
import { Button } from "@sase/ui";
|
import { Button } from "@sase/ui";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { Clock, CreditCard, Lock } from "lucide-react";
|
import { AlertCircle, CreditCard, Loader2, Lock } from "lucide-react";
|
||||||
|
|
||||||
interface StripeCheckoutButtonProps {
|
interface StripeCheckoutButtonProps {
|
||||||
planKey: string;
|
planKey: string;
|
||||||
@@ -89,7 +89,7 @@ export function StripeCheckoutButton({
|
|||||||
>
|
>
|
||||||
{mutation.isPending ? (
|
{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")}
|
{t("payment.paying")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -99,6 +99,41 @@ export function StripeCheckoutButton({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { Badge } from "@sase/ui";
|
|||||||
import { Skeleton } from "@sase/ui";
|
import { Skeleton } from "@sase/ui";
|
||||||
import { cn } from "@sase/ui";
|
import { cn } from "@sase/ui";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Check } from "lucide-react";
|
import { Check, Search, X } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
interface Brand {
|
interface Brand {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,39 +30,54 @@ export function BrandSelector({
|
|||||||
isFullPlan = false,
|
isFullPlan = false,
|
||||||
}: BrandSelectorProps) {
|
}: BrandSelectorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selected, setSelected] = useState<string[]>(selectedBrandIds);
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const { data: brands, isLoading } = useQuery({
|
const { data: brands, isLoading } = useQuery({
|
||||||
queryKey: ["brands"],
|
queryKey: ["brands"],
|
||||||
queryFn: () => api.get<Brand[]>("/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(() => {
|
useEffect(() => {
|
||||||
setSelected(selectedBrandIds);
|
onSelectionChangeRef.current = onSelectionChange;
|
||||||
}, [selectedBrandIds]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (isFullPlan && brands) {
|
if (!isFullPlan || !brands) return;
|
||||||
const allIds = brands.map((b) => b.id);
|
const allIds = brands.map((b) => b.id);
|
||||||
setSelected(allIds);
|
if (
|
||||||
onSelectionChange(allIds);
|
allIds.length === selectedBrandIds.length &&
|
||||||
|
allIds.every((id) => selectedBrandIds.includes(id))
|
||||||
|
) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}, [isFullPlan, brands, onSelectionChange]);
|
onSelectionChangeRef.current(allIds);
|
||||||
|
}, [isFullPlan, brands, selectedBrandIds]);
|
||||||
|
|
||||||
function toggleBrand(brandId: string) {
|
function toggleBrand(brandId: string) {
|
||||||
if (isFullPlan) return;
|
if (isFullPlan) return;
|
||||||
|
const isCurrentlySelected = selectedBrandIds.includes(brandId);
|
||||||
let next: string[];
|
let next: string[];
|
||||||
if (selected.includes(brandId)) {
|
if (isCurrentlySelected) {
|
||||||
next = selected.filter((id) => id !== brandId);
|
next = selectedBrandIds.filter((id) => id !== brandId);
|
||||||
} else {
|
} else {
|
||||||
if (selected.length >= maxBrands) return;
|
if (selectedBrandIds.length >= maxBrands) return;
|
||||||
next = [...selected, brandId];
|
next = [...selectedBrandIds, brandId];
|
||||||
}
|
}
|
||||||
setSelected(next);
|
|
||||||
onSelectionChange(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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
<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>;
|
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 (
|
return (
|
||||||
<div className="space-y-3">
|
<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>
|
<p className="text-sm font-medium">{t("subscription.selectBrands")}</p>
|
||||||
<Badge variant={isFullPlan ? "default" : "secondary"}>
|
<Badge variant={isFullPlan ? "default" : "secondary"} className="tabular-nums">
|
||||||
{isFullPlan
|
{isFullPlan
|
||||||
? t("subscription.allBrandsSelected")
|
? t("subscription.allBrandsSelected")
|
||||||
: `${selected.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
|
: `${selectedBrandIds.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMaxReached && (
|
{showSearch && (
|
||||||
<p className="text-xs text-amber-600">{t("subscription.maxBrandsReached")}</p>
|
<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">
|
{isMaxReached && (
|
||||||
{brands.map((brand) => {
|
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||||
const isSelected = selected.includes(brand.id) || isFullPlan;
|
{t("subscription.maxBrandsReached")}
|
||||||
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{filteredBrands.length === 0 ? (
|
||||||
<Card
|
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||||
key={brand.id}
|
{t("subscription.brandSearchNoMatch", { query })}
|
||||||
className={cn(
|
</p>
|
||||||
"cursor-pointer transition-all hover:shadow-md",
|
) : (
|
||||||
isSelected && "border-primary ring-2 ring-primary/20",
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||||
isDisabled && "cursor-not-allowed opacity-50",
|
{filteredBrands.map((brand) => {
|
||||||
isFullPlan && "cursor-default",
|
const isSelected = selectedBrandIds.includes(brand.id) || isFullPlan;
|
||||||
)}
|
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
|
||||||
onClick={() => !isDisabled && toggleBrand(brand.id)}
|
|
||||||
>
|
return (
|
||||||
<CardContent className="flex flex-col items-center justify-center p-4">
|
<Card
|
||||||
<div className="relative">
|
key={brand.id}
|
||||||
<CarBrandLogo
|
className={cn(
|
||||||
brandName={brand.name}
|
"cursor-pointer transition-all hover:border-primary/40",
|
||||||
logoUrl={brand.logoUrl}
|
isSelected && "border-primary ring-2 ring-primary/20",
|
||||||
size={48}
|
isDisabled && "cursor-not-allowed opacity-50",
|
||||||
className="mb-2"
|
isFullPlan && "cursor-default",
|
||||||
/>
|
)}
|
||||||
{isSelected && (
|
onClick={() => !isDisabled && toggleBrand(brand.id)}
|
||||||
<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" />
|
<CardContent className="flex flex-col items-center justify-center p-4">
|
||||||
</div>
|
<div className="relative">
|
||||||
)}
|
<CarBrandLogo
|
||||||
</div>
|
brandName={brand.name}
|
||||||
<span className="text-center text-sm font-medium">{brand.name}</span>
|
logoUrl={brand.logoUrl}
|
||||||
</CardContent>
|
size={48}
|
||||||
</Card>
|
className="mb-2"
|
||||||
);
|
/>
|
||||||
})}
|
{isSelected && (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { capture } from "@/lib/posthog";
|
import { capture } from "@/lib/posthog";
|
||||||
import { Button } from "@sase/ui";
|
import { Button } from "@sase/ui";
|
||||||
@@ -15,14 +16,77 @@ interface Subscription {
|
|||||||
endDate?: string;
|
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 {
|
type Tier = "soft" | "urgent" | "critical";
|
||||||
return `${DISMISS_STORAGE_PREFIX}-${endDate}`;
|
|
||||||
|
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() {
|
export function TrialUrgencyBanner() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const userId = session?.user?.id ?? null;
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const viewedRef = useRef(false);
|
const viewedRef = useRef(false);
|
||||||
@@ -41,8 +105,8 @@ export function TrialUrgencyBanner() {
|
|||||||
subscription?.status === "trial" && endDate
|
subscription?.status === "trial" && endDate
|
||||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||||
: null;
|
: null;
|
||||||
const visible = !isLoading && days !== null && days <= 7;
|
const visible = !isLoading && days !== null && days <= VISIBLE_AT_DAYS;
|
||||||
const dismissKey = endDate ? getDismissKey(endDate) : null;
|
const dismissKey = endDate ? trialBannerKey(userId, endDate) : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -56,30 +120,35 @@ export function TrialUrgencyBanner() {
|
|||||||
}
|
}
|
||||||
}, [mounted, dismissKey]);
|
}, [mounted, dismissKey]);
|
||||||
|
|
||||||
|
const tier: Tier | null = days !== null ? tierFor(days) : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || dismissed || viewedRef.current || days === null) return;
|
if (!visible || dismissed || viewedRef.current || days === null || !tier) return;
|
||||||
viewedRef.current = true;
|
viewedRef.current = true;
|
||||||
capture("trial_urgency_banner_viewed", {
|
capture("trial_urgency_banner_viewed", {
|
||||||
days_remaining: days,
|
days_remaining: days,
|
||||||
|
tier,
|
||||||
plan_name: subscription?.plan?.name,
|
plan_name: subscription?.plan?.name,
|
||||||
billing_period: subscription?.billingPeriod,
|
billing_period: subscription?.billingPeriod,
|
||||||
});
|
});
|
||||||
}, [visible, dismissed, days, subscription?.plan?.name, subscription?.billingPeriod]);
|
}, [visible, dismissed, days, tier, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||||
|
|
||||||
const handleCTAClick = useCallback(() => {
|
const handleCTAClick = useCallback(() => {
|
||||||
if (days === null) return;
|
if (days === null) return;
|
||||||
capture("trial_urgency_banner_cta_clicked", {
|
capture("trial_urgency_banner_cta_clicked", {
|
||||||
days_remaining: days,
|
days_remaining: days,
|
||||||
|
tier,
|
||||||
plan_name: subscription?.plan?.name,
|
plan_name: subscription?.plan?.name,
|
||||||
billing_period: subscription?.billingPeriod,
|
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 = () => {
|
const handleDismiss = () => {
|
||||||
capture("trial_urgency_banner_dismissed", {
|
capture("trial_urgency_banner_dismissed", {
|
||||||
days_remaining: days,
|
days_remaining: days,
|
||||||
|
tier,
|
||||||
plan_name: subscription?.plan?.name,
|
plan_name: subscription?.plan?.name,
|
||||||
billing_period: subscription?.billingPeriod,
|
billing_period: subscription?.billingPeriod,
|
||||||
});
|
});
|
||||||
@@ -87,41 +156,59 @@ export function TrialUrgencyBanner() {
|
|||||||
setDismissed(true);
|
setDismissed(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Progress bar reflects elapsed share of the trial — a number alone undersells
|
||||||
<div
|
// urgency at e.g. day 27/30, while the bar reads visually as "nearly done".
|
||||||
role="alert"
|
const elapsed = Math.max(0, Math.min(TRIAL_TOTAL_DAYS, TRIAL_TOTAL_DAYS - days));
|
||||||
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"
|
const elapsedPct = Math.round((elapsed / TRIAL_TOTAL_DAYS) * 100);
|
||||||
>
|
const titleKey =
|
||||||
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-400" />
|
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">
|
return (
|
||||||
<div>
|
<div role="alert" className={`flex flex-col gap-2 border-b px-4 py-3 sm:px-6 ${c.bar}`}>
|
||||||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
<div className="flex items-start gap-3 sm:items-center">
|
||||||
{t("subscription.trialBanner.title")}
|
<AlertTriangle className={`mt-0.5 size-5 shrink-0 sm:mt-0 ${c.icon}`} />
|
||||||
</p>
|
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
||||||
{t("subscription.trialBanner.description", { days })}
|
<div>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
|
<button
|
||||||
<Button
|
type="button"
|
||||||
size="sm"
|
onClick={handleDismiss}
|
||||||
className="bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors ${c.close}`}
|
||||||
>
|
aria-label={t("common.close")}
|
||||||
{t("subscription.trialBanner.cta")}
|
>
|
||||||
</Button>
|
<X className="size-4" />
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div
|
||||||
type="button"
|
className={`h-1 w-full overflow-hidden rounded-full ${c.track}`}
|
||||||
onClick={handleDismiss}
|
role="progressbar"
|
||||||
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"
|
tabIndex={-1}
|
||||||
aria-label={t("common.close")}
|
aria-valuenow={elapsedPct}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
aria-label={t("subscription.trialBanner.progressLabel", { percent: elapsedPct })}
|
||||||
>
|
>
|
||||||
<X className="size-4" />
|
<div className={`h-full transition-all ${c.fill}`} style={{ width: `${elapsedPct}%` }} />
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface ChatwootApi {
|
|||||||
setCustomAttributes: (attrs: Record<string, string | number | boolean>) => void;
|
setCustomAttributes: (attrs: Record<string, string | number | boolean>) => void;
|
||||||
deleteCustomAttribute: (key: string) => void;
|
deleteCustomAttribute: (key: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
toggle: (state?: "open" | "close") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -117,3 +118,18 @@ export function setChatwootVehicle(vehicle: {
|
|||||||
window.$chatwoot?.setCustomAttributes(attrs);
|
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 { createRoot } from "react-dom/client";
|
||||||
import { initChatwoot } from "./lib/chatwoot";
|
import { initChatwoot } from "./lib/chatwoot";
|
||||||
import { initFaro } from "./lib/faro";
|
import { initFaro } from "./lib/faro";
|
||||||
|
import { initLocale } from "./lib/i18n";
|
||||||
import { initMetaPixel } from "./lib/meta-pixel";
|
import { initMetaPixel } from "./lib/meta-pixel";
|
||||||
import { initPostHog } from "./lib/posthog";
|
import { initPostHog } from "./lib/posthog";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import "./globals.css";
|
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)
|
// Initialize frontend observability (async, non-blocking)
|
||||||
initFaro();
|
initFaro();
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@
|
|||||||
"perMonth": "/mo",
|
"perMonth": "/mo",
|
||||||
"perYear": "/yr",
|
"perYear": "/yr",
|
||||||
"login": "Log In",
|
"login": "Log In",
|
||||||
"register": "Sign Up"
|
"register": "Sign Up",
|
||||||
|
"reload": "Reload page"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"skipToContent": "Skip to content"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -195,8 +199,10 @@
|
|||||||
"stickyCta": {
|
"stickyCta": {
|
||||||
"toBrands": "Choose brands",
|
"toBrands": "Choose brands",
|
||||||
"toPayment": "Go to payment",
|
"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.",
|
"noSubscription": "You don't have an active subscription.",
|
||||||
"choosePlan": "Choose Plan",
|
"choosePlan": "Choose Plan",
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
@@ -231,6 +237,8 @@
|
|||||||
"brandsSelected": "brands selected",
|
"brandsSelected": "brands selected",
|
||||||
"allBrandsSelected": "All selected",
|
"allBrandsSelected": "All selected",
|
||||||
"maxBrandsReached": "Maximum number of brands reached.",
|
"maxBrandsReached": "Maximum number of brands reached.",
|
||||||
|
"brandSearchPlaceholder": "Search brand — e.g. Volkswagen",
|
||||||
|
"brandSearchNoMatch": "No brand matches \"{query}\".",
|
||||||
"features": {
|
"features": {
|
||||||
"vinSearch": "Unlimited VIN search",
|
"vinSearch": "Unlimited VIN search",
|
||||||
"partsCatalog": "Parts catalog",
|
"partsCatalog": "Parts catalog",
|
||||||
@@ -288,7 +296,12 @@
|
|||||||
"trialDuration": "30-Day Trial",
|
"trialDuration": "30-Day Trial",
|
||||||
"startSearching": "Start Searching",
|
"startSearching": "Start Searching",
|
||||||
"error": "An error occurred while starting your trial.",
|
"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": {
|
"downgradeOffer": {
|
||||||
"title": "Downgrade instead of cancelling",
|
"title": "Downgrade instead of cancelling",
|
||||||
@@ -309,9 +322,12 @@
|
|||||||
"paymentTrustKVKK": "KVKK compliant",
|
"paymentTrustKVKK": "KVKK compliant",
|
||||||
"trialDaysLeft": "{days} days left",
|
"trialDaysLeft": "{days} days left",
|
||||||
"trialBanner": {
|
"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.",
|
"description": "{days} days left. Upgrade now to continue accessing all features.",
|
||||||
"cta": "Upgrade Plan"
|
"cta": "Upgrade Plan",
|
||||||
|
"progressLabel": "{percent}% of trial used"
|
||||||
},
|
},
|
||||||
"trialProgress": {
|
"trialProgress": {
|
||||||
"title": "{days} days left in your Full Package trial",
|
"title": "{days} days left in your Full Package trial",
|
||||||
@@ -359,7 +375,9 @@
|
|||||||
"cancelled": "Payment cancelled. You can try again.",
|
"cancelled": "Payment cancelled. You can try again.",
|
||||||
"verifyingTitle": "Verifying your payment",
|
"verifyingTitle": "Verifying your payment",
|
||||||
"verifyingDescription": "Your subscription will activate as soon as Stripe confirms. Stay on this page for a few seconds.",
|
"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": {
|
"billing": {
|
||||||
@@ -377,6 +395,7 @@
|
|||||||
"downloadReceipt": "Download Receipt",
|
"downloadReceipt": "Download Receipt",
|
||||||
"viewReceipt": "Your receipt",
|
"viewReceipt": "Your receipt",
|
||||||
"receiptUnavailable": "No receipt available for this payment.",
|
"receiptUnavailable": "No receipt available for this payment.",
|
||||||
|
"failedHint": "Card declined. Contact your bank or retry with a different card.",
|
||||||
"filterByStatus": "Filter by Status",
|
"filterByStatus": "Filter by Status",
|
||||||
"filterByMethod": "Filter by Method",
|
"filterByMethod": "Filter by Method",
|
||||||
"statusLabels": {
|
"statusLabels": {
|
||||||
@@ -517,7 +536,24 @@
|
|||||||
"popular": "Popular",
|
"popular": "Popular",
|
||||||
"orYearly": "or {price}/year",
|
"orYearly": "or {price}/year",
|
||||||
"yearlySave": "· 2 months free",
|
"yearlySave": "· 2 months free",
|
||||||
|
"yearlySavePercent": "· save {percent}%",
|
||||||
|
"yearlySaveAmount": "save {percent}% — {amount}/year",
|
||||||
|
"yearlyBilled": "billed {price} per year",
|
||||||
"getStarted": "Start Free",
|
"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",
|
"pageTitle": "Pricing — Sase.tr | Chassis Search Plans",
|
||||||
"how": {
|
"how": {
|
||||||
"title": "How it works",
|
"title": "How it works",
|
||||||
@@ -551,5 +587,484 @@
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"root": "Root"
|
"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",
|
"perMonth": "/ay",
|
||||||
"perYear": "/yıl",
|
"perYear": "/yıl",
|
||||||
"login": "Giriş Yap",
|
"login": "Giriş Yap",
|
||||||
"register": "Kayıt Ol"
|
"register": "Kayıt Ol",
|
||||||
|
"reload": "Sayfayı yenile"
|
||||||
|
},
|
||||||
|
"a11y": {
|
||||||
|
"skipToContent": "İçeriğe atla"
|
||||||
},
|
},
|
||||||
"nav": {
|
"nav": {
|
||||||
"dashboard": "Gösterge Paneli",
|
"dashboard": "Gösterge Paneli",
|
||||||
@@ -195,8 +199,10 @@
|
|||||||
"stickyCta": {
|
"stickyCta": {
|
||||||
"toBrands": "Markaları seç",
|
"toBrands": "Markaları seç",
|
||||||
"toPayment": "Ödeme yöntemine geç",
|
"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.",
|
"noSubscription": "Aktif aboneliğiniz yok.",
|
||||||
"choosePlan": "Plan Seç",
|
"choosePlan": "Plan Seç",
|
||||||
"subscribe": "Abone Ol",
|
"subscribe": "Abone Ol",
|
||||||
@@ -231,6 +237,8 @@
|
|||||||
"brandsSelected": "marka seçildi",
|
"brandsSelected": "marka seçildi",
|
||||||
"allBrandsSelected": "Tümü seçildi",
|
"allBrandsSelected": "Tümü seçildi",
|
||||||
"maxBrandsReached": "Maksimum marka sayısına ulaştınız.",
|
"maxBrandsReached": "Maksimum marka sayısına ulaştınız.",
|
||||||
|
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
|
||||||
|
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
|
||||||
"features": {
|
"features": {
|
||||||
"vinSearch": "Sınırsız VIN arama",
|
"vinSearch": "Sınırsız VIN arama",
|
||||||
"partsCatalog": "Parça kataloğu",
|
"partsCatalog": "Parça kataloğu",
|
||||||
@@ -288,7 +296,12 @@
|
|||||||
"trialDuration": "30 Gün Deneme",
|
"trialDuration": "30 Gün Deneme",
|
||||||
"startSearching": "Şase Aramaya Başla",
|
"startSearching": "Şase Aramaya Başla",
|
||||||
"error": "Deneme başlatılırken bir hata oluştu.",
|
"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": {
|
"downgradeOffer": {
|
||||||
"title": "Planınızı düşürün, iptal etmeyin",
|
"title": "Planınızı düşürün, iptal etmeyin",
|
||||||
@@ -309,9 +322,12 @@
|
|||||||
"paymentTrustKVKK": "KVKK uyumlu",
|
"paymentTrustKVKK": "KVKK uyumlu",
|
||||||
"trialDaysLeft": "{days} gün kaldı",
|
"trialDaysLeft": "{days} gün kaldı",
|
||||||
"trialBanner": {
|
"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.",
|
"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": {
|
"trialProgress": {
|
||||||
"title": "Full Paket denemende {days} gün kaldı",
|
"title": "Full Paket denemende {days} gün kaldı",
|
||||||
@@ -359,7 +375,9 @@
|
|||||||
"cancelled": "Ödeme iptal edildi. Tekrar deneyebilirsin.",
|
"cancelled": "Ödeme iptal edildi. Tekrar deneyebilirsin.",
|
||||||
"verifyingTitle": "Ödemen doğrulanıyor",
|
"verifyingTitle": "Ödemen doğrulanıyor",
|
||||||
"verifyingDescription": "Stripe onayı geldiğinde aboneliğin otomatik aktifleşecek. Bu sayfayı kapatmadan birkaç saniye bekle.",
|
"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": {
|
"billing": {
|
||||||
@@ -377,6 +395,7 @@
|
|||||||
"downloadReceipt": "Dekontu İndir",
|
"downloadReceipt": "Dekontu İndir",
|
||||||
"viewReceipt": "Makbuzunuz",
|
"viewReceipt": "Makbuzunuz",
|
||||||
"receiptUnavailable": "Bu ödeme için makbuz bulunamadı.",
|
"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",
|
"filterByStatus": "Duruma Göre Filtrele",
|
||||||
"filterByMethod": "Yönteme Göre Filtrele",
|
"filterByMethod": "Yönteme Göre Filtrele",
|
||||||
"statusLabels": {
|
"statusLabels": {
|
||||||
@@ -517,7 +536,24 @@
|
|||||||
"popular": "Popüler",
|
"popular": "Popüler",
|
||||||
"orYearly": "veya {price}/yıl",
|
"orYearly": "veya {price}/yıl",
|
||||||
"yearlySave": "· 2 ay bedava",
|
"yearlySave": "· 2 ay bedava",
|
||||||
|
"yearlySavePercent": "· %{percent} tasarruf",
|
||||||
|
"yearlySaveAmount": "%{percent} tasarruf — yılda {amount} kazanç",
|
||||||
|
"yearlyBilled": "yılda {price} tek seferde",
|
||||||
"getStarted": "Ücretsiz Başla",
|
"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ı",
|
"pageTitle": "Fiyatlandırma — Sase.tr | Şase Sorgulama Planları",
|
||||||
"how": {
|
"how": {
|
||||||
"title": "Nasıl çalışır",
|
"title": "Nasıl çalışır",
|
||||||
@@ -551,5 +587,484 @@
|
|||||||
},
|
},
|
||||||
"categories": {
|
"categories": {
|
||||||
"root": "Kök"
|
"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 VIN_TEXT = "WVWZZZ1JZ3W...";
|
||||||
const SIDEBAR_ITEMS = ["Dashboard", "Şase Arama", "Katalog", "Şemalar", "Ayarlar"];
|
|
||||||
const CATEGORIES = ["Motor", "Şasi", "Elektrik", "Karoseri", "Fren"];
|
// All translatable copy threaded through the Remotion composition. Parent
|
||||||
const PARTS = [
|
// supplies labels from useTranslation(); falls back to TR if nothing is passed
|
||||||
{ code: "1J0 820 803F", name: "Klima Kompresörü" },
|
// (keeps the standalone Remotion Studio preview working).
|
||||||
{ code: "1J0 698 151G", name: "Fren Balatası" },
|
export interface DashboardDemoLabels {
|
||||||
{ code: "1J0 819 031A", name: "Kalorifer Motoru" },
|
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 ─────────────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -61,7 +82,7 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
transform: `translateX(${sidebarX}px)`,
|
transform: `translateX(${sidebarX}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SIDEBAR_ITEMS.map((item, i) => (
|
{labels.sidebar.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={item}
|
||||||
style={{
|
style={{
|
||||||
@@ -114,7 +135,7 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
color: typedText ? c.fg : c.mutedFg,
|
color: typedText ? c.fg : c.mutedFg,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{typedText || "Şase numarası girin..."}
|
{typedText || labels.vinPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
{/* Blinking cursor */}
|
{/* Blinking cursor */}
|
||||||
{frame > typeStart && charsToShow < VIN_TEXT.length && (
|
{frame > typeStart && charsToShow < VIN_TEXT.length && (
|
||||||
@@ -135,7 +156,10 @@ const VinInputScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Scene 2: Vehicle Info ──────────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -159,7 +183,7 @@ const VehicleInfoScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SIDEBAR_ITEMS.map((item, i) => (
|
{labels.sidebar.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={item}
|
||||||
style={{
|
style={{
|
||||||
@@ -269,7 +293,10 @@ const VehicleInfoScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Scene 3: Category Selection ────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -285,7 +312,7 @@ const CategoryScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SIDEBAR_ITEMS.map((item, i) => (
|
{labels.sidebar.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={item}
|
||||||
style={{
|
style={{
|
||||||
@@ -314,10 +341,10 @@ const CategoryScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Kategori Seçin
|
{labels.categorySelectTitle}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
{CATEGORIES.map((cat, i) => {
|
{labels.categories.map((cat, i) => {
|
||||||
const delay = i * 5;
|
const delay = i * 5;
|
||||||
const itemSpring = spring({
|
const itemSpring = spring({
|
||||||
frame: frame - delay,
|
frame: frame - delay,
|
||||||
@@ -364,7 +391,10 @@ const CategoryScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Scene 4: Schema View ───────────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -387,7 +417,7 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SIDEBAR_ITEMS.map((item, i) => (
|
{labels.sidebar.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={item}
|
||||||
style={{
|
style={{
|
||||||
@@ -416,7 +446,7 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Motor — Şema Görünümü
|
{labels.schemaViewTitle}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -515,7 +545,10 @@ const SchemaViewScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Scene 5: OEM Codes ─────────────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -537,7 +570,7 @@ const OemCodesScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
padding: 12,
|
padding: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SIDEBAR_ITEMS.map((item, i) => (
|
{labels.sidebar.map((item, i) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={item}
|
||||||
style={{
|
style={{
|
||||||
@@ -566,10 +599,12 @@ const OemCodesScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
fontFamily: "system-ui, sans-serif",
|
fontFamily: "system-ui, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
OEM Parça Listesi
|
{labels.oemListTitle}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
<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 delay = i * 8;
|
||||||
const slideSpring = spring({
|
const slideSpring = spring({
|
||||||
frame: frame - delay,
|
frame: frame - delay,
|
||||||
@@ -634,25 +669,28 @@ const OemCodesScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Main Composition ───────────────────────────────────────────────────────
|
// ─── 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);
|
const c = getColors(isDark);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: c.bg }}>
|
<AbsoluteFill style={{ backgroundColor: c.bg }}>
|
||||||
<Sequence from={0} durationInFrames={90}>
|
<Sequence from={0} durationInFrames={90}>
|
||||||
<VinInputScene isDark={isDark} />
|
<VinInputScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Sequence from={90} durationInFrames={90}>
|
<Sequence from={90} durationInFrames={90}>
|
||||||
<VehicleInfoScene isDark={isDark} />
|
<VehicleInfoScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Sequence from={180} durationInFrames={90}>
|
<Sequence from={180} durationInFrames={90}>
|
||||||
<CategoryScene isDark={isDark} />
|
<CategoryScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Sequence from={270} durationInFrames={90}>
|
<Sequence from={270} durationInFrames={90}>
|
||||||
<SchemaViewScene isDark={isDark} />
|
<SchemaViewScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Sequence from={360} durationInFrames={90}>
|
<Sequence from={360} durationInFrames={90}>
|
||||||
<OemCodesScene isDark={isDark} />
|
<OemCodesScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,20 +23,57 @@ function getColors(isDark: boolean) {
|
|||||||
|
|
||||||
const STORE_NAME = "OtoYedekParca.co";
|
const STORE_NAME = "OtoYedekParca.co";
|
||||||
const VIN_DISPLAY = "WVWZZZ1JZ3W...";
|
const VIN_DISPLAY = "WVWZZZ1JZ3W...";
|
||||||
const PRODUCTS = [
|
|
||||||
{ name: "Fren Balatası", price: "450 TL", compatible: true },
|
export interface EcommerceDemoLabels {
|
||||||
{ name: "Klima Kompresörü", price: "1.200 TL", compatible: true },
|
productNames: [string, string, string, string, string, string];
|
||||||
{ name: "Amortisör", price: "680 TL", compatible: false },
|
searchPlaceholder: string;
|
||||||
{ name: "Yağ Filtresi", price: "120 TL", compatible: true },
|
preReturnBadge: string;
|
||||||
{ name: "Silecek Motoru", price: "350 TL", compatible: false },
|
addToCart: string;
|
||||||
{ name: "Ön Far", price: "890 TL", compatible: false },
|
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";
|
const FONT = "system-ui, sans-serif";
|
||||||
|
|
||||||
// ─── Scene 1: Storefront ─────────────────────────────────────────────────────
|
// ─── Scene 1: Storefront ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
const StorefrontScene: React.FC<{ isDark: boolean; labels: EcommerceDemoLabels }> = ({
|
||||||
|
isDark,
|
||||||
|
labels,
|
||||||
|
}) => {
|
||||||
const frame = useCurrentFrame();
|
const frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -106,7 +143,7 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
fontFamily: FONT,
|
fontFamily: FONT,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Parça ara...
|
{labels.searchPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Cart icon */}
|
{/* Cart icon */}
|
||||||
@@ -169,7 +206,7 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PRODUCTS.map((product, i) => {
|
{buildProducts(labels).map((product, i) => {
|
||||||
const delay = i * 5;
|
const delay = i * 5;
|
||||||
const itemSpring = spring({
|
const itemSpring = spring({
|
||||||
frame: frame - delay,
|
frame: frame - delay,
|
||||||
@@ -253,7 +290,7 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
%23 iade oranı
|
{labels.preReturnBadge}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,7 +300,10 @@ const StorefrontScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Scene 2: Widget Integration ────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -357,7 +397,7 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Parça ara...
|
{labels.searchPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -466,7 +506,7 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PRODUCTS.map((product) => (
|
{buildProducts(labels).map((product) => (
|
||||||
<div
|
<div
|
||||||
key={product.name}
|
key={product.name}
|
||||||
style={{
|
style={{
|
||||||
@@ -516,7 +556,10 @@ const WidgetIntegrationScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Scene 3: VIN Filter ────────────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -605,7 +648,7 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Parça ara...
|
{labels.searchPlaceholder}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -722,7 +765,7 @@ const VinFilterScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PRODUCTS.map((product, i) => {
|
{buildProducts(labels).map((product, i) => {
|
||||||
const isIncompatible = incompatibleIndices.includes(i);
|
const isIncompatible = incompatibleIndices.includes(i);
|
||||||
const cardOpacity = isIncompatible ? interpolate(filterProgress, [0, 1], [1, 0.15]) : 1;
|
const cardOpacity = isIncompatible ? interpolate(filterProgress, [0, 1], [1, 0.15]) : 1;
|
||||||
const borderColor = !isIncompatible && filterProgress > 0.5 ? c.emerald : c.border;
|
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 ───────────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -988,7 +1034,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Fren Balatası sepete eklendi
|
{labels.cartAddedToast}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1022,7 +1068,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
gap: 8,
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PRODUCTS.map((product, i) => {
|
{buildProducts(labels).map((product, i) => {
|
||||||
const isIncompatible = incompatibleIndices.includes(i);
|
const isIncompatible = incompatibleIndices.includes(i);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -1115,7 +1161,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Sepete Ekle
|
{labels.addToCart}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1140,7 +1186,7 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Dönüşüm: %94
|
{labels.conversionOverlay}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1150,15 +1196,18 @@ const AddToCartScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Scene 5: Stats Overlay ─────────────────────────────────────────────────
|
// ─── 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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ arrow: "↓", value: "%42", label: "İade oranı" },
|
{ arrow: "↓", value: "%42", label: labels.statReturnRate },
|
||||||
{ arrow: "↑", value: "%35", label: "Dönüşüm oranı" },
|
{ arrow: "↑", value: "%35", label: labels.statConversionRate },
|
||||||
{ arrow: "↑", value: "%98", label: "Müşteri memnuniyeti" },
|
{ arrow: "↑", value: "%98", label: labels.statSatisfaction },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Fade out at end
|
// Fade out at end
|
||||||
@@ -1231,25 +1280,28 @@ const StatsOverlayScene: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
|
|
||||||
// ─── Main Composition ───────────────────────────────────────────────────────
|
// ─── 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);
|
const c = getColors(isDark);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: c.bg }}>
|
<AbsoluteFill style={{ backgroundColor: c.bg }}>
|
||||||
<Sequence from={0} durationInFrames={90}>
|
<Sequence from={0} durationInFrames={90}>
|
||||||
<StorefrontScene isDark={isDark} />
|
<StorefrontScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Sequence from={90} durationInFrames={90}>
|
<Sequence from={90} durationInFrames={90}>
|
||||||
<WidgetIntegrationScene isDark={isDark} />
|
<WidgetIntegrationScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Sequence from={180} durationInFrames={90}>
|
<Sequence from={180} durationInFrames={90}>
|
||||||
<VinFilterScene isDark={isDark} />
|
<VinFilterScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Sequence from={270} durationInFrames={90}>
|
<Sequence from={270} durationInFrames={90}>
|
||||||
<AddToCartScene isDark={isDark} />
|
<AddToCartScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
<Sequence from={360} durationInFrames={60}>
|
<Sequence from={360} durationInFrames={60}>
|
||||||
<StatsOverlayScene isDark={isDark} />
|
<StatsOverlayScene isDark={isDark} labels={labels} />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
</AbsoluteFill>
|
</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 frame = useCurrentFrame();
|
||||||
const { fps } = useVideoConfig();
|
const { fps } = useVideoConfig();
|
||||||
const c = getColors(isDark);
|
const c = getColors(isDark);
|
||||||
@@ -175,7 +178,7 @@ export const SchemaDemo: React.FC<{ isDark: boolean }> = ({ isDark }) => {
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Klima Kompresörü
|
{partLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
import { resetChatwootUser, setChatwootUser } from "@/lib/chatwoot";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { trackPageView as trackMetaPageView } from "@/lib/meta-pixel";
|
import { trackPageView as trackMetaPageView } from "@/lib/meta-pixel";
|
||||||
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
|
import { capturePageView, identifyUser, resetUser } from "@/lib/posthog";
|
||||||
import { Toaster } from "@/lib/toast";
|
import { Toaster } from "@/lib/toast";
|
||||||
@@ -92,6 +93,7 @@ function applyTheme(theme: "light" | "dark" | "system") {
|
|||||||
function RootComponent() {
|
function RootComponent() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Pageview tracking
|
// Pageview tracking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,7 +137,7 @@ function RootComponent() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<a href="#main-content" className="skip-link">
|
<a href="#main-content" className="skip-link">
|
||||||
İçeriğe atla
|
{t("a11y.skipToContent")}
|
||||||
</a>
|
</a>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<Toaster position="top-center" />
|
<Toaster position="top-center" />
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ export const Route = createFileRoute("/_auth/register")({
|
|||||||
component: RegisterPage,
|
component: RegisterPage,
|
||||||
validateSearch: (
|
validateSearch: (
|
||||||
search: Record<string, unknown>,
|
search: Record<string, unknown>,
|
||||||
): { vin?: string; ref?: string; plan?: string } => ({
|
): { vin?: string; ref?: string; plan?: string; example?: string } => ({
|
||||||
vin: (search.vin as string) || undefined,
|
vin: (search.vin as string) || undefined,
|
||||||
ref: (search.ref as string) || undefined,
|
ref: (search.ref as string) || undefined,
|
||||||
plan: (search.plan 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";
|
const PENDING_PLAN_KEY = "sase-pending-plan";
|
||||||
|
|
||||||
function RegisterPage() {
|
function RegisterPage() {
|
||||||
const { vin, ref, plan } = Route.useSearch();
|
const { vin, ref, plan, example } = Route.useSearch();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -50,6 +51,7 @@ function RegisterPage() {
|
|||||||
"/dashboard/search?welcome=1",
|
"/dashboard/search?welcome=1",
|
||||||
vin ? `&vin=${encodeURIComponent(vin)}` : "",
|
vin ? `&vin=${encodeURIComponent(vin)}` : "",
|
||||||
refCode.trim() ? `&ref=${encodeURIComponent(refCode.trim().toUpperCase())}` : "",
|
refCode.trim() ? `&ref=${encodeURIComponent(refCode.trim().toUpperCase())}` : "",
|
||||||
|
example === "1" ? "&example=1" : "",
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Button } from "@sase/ui";
|
|||||||
import { Skeleton } from "@sase/ui";
|
import { Skeleton } from "@sase/ui";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
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";
|
import { useState } from "react";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard/billing")({
|
export const Route = createFileRoute("/dashboard/billing")({
|
||||||
@@ -237,8 +237,21 @@ function BillingPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : !filteredPayments || filteredPayments.length === 0 ? (
|
) : !filteredPayments || filteredPayments.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center gap-3 py-12 text-center text-muted-foreground">
|
<CardContent className="flex flex-col items-center gap-3 py-12 text-center">
|
||||||
<p>{hasActiveFilter ? t("billing.noResults") : t("billing.noPayments")}</p>
|
<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 && (
|
{hasActiveFilter && (
|
||||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||||
{t("billing.clearFilters")}
|
{t("billing.clearFilters")}
|
||||||
@@ -300,13 +313,24 @@ function BillingPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
|
<td data-label={t("billing.status")} className={`${TD} sm:text-center`}>
|
||||||
<Badge variant={statusVariants[payment.status] || "secondary"}>
|
<span className="inline-flex items-center gap-1.5">
|
||||||
{t(`billing.statusLabels.${payment.status}`)}
|
<Badge variant={statusVariants[payment.status] || "secondary"}>
|
||||||
</Badge>
|
{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>
|
||||||
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
|
<td className="block pt-3 text-right sm:table-cell sm:py-4 sm:pt-4">
|
||||||
{payment.eftReceiptUrl ? (
|
{payment.eftReceiptUrl ? (
|
||||||
<Button variant="ghost" size="sm" asChild>
|
<Button variant="outline" size="sm" asChild>
|
||||||
<a
|
<a
|
||||||
href={payment.eftReceiptUrl}
|
href={payment.eftReceiptUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -319,7 +343,7 @@ function BillingPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : payment.hasStripeReceipt ? (
|
) : payment.hasStripeReceipt ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled={receiptLoadingId === payment.id}
|
disabled={receiptLoadingId === payment.id}
|
||||||
onClick={() => openReceipt(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 { VehicleSelectModal } from "@/components/vehicles/vehicle-select-modal";
|
||||||
import { ApiError, api } from "@/lib/api-client";
|
import { ApiError, api } from "@/lib/api-client";
|
||||||
import { startAction } from "@/lib/faro";
|
import { startAction } from "@/lib/faro";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { KEYS_6, KEYS_17 } from "@/lib/keys";
|
import { KEYS_6, KEYS_17 } from "@/lib/keys";
|
||||||
import { capture } from "@/lib/posthog";
|
import { capture } from "@/lib/posthog";
|
||||||
import { toast } from "@/lib/toast";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { AlertCircle, Car, Clock, Loader2, Search, Send } from "lucide-react";
|
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}$/;
|
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 {
|
function isValidVin(vin: string): boolean {
|
||||||
if (!vin || vin.length !== 17) return false;
|
if (!vin || vin.length !== 17) return false;
|
||||||
return VIN_REGEX.test(vin.toUpperCase());
|
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
|
* a vehicle-page-shaped skeleton so the wait feels like the page is loading
|
||||||
* rather than an unresponsive button. */
|
* rather than an unresponsive button. */
|
||||||
function DecodingSkeleton() {
|
function DecodingSkeleton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6" aria-busy="true" aria-live="polite">
|
<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 */}
|
{/* Header: back + brand logo + 2-line title */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ShimmerBox className="h-9 w-9 rounded-md" />
|
<ShimmerBox className="h-9 w-9 rounded-md" />
|
||||||
@@ -90,16 +101,23 @@ export const Route = createFileRoute("/dashboard/search")({
|
|||||||
component: SearchPage,
|
component: SearchPage,
|
||||||
validateSearch: (
|
validateSearch: (
|
||||||
search: Record<string, unknown>,
|
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,
|
vin: search.vin ? String(search.vin) : undefined,
|
||||||
welcome: search.welcome ? String(search.welcome) : undefined,
|
welcome: search.welcome ? String(search.welcome) : undefined,
|
||||||
ref: search.ref ? String(search.ref) : undefined,
|
ref: search.ref ? String(search.ref) : undefined,
|
||||||
|
example: search.example ? String(search.example) : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
const focusFiredRef = useRef(false);
|
const focusFiredRef = useRef(false);
|
||||||
const querySourceRef = useRef<"manual" | "paste" | "history" | "landing">("manual");
|
const querySourceRef = useRef<"manual" | "paste" | "history" | "landing">("manual");
|
||||||
@@ -139,7 +157,7 @@ function SearchPage() {
|
|||||||
|
|
||||||
const { data: history } = useQuery({
|
const { data: history } = useQuery({
|
||||||
queryKey: ["vehicles", "history"],
|
queryKey: ["vehicles", "history"],
|
||||||
queryFn: () => api.get<any[]>("/vehicles/history?limit=20"),
|
queryFn: () => api.get<VehicleHistoryItem[]>("/vehicles/history?limit=6"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Ctrl+K shortcut ───────────────────────────────────────────────────────
|
// ─── Ctrl+K shortcut ───────────────────────────────────────────────────────
|
||||||
@@ -271,8 +289,7 @@ function SearchPage() {
|
|||||||
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
|
navigate({ to: "/dashboard/vehicles/$id", params: { id: data.id } });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const responseTimeMs = Math.round(performance.now() - decodeStart);
|
const responseTimeMs = Math.round(performance.now() - decodeStart);
|
||||||
const message =
|
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
|
||||||
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
|
|
||||||
capture("vin_decode_error", {
|
capture("vin_decode_error", {
|
||||||
vin: cleanVin,
|
vin: cleanVin,
|
||||||
error: message,
|
error: message,
|
||||||
@@ -282,7 +299,7 @@ function SearchPage() {
|
|||||||
attempt,
|
attempt,
|
||||||
});
|
});
|
||||||
setError(message);
|
setError(message);
|
||||||
toast.error("Şase arama başarısız");
|
toast.error(t("search.errorToast"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -304,7 +321,7 @@ function SearchPage() {
|
|||||||
error_type: cleanVin.length !== 17 ? "invalid_length" : "invalid_chars",
|
error_type: cleanVin.length !== 17 ? "invalid_length" : "invalid_chars",
|
||||||
input_length: cleanVin.length,
|
input_length: cleanVin.length,
|
||||||
});
|
});
|
||||||
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
|
setError(t("search.errorInvalidVin"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +336,7 @@ function SearchPage() {
|
|||||||
querySourceRef.current = "landing";
|
querySourceRef.current = "landing";
|
||||||
capture("vin_decoded", { vin: cleanVin, query_source: "landing" });
|
capture("vin_decoded", { vin: cleanVin, query_source: "landing" });
|
||||||
if (!isValidVin(cleanVin)) {
|
if (!isValidVin(cleanVin)) {
|
||||||
setError("Geçersiz şase. 17 karakter olmalı, I, O, Q harfleri kullanılamaz.");
|
setError(t("search.errorInvalidVin"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastAttemptedVinRef.current = cleanVin;
|
lastAttemptedVinRef.current = cleanVin;
|
||||||
@@ -342,15 +359,20 @@ function SearchPage() {
|
|||||||
if (didInitFromUrlRef.current) return;
|
if (didInitFromUrlRef.current) return;
|
||||||
didInitFromUrlRef.current = true;
|
didInitFromUrlRef.current = true;
|
||||||
const carried = vinParam?.toUpperCase().trim();
|
const carried = vinParam?.toUpperCase().trim();
|
||||||
|
const isExample = exampleParam === "1";
|
||||||
if (carried) {
|
if (carried) {
|
||||||
setVin(carried);
|
setVin(carried);
|
||||||
if (welcomeParam === "1") {
|
// Example VINs from the landing should just prefill — the visitor wanted
|
||||||
pendingVinRef.current = carried;
|
// to explore, not commit. User-typed VINs are decoded straight through.
|
||||||
} else {
|
if (!isExample) {
|
||||||
autoDecode(carried);
|
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: {} });
|
navigate({ to: "/dashboard/search", replace: true, search: {} });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -360,11 +382,11 @@ function SearchPage() {
|
|||||||
try {
|
try {
|
||||||
await api.post("/vehicles/report-vin", { vin: vin.toUpperCase().trim() });
|
await api.post("/vehicles/report-vin", { vin: vin.toUpperCase().trim() });
|
||||||
setReportSent(true);
|
setReportSent(true);
|
||||||
toast.success("Bildirim gönderildi", {
|
toast.success(t("search.reportToastTitle"), {
|
||||||
description: "Şase numarası sistem yöneticisine iletildi.",
|
description: t("search.reportToastDesc"),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Bildirim gönderilemedi");
|
toast.error(t("search.reportToastError"));
|
||||||
} finally {
|
} finally {
|
||||||
setReportSending(false);
|
setReportSending(false);
|
||||||
}
|
}
|
||||||
@@ -382,11 +404,11 @@ function SearchPage() {
|
|||||||
if (correctionsRef.current.size === 0) return;
|
if (correctionsRef.current.size === 0) return;
|
||||||
const list = [...correctionsRef.current];
|
const list = [...correctionsRef.current];
|
||||||
correctionsRef.current = new Set();
|
correctionsRef.current = new Set();
|
||||||
toast.info(`Otomatik düzeltildi: ${list.join(", ")}`, {
|
toast.info(t("search.autoCorrected", { list: list.join(", ") }), {
|
||||||
description: "Şase numarasında I, O, Q harfleri kullanılamaz",
|
description: t("search.pasteInvalidChars"),
|
||||||
duration: 2500,
|
duration: 2500,
|
||||||
});
|
});
|
||||||
}, []);
|
}, [t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -445,12 +467,11 @@ function SearchPage() {
|
|||||||
params: { id: data.id },
|
params: { id: data.id },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message =
|
const message = err instanceof ApiError ? err.message : t("search.errorGeneric");
|
||||||
err instanceof ApiError ? err.message : "Bir hata oluştu. Lütfen tekrar deneyin.";
|
|
||||||
setError(message);
|
setError(message);
|
||||||
setCandidates(null);
|
setCandidates(null);
|
||||||
setCandidateSource(null);
|
setCandidateSource(null);
|
||||||
toast.error("Araç seçimi başarısız");
|
toast.error(t("search.candidateSelectFailed"));
|
||||||
} finally {
|
} finally {
|
||||||
setSelectLoading(false);
|
setSelectLoading(false);
|
||||||
}
|
}
|
||||||
@@ -488,11 +509,9 @@ function SearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
|
<h2 className="font-[family-name:var(--font-display)] text-3xl font-bold tracking-tight">
|
||||||
Şase Arama
|
{t("search.title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">{t("search.subtitle")}</p>
|
||||||
Araç şase numarasını girerek yedek parça kataloğuna erişin
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -500,11 +519,15 @@ function SearchPage() {
|
|||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSearch} className="space-y-4">
|
<form onSubmit={handleSearch} className="space-y-4">
|
||||||
{/* Input */}
|
{/* Input */}
|
||||||
|
<Label htmlFor="vin" className="sr-only">
|
||||||
|
{t("search.inputLabel")}
|
||||||
|
</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<VinBrandIcon vin={vin} className="absolute left-4 top-1/2 -translate-y-1/2" />
|
<VinBrandIcon vin={vin} className="absolute left-4 top-1/2 -translate-y-1/2" />
|
||||||
<Input
|
<Input
|
||||||
|
id="vin"
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
placeholder="Şase numarasını girin (17 karakter)"
|
placeholder={t("search.inputPlaceholder")}
|
||||||
value={vin}
|
value={vin}
|
||||||
onChange={(e) => handleVinChange(e.target.value)}
|
onChange={(e) => handleVinChange(e.target.value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
@@ -556,14 +579,16 @@ function SearchPage() {
|
|||||||
|
|
||||||
{/* Counter + Example VIN */}
|
{/* Counter + Example VIN */}
|
||||||
<div className="flex items-center justify-between text-sm">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={fillExampleVin}
|
onClick={fillExampleVin}
|
||||||
data-faro-user-action-name="fill-example-vin"
|
data-faro-user-action-name="fill-example-vin"
|
||||||
className="text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
|
className="text-muted-foreground underline underline-offset-4 transition hover:text-foreground"
|
||||||
>
|
>
|
||||||
Örnek şase deneyin →
|
{t("search.tryExample")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -578,7 +603,7 @@ function SearchPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Search className="mr-2 size-4" />
|
<Search className="mr-2 size-4" />
|
||||||
)}
|
)}
|
||||||
Şase Çöz
|
{t("search.submit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Error banner — the main submit button above doubles as the retry,
|
{/* 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" />
|
<AlertCircle className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||||
<div className="min-w-0 flex-1">
|
<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">
|
<p className="mt-1 text-sm text-destructive/90">
|
||||||
{error.includes("abone olun") ? (
|
{error.includes("abone olun") ? (
|
||||||
<>
|
<>
|
||||||
Aktif aboneliğiniz yok. Araç verilerine erişmek için{" "}
|
{t("search.errorSubscriptionPrefix")}{" "}
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard/subscription"
|
to="/dashboard/subscription"
|
||||||
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
|
className="inline-flex items-center font-semibold underline underline-offset-4 transition hover:text-destructive/80"
|
||||||
>
|
>
|
||||||
abone olun
|
{t("search.subscribeLink")}
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</>
|
</>
|
||||||
@@ -627,13 +652,11 @@ function SearchPage() {
|
|||||||
) : (
|
) : (
|
||||||
<Send className="mr-2 size-4" />
|
<Send className="mr-2 size-4" />
|
||||||
)}
|
)}
|
||||||
Şase no doğru, sistem yöneticisine gönder
|
{t("search.reportVin")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{reportSent && (
|
{reportSent && (
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">{t("search.reportSent")}</p>
|
||||||
Bildirim gönderildi. En kısa sürede incelenecektir.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -642,7 +665,7 @@ function SearchPage() {
|
|||||||
{previewLoading && (
|
{previewLoading && (
|
||||||
<div className="flex items-center justify-center gap-3 rounded-2xl border border-border bg-background p-6">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -665,15 +688,13 @@ function SearchPage() {
|
|||||||
variant="default"
|
variant="default"
|
||||||
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
|
className="bg-brand text-xs text-brand-foreground hover:bg-brand/90"
|
||||||
>
|
>
|
||||||
Araç tanımlandı
|
{t("search.vehicleIdentified")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-4 bg-border" />
|
<Separator className="my-4 bg-border" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t("search.previewHint")}</p>
|
||||||
Şase Çöz butonuna tıklayarak tam parça kataloğuna erişin.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -684,22 +705,27 @@ function SearchPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="size-4 text-muted-foreground" />
|
<Clock className="size-4 text-muted-foreground" />
|
||||||
<h3 className="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight">
|
<h3 className="font-[family-name:var(--font-display)] text-lg font-bold tracking-tight">
|
||||||
Son Aramalar
|
{t("search.recent")}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/dashboard/history"
|
to="/dashboard/history"
|
||||||
className="text-sm text-muted-foreground transition hover:text-foreground"
|
className="text-sm text-muted-foreground transition hover:text-foreground"
|
||||||
>
|
>
|
||||||
Tümünü Gör →
|
{t("search.seeAll")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<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
|
<button
|
||||||
key={v.id}
|
key={v.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={t("search.historyAria", {
|
||||||
|
brand: v.brandName,
|
||||||
|
model: v.model ?? "",
|
||||||
|
vin: v.vin,
|
||||||
|
})}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
querySourceRef.current = "history";
|
querySourceRef.current = "history";
|
||||||
const ageDays = v.lastAccessedAt
|
const ageDays = v.lastAccessedAt
|
||||||
@@ -711,8 +737,7 @@ function SearchPage() {
|
|||||||
position: idx,
|
position: idx,
|
||||||
age_days: ageDays,
|
age_days: ageDays,
|
||||||
});
|
});
|
||||||
setVin(v.vin);
|
navigate({ to: "/dashboard/vehicles/$id", params: { id: v.id } });
|
||||||
inputRef.current?.focus();
|
|
||||||
}}
|
}}
|
||||||
data-faro-user-action-name="select-history-vin"
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
<p className="text-center text-xs text-muted-foreground">{t("search.openHint")}</p>
|
||||||
Geçmiş aramayı tıklayın — şase otomatik doldurulur
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { CredibilityStrip } from "@/components/credibility-strip";
|
|||||||
import { StripeCheckoutButton } from "@/components/payment/stripe-checkout-button";
|
import { StripeCheckoutButton } from "@/components/payment/stripe-checkout-button";
|
||||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { startAction } from "@/lib/faro";
|
import { startAction } from "@/lib/faro";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { BRAND_SKELETON_KEYS } from "@/lib/keys";
|
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 {
|
interface SubscriptionSearch {
|
||||||
welcome?: string;
|
welcome?: string;
|
||||||
ref?: string | null;
|
ref?: string | null;
|
||||||
@@ -174,11 +211,20 @@ function getDowngradePlan(currentPlanKey: string) {
|
|||||||
|
|
||||||
type CheckoutStep = "plan" | "brands" | "payment" | "confirmation";
|
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() {
|
export function SubscriptionPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const search = Route.useSearch();
|
const search = Route.useSearch();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const userId = session?.user?.id ?? null;
|
||||||
|
|
||||||
// ─── Search-param-driven flags (read once) ─────────────────────────────────
|
// ─── Search-param-driven flags (read once) ─────────────────────────────────
|
||||||
const [welcome] = useState(() => search.welcome === "1");
|
const [welcome] = useState(() => search.welcome === "1");
|
||||||
@@ -379,9 +425,9 @@ export function SubscriptionPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!subscription?.endDate) return;
|
if (!subscription?.endDate) return;
|
||||||
const key = `trialBannerDismissed-${subscription.endDate}`;
|
const key = trialBannerKey(userId, subscription.endDate);
|
||||||
if (localStorage.getItem(key) === "true") setTrialBannerDismissed(true);
|
if (localStorage.getItem(key) === "true") setTrialBannerDismissed(true);
|
||||||
}, [subscription?.endDate]);
|
}, [subscription?.endDate, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -432,6 +478,13 @@ export function SubscriptionPage() {
|
|||||||
if (!planKey || !plans.some((p) => p.key === planKey)) return;
|
if (!planKey || !plans.some((p) => p.key === planKey)) return;
|
||||||
hasPreselectedRef.current = true;
|
hasPreselectedRef.current = true;
|
||||||
localStorage.removeItem(PENDING_PLAN_KEY);
|
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" });
|
capture("plan_selected", { plan: planKey, source: "marketing_preselect" });
|
||||||
setSelectedPlanKey(planKey);
|
setSelectedPlanKey(planKey);
|
||||||
setSelectedBrandIds([]);
|
setSelectedBrandIds([]);
|
||||||
@@ -519,7 +572,7 @@ export function SubscriptionPage() {
|
|||||||
plan_name: subscription?.plan?.name,
|
plan_name: subscription?.plan?.name,
|
||||||
billing_period: subscription?.billingPeriod,
|
billing_period: subscription?.billingPeriod,
|
||||||
});
|
});
|
||||||
localStorage.setItem(`trialBannerDismissed-${subscription?.endDate}`, "true");
|
localStorage.setItem(trialBannerKey(userId, subscription?.endDate), "true");
|
||||||
setTrialBannerDismissed(true);
|
setTrialBannerDismissed(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,7 +744,7 @@ export function SubscriptionPage() {
|
|||||||
count: selectedPlan?.brandLimit ?? 0,
|
count: selectedPlan?.brandLimit ?? 0,
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<Suspense fallback={<BrandSelectorFallback />}>
|
<Suspense fallback={<BrandSelectorFallbackWithTimeout />}>
|
||||||
<BrandSelector
|
<BrandSelector
|
||||||
maxBrands={selectedPlan?.brandLimit ?? 1}
|
maxBrands={selectedPlan?.brandLimit ?? 1}
|
||||||
selectedBrandIds={selectedBrandIds}
|
selectedBrandIds={selectedBrandIds}
|
||||||
@@ -717,6 +770,7 @@ export function SubscriptionPage() {
|
|||||||
period={billingPeriod}
|
period={billingPeriod}
|
||||||
brandIds={selectedBrandIds}
|
brandIds={selectedBrandIds}
|
||||||
totalAmount={totalAmount}
|
totalAmount={totalAmount}
|
||||||
|
onStaleBrandsFiltered={setSelectedBrandIds}
|
||||||
/>
|
/>
|
||||||
</StepCard>
|
</StepCard>
|
||||||
)}
|
)}
|
||||||
@@ -1040,7 +1094,11 @@ export function PlanGrid({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
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) => {
|
{plans.map((plan) => {
|
||||||
const isSelected = selectedPlanKey === plan.key;
|
const isSelected = selectedPlanKey === plan.key;
|
||||||
const price = billingPeriod === "monthly" ? plan.priceMonthly : plan.priceYearly;
|
const price = billingPeriod === "monthly" ? plan.priceMonthly : plan.priceYearly;
|
||||||
@@ -1051,9 +1109,12 @@ export function PlanGrid({
|
|||||||
key={plan.key}
|
key={plan.key}
|
||||||
data-faro-user-action-name={`select-plan-${plan.key}`}
|
data-faro-user-action-name={`select-plan-${plan.key}`}
|
||||||
onClick={() => !isCurrentPlan && onSelect(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}
|
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
|
isCurrentPlan
|
||||||
? "cursor-not-allowed border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-950/20"
|
? "cursor-not-allowed border-emerald-500/40 bg-emerald-50/40 dark:bg-emerald-950/20"
|
||||||
: isSelected
|
: isSelected
|
||||||
@@ -1186,11 +1247,13 @@ function PaymentMethodSection({
|
|||||||
period,
|
period,
|
||||||
brandIds,
|
brandIds,
|
||||||
totalAmount,
|
totalAmount,
|
||||||
|
onStaleBrandsFiltered,
|
||||||
}: {
|
}: {
|
||||||
planKey: string;
|
planKey: string;
|
||||||
period: "monthly" | "yearly";
|
period: "monthly" | "yearly";
|
||||||
brandIds: string[];
|
brandIds: string[];
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
|
onStaleBrandsFiltered?: (validIds: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isFullPlan = planKey === "full";
|
const isFullPlan = planKey === "full";
|
||||||
@@ -1200,6 +1263,23 @@ function PaymentMethodSection({
|
|||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
enabled: !isFullPlan && brandIds.length > 0,
|
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 ?? [])
|
const selectedBrandNames = (brands ?? [])
|
||||||
.filter((b) => brandIds.includes(b.id))
|
.filter((b) => brandIds.includes(b.id))
|
||||||
.map((b) => b.name);
|
.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 (
|
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="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">
|
<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>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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}
|
{ctaLabel}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1719,7 +1811,7 @@ function ProvisioningView({
|
|||||||
)}
|
)}
|
||||||
{trialMutation.isError && (
|
{trialMutation.isError && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<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")}
|
{t("subscription.onboarding.error")}
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" onClick={() => trialMutation.mutate()}>
|
<Button variant="outline" onClick={() => trialMutation.mutate()}>
|
||||||
@@ -1727,12 +1819,52 @@ function ProvisioningView({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ProvisioningStuckHint trialMutation={trialMutation} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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({
|
function OnboardingCompletedView({
|
||||||
subscription,
|
subscription,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -1756,7 +1888,7 @@ function OnboardingCompletedView({
|
|||||||
{t("subscription.currentPlan")}
|
{t("subscription.currentPlan")}
|
||||||
</span>
|
</span>
|
||||||
<Badge className="bg-brand text-brand-foreground">
|
<Badge className="bg-brand text-brand-foreground">
|
||||||
{t("subscription.plans.full.name")}
|
{subscription?.plan?.name ?? t("subscription.plans.full.name")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -1937,75 +2069,85 @@ export function FeatureMatrix() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-4 text-xl font-semibold leading-8">{t("subscription.featureMatrix")}</h3>
|
<h3 className="mb-4 text-xl font-semibold leading-8">{t("subscription.featureMatrix")}</h3>
|
||||||
<div className="overflow-x-auto rounded-lg border">
|
{/* Right-fade gradient hints horizontal scroll on narrow viewports.
|
||||||
<table className="w-full text-sm">
|
Sticky thead keeps plan names + prices in view while the rows scroll. */}
|
||||||
<thead>
|
<div className="relative">
|
||||||
<tr className="border-b bg-muted/50">
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
<th className="w-48 px-4 py-3 text-left font-semibold leading-5 text-muted-foreground" />
|
<table className="w-full text-sm">
|
||||||
{plans.map((plan) => (
|
<thead className="sticky top-0 z-10 bg-background">
|
||||||
<th
|
<tr className="border-b bg-muted/50">
|
||||||
key={plan.key}
|
<th className="w-48 px-4 py-3 text-left font-semibold leading-5 text-muted-foreground" />
|
||||||
className={`relative px-4 py-3 text-center align-bottom font-semibold leading-5 ${
|
{plans.map((plan) => (
|
||||||
plan.popular ? "text-primary" : "text-foreground"
|
<th
|
||||||
}`}
|
key={plan.key}
|
||||||
>
|
className={`relative px-4 py-3 text-center align-bottom font-semibold leading-5 ${
|
||||||
<div className="flex flex-col items-center gap-1">
|
plan.popular ? "text-primary" : "text-foreground"
|
||||||
{plan.popular && (
|
}`}
|
||||||
<Badge className="px-1.5 py-0 text-[10px]">{t("subscription.popular")}</Badge>
|
>
|
||||||
)}
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span>{t(`subscription.plans.${plan.key}.name`)}</span>
|
{plan.popular && (
|
||||||
<span className="text-xs font-normal text-muted-foreground">
|
<Badge className="px-1.5 py-0 text-[10px]">
|
||||||
{formatTRY(plan.priceMonthly)}
|
{t("subscription.popular")}
|
||||||
{t("common.perMonth")}
|
</Badge>
|
||||||
</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")}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</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>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CategoryTree } from "@/components/categories/category-tree";
|
|||||||
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
|
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
|
||||||
import { useCategoryParts } from "@/hooks/use-parts";
|
import { useCategoryParts } from "@/hooks/use-parts";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
|
import { useTranslation } from "@/lib/i18n";
|
||||||
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
import { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
import { cleanModelName } from "@/lib/vehicle";
|
import { cleanModelName } from "@/lib/vehicle";
|
||||||
import { Button, Skeleton } from "@sase/ui";
|
import { Button, Skeleton } from "@sase/ui";
|
||||||
@@ -51,6 +52,7 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/categories_/$cate
|
|||||||
});
|
});
|
||||||
|
|
||||||
function VehicleCategoryPage() {
|
function VehicleCategoryPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id, categoryId } = Route.useParams();
|
const { id, categoryId } = Route.useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isLoading, error, refetch, isFetching } = useCategoryParts(id, categoryId);
|
const { data, isLoading, error, refetch, isFetching } = useCategoryParts(id, categoryId);
|
||||||
@@ -93,7 +95,7 @@ function VehicleCategoryPage() {
|
|||||||
const cleanModel = cleanModelName(vehicle?.model);
|
const cleanModel = cleanModelName(vehicle?.model);
|
||||||
const vehicleLabel = vehicle?.brandName
|
const vehicleLabel = vehicle?.brandName
|
||||||
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
|
? `${vehicle.brandName}${cleanModel ? ` ${cleanModel}` : ""}`
|
||||||
: "Araç";
|
: t("vehicle.title");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -111,8 +113,8 @@ function VehicleCategoryPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
title="Geri dön"
|
title={t("vehicle.back")}
|
||||||
aria-label="Geri dön"
|
aria-label={t("vehicle.back")}
|
||||||
data-faro-user-action-name="category-back"
|
data-faro-user-action-name="category-back"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
@@ -123,7 +125,7 @@ function VehicleCategoryPage() {
|
|||||||
(isLoading ? (
|
(isLoading ? (
|
||||||
<span className="inline-block h-6 w-48 animate-pulse rounded-md bg-primary/10 align-middle" />
|
<span className="inline-block h-6 w-48 animate-pulse rounded-md bg-primary/10 align-middle" />
|
||||||
) : (
|
) : (
|
||||||
"Kategori Detayı"
|
t("vehicle.categoryDetail")
|
||||||
))}
|
))}
|
||||||
</h1>
|
</h1>
|
||||||
{data?.description && (
|
{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"
|
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -154,7 +156,7 @@ function VehicleCategoryPage() {
|
|||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
data-faro-user-action-name="category-retry"
|
data-faro-user-action-name="category-retry"
|
||||||
>
|
>
|
||||||
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
|
{isFetching ? t("common.loading") : t("vehicle.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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"
|
className="flex flex-col items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-5 text-sm"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-destructive">Katalog şu an yüklenemedi</p>
|
<p className="font-medium text-destructive">{t("vehicle.catalogUnavailableTitle")}</p>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">{t("vehicle.catalogUnavailableHint")}</p>
|
||||||
Bu kategori tedarikçi katalogundan alınamadı. Lütfen birazdan tekrar deneyin.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -196,7 +196,7 @@ function VehicleCategoryPage() {
|
|||||||
disabled={isFetching}
|
disabled={isFetching}
|
||||||
data-faro-user-action-name="category-loaderror-retry"
|
data-faro-user-action-name="category-loaderror-retry"
|
||||||
>
|
>
|
||||||
{isFetching ? "Yükleniyor…" : "Tekrar dene"}
|
{isFetching ? t("common.loading") : t("vehicle.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -6,9 +6,12 @@ import { CategoryTree } from "@/components/categories/category-tree";
|
|||||||
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
|
import { CategoryViewToggle } from "@/components/categories/category-view-toggle";
|
||||||
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
import { CarBrandLogo } from "@/components/ui/car-brand-logo";
|
||||||
import { ApiError, api } from "@/lib/api-client";
|
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 { getUserSettings, setUserSetting } from "@/lib/user-settings";
|
||||||
import { cleanModelName } from "@/lib/vehicle";
|
import { cleanModelName } from "@/lib/vehicle";
|
||||||
|
import type { CategoryNode, Vehicle } from "@sase/shared";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
@@ -31,8 +34,12 @@ export const Route = createFileRoute("/dashboard/vehicles_/$id/")({
|
|||||||
component: VehicleDetailPage,
|
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() {
|
function VehicleDetailPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { id } = Route.useParams();
|
const { id } = Route.useParams();
|
||||||
|
const idValid = UUID_RE.test(id);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
const [viewMode, setViewMode] = useState<"grid" | "tree" | "columns">(
|
||||||
() => getUserSettings().categoryViewMode ?? "grid",
|
() => getUserSettings().categoryViewMode ?? "grid",
|
||||||
);
|
);
|
||||||
@@ -43,6 +50,7 @@ function VehicleDetailPage() {
|
|||||||
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
const changeViewMode = (mode: "grid" | "tree" | "columns") => {
|
||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
setUserSetting("categoryViewMode", mode);
|
setUserSetting("categoryViewMode", mode);
|
||||||
|
capture("category_view_changed", { mode, vehicle_id: id });
|
||||||
};
|
};
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -65,8 +73,8 @@ function VehicleDetailPage() {
|
|||||||
isFetching: vehicleFetching,
|
isFetching: vehicleFetching,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["vehicle", id],
|
queryKey: ["vehicle", id],
|
||||||
queryFn: () => api.get<any>(`/vehicles/${id}`),
|
queryFn: () => api.get<Vehicle>(`/vehicles/${id}`),
|
||||||
enabled: !!id,
|
enabled: !!id && idValid,
|
||||||
// A missing vehicle (404) is final — don't burn retries before showing the
|
// A missing vehicle (404) is final — don't burn retries before showing the
|
||||||
// not-found state. Transient errors still retry.
|
// not-found state. Transient errors still retry.
|
||||||
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
|
retry: (count, err) => !(err instanceof ApiError && err.status === 404) && count < 2,
|
||||||
@@ -74,13 +82,13 @@ function VehicleDetailPage() {
|
|||||||
|
|
||||||
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
|
const { data: categoryTree, isLoading: categoriesLoading } = useQuery({
|
||||||
queryKey: ["category-tree", id],
|
queryKey: ["category-tree", id],
|
||||||
queryFn: () => api.get<any[]>(`/categories/tree/${id}`),
|
queryFn: () => api.get<CategoryNode[]>(`/categories/tree/${id}`),
|
||||||
enabled: !!id,
|
enabled: !!id && idValid,
|
||||||
});
|
});
|
||||||
|
|
||||||
const vehicleLabel = vehicle?.brandName
|
const vehicleLabel: string = vehicle?.brandName
|
||||||
? `${vehicle.brandName}${cleanModelName(vehicle?.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
|
? `${vehicle.brandName}${cleanModelName(vehicle.model) ? ` ${cleanModelName(vehicle.model)}` : ""}`
|
||||||
: "Araç";
|
: t("vehicle.title");
|
||||||
|
|
||||||
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
|
// Surface the viewed vehicle to the support chat widget (VIN/brand/model)
|
||||||
// so agents have the car context for part-compatibility questions.
|
// so agents have the car context for part-compatibility questions.
|
||||||
@@ -94,8 +102,46 @@ function VehicleDetailPage() {
|
|||||||
year: vehicle.year,
|
year: vehicle.year,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return () => clearChatwootVehicle();
|
||||||
}, [vehicle, id]);
|
}, [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) {
|
if (vehicleLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -135,13 +181,15 @@ function VehicleDetailPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
title="Geri dön"
|
title={t("vehicle.back")}
|
||||||
aria-label="Geri dön"
|
aria-label={t("vehicle.back")}
|
||||||
data-faro-user-action-name="vehicle-back"
|
data-faro-user-action-name="vehicle-back"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
||||||
<div
|
<div
|
||||||
role="alert"
|
role="alert"
|
||||||
@@ -149,14 +197,10 @@ function VehicleDetailPage() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-destructive">
|
<p className="font-medium text-destructive">
|
||||||
{is404 ? "Bu araç kaydı bulunamadı" : "Araç bilgileri yüklenemedi"}
|
{is404 ? t("vehicle.notFoundMsg") : t("vehicle.errorMsg")}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-muted-foreground">
|
<p className="mt-1 text-muted-foreground">
|
||||||
{is404
|
{is404 ? t("vehicle.notFoundHint") : t("vehicle.errorHint")}
|
||||||
? "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."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -169,11 +213,11 @@ function VehicleDetailPage() {
|
|||||||
disabled={vehicleFetching}
|
disabled={vehicleFetching}
|
||||||
data-faro-user-action-name="vehicle-retry"
|
data-faro-user-action-name="vehicle-retry"
|
||||||
>
|
>
|
||||||
{vehicleFetching ? "Yükleniyor…" : "Tekrar dene"}
|
{vehicleFetching ? t("common.loading") : t("vehicle.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button type="button" size="sm" variant="ghost" onClick={handleBack}>
|
<Button type="button" size="sm" variant="ghost" onClick={handleBack}>
|
||||||
Geri dön
|
{t("vehicle.back")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<CategoryBreadcrumb vehicleId={id} vehicleLabel={vehicleLabel} />
|
<CategoryBreadcrumb vehicleId={id} vehicleLabel={vehicleLabel} />
|
||||||
@@ -191,23 +239,22 @@ function VehicleDetailPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
title="Geri dön"
|
title={t("vehicle.back")}
|
||||||
aria-label="Geri dön"
|
aria-label={t("vehicle.back")}
|
||||||
|
className="shrink-0"
|
||||||
data-faro-user-action-name="vehicle-back"
|
data-faro-user-action-name="vehicle-back"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex items-center gap-3">
|
{vehicle?.brandName && (
|
||||||
{vehicle?.brandName && (
|
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
|
||||||
<CarBrandLogo brandName={vehicle.brandName} size={32} className="shrink-0" />
|
)}
|
||||||
)}
|
<div className="min-w-0 flex-1">
|
||||||
<div>
|
<h2 className="break-words font-[family-name:var(--font-display)] text-2xl font-bold leading-tight tracking-tight">
|
||||||
<h2 className="text-2xl font-bold">
|
{vehicle?.brandName} {cleanModelName(vehicle?.model)}{" "}
|
||||||
{vehicle?.brandName} {cleanModelName(vehicle?.model)}{" "}
|
{vehicle?.year && `(${vehicle.year})`}
|
||||||
{vehicle?.year && `(${vehicle.year})`}
|
</h2>
|
||||||
</h2>
|
<p className="truncate font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
|
||||||
<p className="font-mono text-sm text-muted-foreground">{vehicle?.vin}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,7 +265,7 @@ function VehicleDetailPage() {
|
|||||||
<AccordionItem value="info" className="border-b-0">
|
<AccordionItem value="info" className="border-b-0">
|
||||||
<AccordionTrigger className="px-6 hover:no-underline">
|
<AccordionTrigger className="px-6 hover:no-underline">
|
||||||
<div className="flex flex-1 flex-col items-start gap-1.5 pr-3 text-left">
|
<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} />
|
<VehicleSummary vehicle={vehicle} />
|
||||||
</div>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
@@ -233,7 +280,7 @@ function VehicleDetailPage() {
|
|||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<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} />}
|
{!searchActive && <CategoryViewToggle value={viewMode} onChange={changeViewMode} />}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent
|
<CardContent
|
||||||
@@ -244,21 +291,44 @@ function VehicleDetailPage() {
|
|||||||
{/* Search box keeps its own padding when the columns view zeroes out
|
{/* Search box keeps its own padding when the columns view zeroes out
|
||||||
CardContent padding. */}
|
CardContent padding. */}
|
||||||
<div className={viewMode === "columns" && !searchActive ? "p-6 pb-0" : undefined}>
|
<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>
|
</div>
|
||||||
{!searchActive &&
|
{!searchActive &&
|
||||||
(categoriesLoading ? (
|
(categoriesLoading ? (
|
||||||
<div className="space-y-2">
|
viewMode === "grid" ? (
|
||||||
{KEYS_8.map((__k) => (
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
<Skeleton key={__k} className="h-8 w-full" />
|
{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>
|
</div>
|
||||||
) : viewMode === "grid" ? (
|
) : viewMode === "grid" ? (
|
||||||
<CategoryGrid categories={categoryTree || []} vehicleId={id} hideFilter />
|
<CategoryGrid categories={categoryTree} vehicleId={id} hideFilter />
|
||||||
) : viewMode === "tree" ? (
|
) : viewMode === "tree" ? (
|
||||||
<CategoryTree categories={categoryTree || []} vehicleId={id} />
|
<CategoryTree categories={categoryTree} vehicleId={id} />
|
||||||
) : (
|
) : (
|
||||||
<CategoryColumns categories={categoryTree || []} vehicleId={id} />
|
<CategoryColumns categories={categoryTree} vehicleId={id} />
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
/** Known PL24 raw labels (missing Turkish chars) → clean Turkish. Unknown
|
||||||
* labels pass through unchanged. */
|
* labels pass through unchanged. */
|
||||||
const LABEL_OVERRIDES: Record<string, string> = {
|
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ı"]);
|
const HEADER_LABELS = new Set(["model", "model yili", "model yılı"]);
|
||||||
|
|
||||||
/** Extract vinfoBasic records from rawData, dropping VIN and header dupes. */
|
/** Extract vinfoBasic records from rawData, dropping VIN and header dupes. */
|
||||||
function getVehicleAttributes(vehicle: any): Array<{ label: string; value: string }> {
|
function getVehicleAttributes(vehicle: Vehicle): Array<{ label: string; value: string }> {
|
||||||
const records = vehicle?.rawData?.raw?.segments?.vinfoBasic?.records;
|
const rd = vehicle.rawData as RawData | null;
|
||||||
|
const records = rd?.raw?.segments?.vinfoBasic?.records;
|
||||||
if (!Array.isArray(records)) return [];
|
if (!Array.isArray(records)) return [];
|
||||||
|
|
||||||
const vinLower = (vehicle?.vin || "").toLowerCase();
|
const vinLower = (vehicle.vin || "").toLowerCase();
|
||||||
|
|
||||||
return records
|
return records
|
||||||
.map((r: any) => ({
|
.map((r) => ({
|
||||||
label: (r.values?.description || "").trim(),
|
label: (r.values?.description || "").trim(),
|
||||||
value: (r.values?.value || "").replace(/\r?\n/g, " ").trim(),
|
value: (r.values?.value || "").replace(/\r?\n/g, " ").trim(),
|
||||||
}))
|
}))
|
||||||
.filter(
|
.filter(
|
||||||
(attr: { label: string; value: string }) =>
|
(attr) =>
|
||||||
attr.label &&
|
attr.label &&
|
||||||
attr.value &&
|
attr.value &&
|
||||||
attr.value.toLowerCase() !== vinLower &&
|
attr.value.toLowerCase() !== vinLower &&
|
||||||
!HEADER_LABELS.has(attr.label.toLowerCase()),
|
!HEADER_LABELS.has(attr.label.toLowerCase()),
|
||||||
)
|
)
|
||||||
.map((attr: { label: string; value: string }) => ({
|
.map((attr) => ({
|
||||||
label: LABEL_OVERRIDES[attr.label] ?? attr.label,
|
label: LABEL_OVERRIDES[attr.label] ?? attr.label,
|
||||||
value: attr.value,
|
value: attr.value,
|
||||||
}));
|
}));
|
||||||
@@ -340,16 +450,17 @@ const PCAT_LABELS: Record<string, string> = {
|
|||||||
"Car name": "Model",
|
"Car name": "Model",
|
||||||
};
|
};
|
||||||
|
|
||||||
function fromPcat(vehicle: any): Attr[] {
|
function fromPcat(vehicle: Vehicle): Attr[] {
|
||||||
const params = vehicle?.rawData?.pcatCar?.parameters;
|
const rd = vehicle.rawData as RawData | null;
|
||||||
|
const params = rd?.pcatCar?.parameters;
|
||||||
if (!Array.isArray(params)) return [];
|
if (!Array.isArray(params)) return [];
|
||||||
return [...params]
|
return [...params]
|
||||||
.sort((a: any, b: any) => (a?.sortOrder ?? 999) - (b?.sortOrder ?? 999))
|
.sort((a, b) => (a?.sortOrder ?? 999) - (b?.sortOrder ?? 999))
|
||||||
.map((p: any) => ({
|
.map((p) => ({
|
||||||
label: PCAT_LABELS[p?.name] ?? String(p?.name ?? "").trim(),
|
label: (p?.name && PCAT_LABELS[p.name]) ?? String(p?.name ?? "").trim(),
|
||||||
value: String(p?.value ?? "").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. */
|
/** 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",
|
vehicle_type: "Araç tipi",
|
||||||
};
|
};
|
||||||
|
|
||||||
function fromEmex(vehicle: any): Attr[] {
|
function fromEmex(vehicle: Vehicle): Attr[] {
|
||||||
const opts = vehicle?.rawData?.parsedOptions;
|
const rd = vehicle.rawData as RawData | null;
|
||||||
|
const opts = rd?.parsedOptions;
|
||||||
if (!opts || typeof opts !== "object") return [];
|
if (!opts || typeof opts !== "object") return [];
|
||||||
return Object.entries(EMEX_LABELS)
|
return Object.entries(EMEX_LABELS)
|
||||||
.map(([key, label]) => ({ label, value: String(opts[key] ?? "").trim() }))
|
.map(([key, label]) => ({ label, value: String(opts[key] ?? "").trim() }))
|
||||||
@@ -375,8 +487,8 @@ const VINAPI_FIELDS: Array<[string, string]> = [
|
|||||||
["plantCountry", "Üretim ülkesi"],
|
["plantCountry", "Üretim ülkesi"],
|
||||||
];
|
];
|
||||||
|
|
||||||
function fromVinApi(vehicle: any): Attr[] {
|
function fromVinApi(vehicle: Vehicle): Attr[] {
|
||||||
const rd = vehicle?.rawData;
|
const rd = vehicle.rawData as RawData | null;
|
||||||
if (!rd) return [];
|
if (!rd) return [];
|
||||||
return VINAPI_FIELDS.map(([key, label]) => ({
|
return VINAPI_FIELDS.map(([key, label]) => ({
|
||||||
label,
|
label,
|
||||||
@@ -385,49 +497,52 @@ function fromVinApi(vehicle: any): Attr[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Plain DB columns — last resort when no source payload is recognised. */
|
/** Plain DB columns — last resort when no source payload is recognised. */
|
||||||
function getFallbackAttributes(vehicle: any): Attr[] {
|
function getFallbackAttributes(vehicle: Vehicle): Attr[] {
|
||||||
return [
|
return [
|
||||||
{ label: "Marka", value: vehicle?.brandName },
|
{ label: "Marka", value: vehicle.brandName },
|
||||||
{ label: "Model", value: vehicle?.model },
|
{ label: "Model", value: vehicle.model },
|
||||||
{ label: "Yıl", value: vehicle?.year },
|
{ label: "Yıl", value: vehicle.year },
|
||||||
{ label: "Motor", value: vehicle?.engine },
|
{ label: "Motor", value: vehicle.engine },
|
||||||
{ label: "Vites", value: vehicle?.transmission },
|
{ label: "Vites", value: vehicle.transmission },
|
||||||
{ label: "Kasa", value: vehicle?.bodyType },
|
{ label: "Kasa", value: vehicle.bodyType },
|
||||||
].filter(isAttr);
|
].filter(isAttr);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the attribute list for the vehicle's decode source. Each catalog
|
/** Resolve the attribute list for the vehicle's decode source. Each catalog
|
||||||
* ships its detail in a different shape; surface the richest one available. */
|
* 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.
|
// PL24 — readable extras lead, then the decoded VIN codes.
|
||||||
const vinfo = getVehicleAttributes(vehicle);
|
const vinfo = getVehicleAttributes(vehicle);
|
||||||
if (vinfo.length > 0) {
|
if (vinfo.length > 0) {
|
||||||
const extras = [
|
const extras = [
|
||||||
{ label: "Motor", value: vehicle?.engine },
|
{ label: "Motor", value: vehicle.engine },
|
||||||
{ label: "Çekiş", value: vehicle?.rawData?.driveType },
|
{ label: "Çekiş", value: rd?.driveType },
|
||||||
{ label: "Kasa", value: vehicle?.bodyType },
|
{ label: "Kasa", value: vehicle.bodyType },
|
||||||
].filter(isAttr);
|
].filter(isAttr);
|
||||||
return [...extras, ...vinfo];
|
return [...extras, ...vinfo];
|
||||||
}
|
}
|
||||||
|
|
||||||
// parts-catalogs — structured parameter list.
|
// parts-catalogs — structured parameter list.
|
||||||
if (vehicle?.source === "parts-catalogs") {
|
if (source === "parts-catalogs") {
|
||||||
const a = dropHeaderDupes(fromPcat(vehicle));
|
const a = dropHeaderDupes(fromPcat(vehicle));
|
||||||
if (a.length > 0) return a;
|
if (a.length > 0) return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
// emex — lead with any readable DB columns, then the option codes.
|
// emex — lead with any readable DB columns, then the option codes.
|
||||||
if (vehicle?.source === "emex") {
|
if (source === "emex") {
|
||||||
const dbExtras = [
|
const dbExtras = [
|
||||||
{ label: "Vites", value: vehicle?.transmission },
|
{ label: "Vites", value: vehicle.transmission },
|
||||||
{ label: "Kasa", value: vehicle?.bodyType },
|
{ label: "Kasa", value: vehicle.bodyType },
|
||||||
].filter(isAttr);
|
].filter(isAttr);
|
||||||
const a = dropHeaderDupes([...dbExtras, ...fromEmex(vehicle)]);
|
const a = dropHeaderDupes([...dbExtras, ...fromEmex(vehicle)]);
|
||||||
if (a.length > 0) return a;
|
if (a.length > 0) return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
// vin-api — flat readable fields.
|
// vin-api — flat readable fields.
|
||||||
if (vehicle?.source === "vin-api") {
|
if (source === "vin-api") {
|
||||||
const a = dropHeaderDupes(fromVinApi(vehicle));
|
const a = dropHeaderDupes(fromVinApi(vehicle));
|
||||||
if (a.length > 0) return a;
|
if (a.length > 0) return a;
|
||||||
}
|
}
|
||||||
@@ -435,13 +550,12 @@ function getDisplayAttributes(vehicle: any): Attr[] {
|
|||||||
return dropHeaderDupes(getFallbackAttributes(vehicle));
|
return dropHeaderDupes(getFallbackAttributes(vehicle));
|
||||||
}
|
}
|
||||||
|
|
||||||
function VehicleAttributes({ vehicle }: { vehicle: any }) {
|
function VehicleAttributes({ vehicle }: { vehicle: Vehicle }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const attrs = getDisplayAttributes(vehicle);
|
const attrs = getDisplayAttributes(vehicle);
|
||||||
|
|
||||||
if (attrs.length === 0) {
|
if (attrs.length === 0) {
|
||||||
return (
|
return <p className="text-sm text-muted-foreground">{t("vehicle.noAttrs")}</p>;
|
||||||
<p className="text-sm text-muted-foreground">Bu araç için ayrıntı bilgisi bulunamadı.</p>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -465,24 +579,23 @@ function VehicleAttributes({ vehicle }: { vehicle: any }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The engine code lives in a different spot per decode source. */
|
/** The engine code lives in a different spot per decode source. */
|
||||||
function getEngineCode(vehicle: any): string | undefined {
|
function getEngineCode(vehicle: Vehicle): string | undefined {
|
||||||
const rd = vehicle?.rawData;
|
const rd = vehicle.rawData as RawData | null;
|
||||||
if (rd?.engineCode) return rd.engineCode; // pl24
|
if (rd?.engineCode) return rd.engineCode; // pl24
|
||||||
if (rd?.parsedOptions?.engine_type) return rd.parsedOptions.engine_type; // emex
|
if (rd?.parsedOptions?.engine_type) return rd.parsedOptions.engine_type; // emex
|
||||||
if (rd?.engineModel) return rd.engineModel; // vin-api
|
if (rd?.engineModel) return rd.engineModel; // vin-api
|
||||||
// pcat — the "engine" parameter.
|
// pcat — the "engine" parameter.
|
||||||
const param = rd?.pcatCar?.parameters?.find(
|
const param = rd?.pcatCar?.parameters?.find((p) => p?.key === "engine" || p?.name === "Engine");
|
||||||
(p: any) => p?.key === "engine" || p?.name === "Engine",
|
|
||||||
);
|
|
||||||
return param?.value ? String(param.value) : undefined;
|
return param?.value ? String(param.value) : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compact at-a-glance fields shown on the collapsed Araç Bilgileri header. */
|
/** 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 = [
|
const fields = [
|
||||||
{ label: "Model", value: cleanModelName(vehicle?.model) },
|
{ label: t("vehicle.labelModel"), value: cleanModelName(vehicle.model) },
|
||||||
{ label: "Model yılı", value: vehicle?.year },
|
{ label: t("vehicle.labelYear"), value: vehicle.year },
|
||||||
{ label: "Motor kodu", value: getEngineCode(vehicle) },
|
{ label: t("vehicle.labelEngineCode"), value: getEngineCode(vehicle) },
|
||||||
].filter((f) => f.value != null && f.value !== "");
|
].filter((f) => f.value != null && f.value !== "");
|
||||||
|
|
||||||
if (fields.length === 0) return null;
|
if (fields.length === 0) return null;
|
||||||
@@ -512,30 +625,37 @@ interface Equipment {
|
|||||||
|
|
||||||
/** Decode the prNr (PR-code / equipment) segment — PL24 already ships a
|
/** Decode the prNr (PR-code / equipment) segment — PL24 already ships a
|
||||||
* readable description per code, so this is the equipment "dictionary". */
|
* readable description per code, so this is the equipment "dictionary". */
|
||||||
function getEquipment(vehicle: any): Equipment[] {
|
function getEquipment(vehicle: Vehicle): Equipment[] {
|
||||||
const records = vehicle?.rawData?.raw?.segments?.prNr?.records;
|
const rd = vehicle.rawData as RawData | null;
|
||||||
|
const records = rd?.raw?.segments?.prNr?.records;
|
||||||
if (!Array.isArray(records)) return [];
|
if (!Array.isArray(records)) return [];
|
||||||
return records
|
return records
|
||||||
.map((r: any) => ({
|
.map((r) => ({
|
||||||
code: (r.values?.col2 || "").trim(),
|
code: (r.values?.col2 || "").trim(),
|
||||||
group: cleanText(r.values?.col1 || ""),
|
group: cleanText(r.values?.col1 || ""),
|
||||||
desc: cleanText(r.values?.col3 || ""),
|
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. */
|
/** 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 items = getEquipment(vehicle);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const hasMore = items.length > EQUIPMENT_PREVIEW;
|
||||||
|
const visible = expanded || !hasMore ? items : items.slice(0, EQUIPMENT_PREVIEW);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 border-t border-border pt-4">
|
<div className="mt-6 border-t border-border pt-4">
|
||||||
<p className="mb-3 text-xs font-semibold text-muted-foreground">
|
<p className="mb-3 text-xs font-semibold text-muted-foreground">
|
||||||
Donanım kodları ({items.length})
|
{t("vehicle.equipmentTitle", { count: items.length })}
|
||||||
</p>
|
</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">
|
<ul className="grid grid-cols-1 gap-x-6 gap-y-2 sm:grid-cols-2">
|
||||||
{items.map((it, i) => (
|
{visible.map((it, i) => (
|
||||||
<li key={`${it.code}-${i}`} className="flex items-baseline gap-2">
|
<li key={`${it.code}-${i}`} className="flex items-baseline gap-2">
|
||||||
{it.code && (
|
{it.code && (
|
||||||
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] leading-tight text-muted-foreground">
|
<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>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</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 { SiteHeader } from "@/components/site-header";
|
||||||
import { usePageMeta } from "@/hooks/use-page-meta";
|
import { usePageMeta } from "@/hooks/use-page-meta";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import { capture } from "@/lib/posthog";
|
||||||
import { FULL_PLAN_BRAND_LIMIT, formatTRY } from "@sase/shared";
|
import { FULL_PLAN_BRAND_LIMIT, formatTRY } from "@sase/shared";
|
||||||
import { Button } from "@sase/ui";
|
import { Button } from "@sase/ui";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@sase/ui";
|
||||||
import { Badge } from "@sase/ui";
|
import { Badge } from "@sase/ui";
|
||||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
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")({
|
export const Route = createFileRoute("/pricing")({
|
||||||
component: PricingPage,
|
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() {
|
function PricingPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [period, setPeriod] = useState<BillingPeriod>("monthly");
|
||||||
|
|
||||||
usePageMeta({
|
usePageMeta({
|
||||||
title: t("pricing.pageTitle"),
|
title: t("pricing.pageTitle"),
|
||||||
@@ -61,6 +79,12 @@ function PricingPage() {
|
|||||||
canonical: "https://sase.tr/pricing",
|
canonical: "https://sase.tr/pricing",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function changePeriod(next: BillingPeriod) {
|
||||||
|
if (next === period) return;
|
||||||
|
setPeriod(next);
|
||||||
|
capture("pricing_period_toggled", { period: next });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
@@ -74,69 +98,57 @@ function PricingPage() {
|
|||||||
<p className="mt-4 text-lg text-muted-foreground">{t("pricing.heroSubtitle")}</p>
|
<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">
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-sm text-muted-foreground">
|
||||||
{[
|
{[
|
||||||
"pricing.trialFree",
|
{ key: "pricing.trialFree", icon: CalendarClock },
|
||||||
"subscription.trustNoCard",
|
{ key: "subscription.trustNoCard", icon: CreditCard },
|
||||||
"subscription.trustCancelAnytime",
|
{ key: "subscription.trustCancelAnytime", icon: RotateCcw },
|
||||||
"subscription.trustRefund",
|
{ key: "subscription.trustRefund", icon: Check },
|
||||||
].map((k) => (
|
].map(({ key, icon: Icon }) => (
|
||||||
<span key={k} className="flex items-center gap-1.5">
|
<span key={key} className="flex items-center gap-1.5">
|
||||||
<Check className="h-4 w-4 text-primary" />
|
<Icon className="h-4 w-4 text-primary" />
|
||||||
{t(k)}
|
{t(key)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</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">
|
<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" />
|
<Info className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||||
<span>{t("subscription.planHelper")}</span>
|
<span>{t("subscription.planHelper")}</span>
|
||||||
</div>
|
</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) => (
|
{plans.map((plan) => (
|
||||||
<Card
|
<PlanCard key={plan.key} plan={plan} period={period} />
|
||||||
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>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</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() {
|
export function PricingHowItWorks() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const steps = [1, 2, 3] as const;
|
const steps = [1, 2, 3] as const;
|
||||||
return (
|
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>
|
<h2 className="text-center text-2xl font-bold">{t("pricing.how.title")}</h2>
|
||||||
<div className="mt-8 grid gap-6 sm:grid-cols-3">
|
{/* Vertical timeline rail (md+) ditches the "3 equal cards" cliché.
|
||||||
{steps.map((i) => (
|
Each step alternates sides; the rail connects them. */}
|
||||||
<div key={i} className="rounded-2xl border border-border bg-muted/20 p-6 text-center">
|
<ol className="relative mt-10 space-y-10 md:space-y-12">
|
||||||
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full bg-primary font-bold text-primary-foreground">
|
<div
|
||||||
{i}
|
aria-hidden="true"
|
||||||
</div>
|
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"
|
||||||
<h3 className="mt-4 font-semibold">{t(`pricing.how.s${i}t`)}</h3>
|
/>
|
||||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
{steps.map((i) => {
|
||||||
{t(`pricing.how.s${i}d`)}
|
const isLeft = i % 2 === 1;
|
||||||
</p>
|
return (
|
||||||
</div>
|
<li
|
||||||
))}
|
key={i}
|
||||||
</div>
|
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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FAQ_IDS = [1, 2, 3, 4, 5, 6] as const;
|
||||||
|
|
||||||
export function PricingFaq() {
|
export function PricingFaq() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<section className="mx-auto mt-20 max-w-3xl">
|
<section className="mx-auto mt-20 max-w-3xl">
|
||||||
<h2 className="text-center text-2xl font-bold">{t("pricing.faq.title")}</h2>
|
<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">
|
<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">
|
<details key={i} className="group">
|
||||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-5 font-medium">
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-5 font-medium">
|
||||||
{t(`pricing.faq.q${i}`)}
|
{t(`pricing.faq.q${i}`)}
|
||||||
|
|||||||
Reference in New Issue
Block a user