refactor(web): brand selector controlled state + search; trial banner urgency tiers + progress
BrandSelector - Now fully controlled — drops the internal `selected` state that could drift from the prop source of truth - Full-plan auto-select effect compares against the live selection and only fires when out of sync, removing the infinite-loop risk if parents passed an unstable onSelectionChange (now also ref-stashed) - Search input appears when there are more than 12 brands; Turkish-locale case-folding so "Volkswagen"/"volkswagen" both match - No-match state for empty search results TrialUrgencyBanner - Three urgency tiers (soft 7d, urgent ≤3d, critical ≤1d) — colors and tighter copy escalate as the trial winds down - Adds an elapsed-time progress bar so "3 days left" reads as 90% used at a glance (number alone undersells urgency at the tail) - LS dismissal key now userId-scoped (mirrors subscription page) - Banner emits `tier` alongside days for funnel segmentation
This commit is contained in:
@@ -6,8 +6,8 @@ import { Badge } from "@sase/ui";
|
||||
import { Skeleton } from "@sase/ui";
|
||||
import { cn } from "@sase/ui";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Check } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
@@ -30,39 +30,54 @@ export function BrandSelector({
|
||||
isFullPlan = false,
|
||||
}: BrandSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<string[]>(selectedBrandIds);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const { data: brands, isLoading } = useQuery({
|
||||
queryKey: ["brands"],
|
||||
queryFn: () => api.get<Brand[]>("/brands"),
|
||||
});
|
||||
|
||||
// Stash the callback so the full-plan auto-select effect doesn't refire on
|
||||
// every parent render — the parent may pass a fresh function each time.
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
useEffect(() => {
|
||||
setSelected(selectedBrandIds);
|
||||
}, [selectedBrandIds]);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
}, [onSelectionChange]);
|
||||
|
||||
// For the full plan, mirror every brand into the parent's selection exactly
|
||||
// once per `brands` payload (not per render). Bail if it's already aligned.
|
||||
useEffect(() => {
|
||||
if (isFullPlan && brands) {
|
||||
const allIds = brands.map((b) => b.id);
|
||||
setSelected(allIds);
|
||||
onSelectionChange(allIds);
|
||||
if (!isFullPlan || !brands) return;
|
||||
const allIds = brands.map((b) => b.id);
|
||||
if (
|
||||
allIds.length === selectedBrandIds.length &&
|
||||
allIds.every((id) => selectedBrandIds.includes(id))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [isFullPlan, brands, onSelectionChange]);
|
||||
onSelectionChangeRef.current(allIds);
|
||||
}, [isFullPlan, brands, selectedBrandIds]);
|
||||
|
||||
function toggleBrand(brandId: string) {
|
||||
if (isFullPlan) return;
|
||||
|
||||
const isCurrentlySelected = selectedBrandIds.includes(brandId);
|
||||
let next: string[];
|
||||
if (selected.includes(brandId)) {
|
||||
next = selected.filter((id) => id !== brandId);
|
||||
if (isCurrentlySelected) {
|
||||
next = selectedBrandIds.filter((id) => id !== brandId);
|
||||
} else {
|
||||
if (selected.length >= maxBrands) return;
|
||||
next = [...selected, brandId];
|
||||
if (selectedBrandIds.length >= maxBrands) return;
|
||||
next = [...selectedBrandIds, brandId];
|
||||
}
|
||||
setSelected(next);
|
||||
onSelectionChange(next);
|
||||
}
|
||||
|
||||
const filteredBrands = useMemo(() => {
|
||||
if (!brands) return [];
|
||||
const q = query.trim().toLocaleLowerCase("tr");
|
||||
if (!q) return brands;
|
||||
return brands.filter((b) => b.name.toLocaleLowerCase("tr").includes(q));
|
||||
}, [brands, query]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
@@ -77,59 +92,92 @@ export function BrandSelector({
|
||||
return <p className="text-sm text-muted-foreground">{t("common.noData")}</p>;
|
||||
}
|
||||
|
||||
const isMaxReached = !isFullPlan && selected.length >= maxBrands;
|
||||
const isMaxReached = !isFullPlan && selectedBrandIds.length >= maxBrands;
|
||||
const showSearch = !isFullPlan && brands.length > 12;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">{t("subscription.selectBrands")}</p>
|
||||
<Badge variant={isFullPlan ? "default" : "secondary"}>
|
||||
<Badge variant={isFullPlan ? "default" : "secondary"} className="tabular-nums">
|
||||
{isFullPlan
|
||||
? t("subscription.allBrandsSelected")
|
||||
: `${selected.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
|
||||
: `${selectedBrandIds.length}/${maxBrands} ${t("subscription.brandsSelected")}`}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{isMaxReached && (
|
||||
<p className="text-xs text-amber-600">{t("subscription.maxBrandsReached")}</p>
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("subscription.brandSearchPlaceholder")}
|
||||
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-9 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||
aria-label={t("subscription.brandSearchPlaceholder")}
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
aria-label={t("common.cancel")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{brands.map((brand) => {
|
||||
const isSelected = selected.includes(brand.id) || isFullPlan;
|
||||
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
|
||||
{isMaxReached && (
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400">
|
||||
{t("subscription.maxBrandsReached")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={brand.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-all hover:shadow-md",
|
||||
isSelected && "border-primary ring-2 ring-primary/20",
|
||||
isDisabled && "cursor-not-allowed opacity-50",
|
||||
isFullPlan && "cursor-default",
|
||||
)}
|
||||
onClick={() => !isDisabled && toggleBrand(brand.id)}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center p-4">
|
||||
<div className="relative">
|
||||
<CarBrandLogo
|
||||
brandName={brand.name}
|
||||
logoUrl={brand.logoUrl}
|
||||
size={48}
|
||||
className="mb-2"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-center text-sm font-medium">{brand.name}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredBrands.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
|
||||
{t("subscription.brandSearchNoMatch", { query })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{filteredBrands.map((brand) => {
|
||||
const isSelected = selectedBrandIds.includes(brand.id) || isFullPlan;
|
||||
const isDisabled = !isSelected && isMaxReached && !isFullPlan;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={brand.id}
|
||||
className={cn(
|
||||
"cursor-pointer transition-all hover:border-primary/40",
|
||||
isSelected && "border-primary ring-2 ring-primary/20",
|
||||
isDisabled && "cursor-not-allowed opacity-50",
|
||||
isFullPlan && "cursor-default",
|
||||
)}
|
||||
onClick={() => !isDisabled && toggleBrand(brand.id)}
|
||||
>
|
||||
<CardContent className="flex flex-col items-center justify-center p-4">
|
||||
<div className="relative">
|
||||
<CarBrandLogo
|
||||
brandName={brand.name}
|
||||
logoUrl={brand.logoUrl}
|
||||
size={48}
|
||||
className="mb-2"
|
||||
/>
|
||||
{isSelected && (
|
||||
<div className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-center text-sm font-medium">{brand.name}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { capture } from "@/lib/posthog";
|
||||
import { Button } from "@sase/ui";
|
||||
@@ -15,14 +16,77 @@ interface Subscription {
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
const DISMISS_STORAGE_PREFIX = "trialBannerDismissed";
|
||||
// Show the banner inside the final week. Two tighter tiers (3, 1 days) push the
|
||||
// visual urgency without spamming earlier.
|
||||
const VISIBLE_AT_DAYS = 7;
|
||||
const URGENT_AT_DAYS = 3;
|
||||
const CRITICAL_AT_DAYS = 1;
|
||||
const TRIAL_TOTAL_DAYS = 30;
|
||||
|
||||
function getDismissKey(endDate: string): string {
|
||||
return `${DISMISS_STORAGE_PREFIX}-${endDate}`;
|
||||
type Tier = "soft" | "urgent" | "critical";
|
||||
|
||||
function tierFor(days: number): Tier {
|
||||
if (days <= CRITICAL_AT_DAYS) return "critical";
|
||||
if (days <= URGENT_AT_DAYS) return "urgent";
|
||||
return "soft";
|
||||
}
|
||||
|
||||
// Per-tier classes. Kept inline (not via tokens) because they map to brand
|
||||
// urgency colors that don't yet exist in @theme — Phase-5 token sweep can
|
||||
// move these to bg-warning / bg-destructive.
|
||||
const tierClasses: Record<
|
||||
Tier,
|
||||
{
|
||||
bar: string;
|
||||
cta: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
body: string;
|
||||
close: string;
|
||||
track: string;
|
||||
fill: string;
|
||||
}
|
||||
> = {
|
||||
soft: {
|
||||
bar: "border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/40",
|
||||
cta: "bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400",
|
||||
icon: "text-amber-600 dark:text-amber-400",
|
||||
title: "text-amber-900 dark:text-amber-100",
|
||||
body: "text-amber-700 dark:text-amber-300",
|
||||
close: "text-amber-600 hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-900",
|
||||
track: "bg-amber-200/60 dark:bg-amber-800/40",
|
||||
fill: "bg-amber-500 dark:bg-amber-400",
|
||||
},
|
||||
urgent: {
|
||||
bar: "border-orange-300 bg-orange-50 dark:border-orange-800 dark:bg-orange-950/40",
|
||||
cta: "bg-orange-600 text-white hover:bg-orange-700 dark:bg-orange-500 dark:text-orange-950 dark:hover:bg-orange-400",
|
||||
icon: "text-orange-600 dark:text-orange-400",
|
||||
title: "text-orange-900 dark:text-orange-100",
|
||||
body: "text-orange-700 dark:text-orange-300",
|
||||
close: "text-orange-600 hover:bg-orange-100 dark:text-orange-400 dark:hover:bg-orange-900",
|
||||
track: "bg-orange-200/60 dark:bg-orange-800/40",
|
||||
fill: "bg-orange-500 dark:bg-orange-400",
|
||||
},
|
||||
critical: {
|
||||
bar: "border-destructive/40 bg-destructive/5",
|
||||
cta: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
icon: "text-destructive",
|
||||
title: "text-foreground",
|
||||
body: "text-muted-foreground",
|
||||
close: "text-destructive/80 hover:bg-destructive/10",
|
||||
track: "bg-destructive/15",
|
||||
fill: "bg-destructive",
|
||||
},
|
||||
};
|
||||
|
||||
function trialBannerKey(userId: string | null | undefined, endDate: string): string {
|
||||
return `trialBannerDismissed-${userId ?? "anon"}-${endDate}`;
|
||||
}
|
||||
|
||||
export function TrialUrgencyBanner() {
|
||||
const { t } = useTranslation();
|
||||
const { data: session } = useSession();
|
||||
const userId = session?.user?.id ?? null;
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const viewedRef = useRef(false);
|
||||
@@ -41,8 +105,8 @@ export function TrialUrgencyBanner() {
|
||||
subscription?.status === "trial" && endDate
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
||||
: null;
|
||||
const visible = !isLoading && days !== null && days <= 7;
|
||||
const dismissKey = endDate ? getDismissKey(endDate) : null;
|
||||
const visible = !isLoading && days !== null && days <= VISIBLE_AT_DAYS;
|
||||
const dismissKey = endDate ? trialBannerKey(userId, endDate) : null;
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -56,30 +120,35 @@ export function TrialUrgencyBanner() {
|
||||
}
|
||||
}, [mounted, dismissKey]);
|
||||
|
||||
const tier: Tier | null = days !== null ? tierFor(days) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || dismissed || viewedRef.current || days === null) return;
|
||||
if (!visible || dismissed || viewedRef.current || days === null || !tier) return;
|
||||
viewedRef.current = true;
|
||||
capture("trial_urgency_banner_viewed", {
|
||||
days_remaining: days,
|
||||
tier,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
}, [visible, dismissed, days, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||
}, [visible, dismissed, days, tier, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||
|
||||
const handleCTAClick = useCallback(() => {
|
||||
if (days === null) return;
|
||||
capture("trial_urgency_banner_cta_clicked", {
|
||||
days_remaining: days,
|
||||
tier,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
}, [days, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||
}, [days, tier, subscription?.plan?.name, subscription?.billingPeriod]);
|
||||
|
||||
if (!visible || dismissed || days === null || !dismissKey) return null;
|
||||
if (!visible || dismissed || days === null || !dismissKey || !tier) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
capture("trial_urgency_banner_dismissed", {
|
||||
days_remaining: days,
|
||||
tier,
|
||||
plan_name: subscription?.plan?.name,
|
||||
billing_period: subscription?.billingPeriod,
|
||||
});
|
||||
@@ -87,41 +156,59 @@ export function TrialUrgencyBanner() {
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-center gap-3 border-b border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-950 sm:gap-4 sm:px-6"
|
||||
>
|
||||
<AlertTriangle className="size-5 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
// Progress bar reflects elapsed share of the trial — a number alone undersells
|
||||
// urgency at e.g. day 27/30, while the bar reads visually as "nearly done".
|
||||
const elapsed = Math.max(0, Math.min(TRIAL_TOTAL_DAYS, TRIAL_TOTAL_DAYS - days));
|
||||
const elapsedPct = Math.round((elapsed / TRIAL_TOTAL_DAYS) * 100);
|
||||
const titleKey =
|
||||
tier === "critical"
|
||||
? "subscription.trialBanner.titleCritical"
|
||||
: tier === "urgent"
|
||||
? "subscription.trialBanner.titleUrgent"
|
||||
: "subscription.trialBanner.title";
|
||||
const c = tierClasses[tier];
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||
{t("subscription.trialBanner.title")}
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{t("subscription.trialBanner.description", { days })}
|
||||
</p>
|
||||
return (
|
||||
<div role="alert" className={`flex flex-col gap-2 border-b px-4 py-3 sm:px-6 ${c.bar}`}>
|
||||
<div className="flex items-start gap-3 sm:items-center">
|
||||
<AlertTriangle className={`mt-0.5 size-5 shrink-0 sm:mt-0 ${c.icon}`} />
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 sm:flex-row sm:items-center sm:gap-4">
|
||||
<div>
|
||||
<p className={`text-sm font-semibold ${c.title}`}>{t(titleKey, { days })}</p>
|
||||
<p className={`text-xs ${c.body}`}>
|
||||
{t("subscription.trialBanner.description", { days })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
|
||||
<Button size="sm" className={c.cta}>
|
||||
{t("subscription.trialBanner.cta")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link to="/dashboard/subscription" className="shrink-0" onClick={handleCTAClick}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:text-amber-950 dark:hover:bg-amber-400"
|
||||
>
|
||||
{t("subscription.trialBanner.cta")}
|
||||
</Button>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors ${c.close}`}
|
||||
aria-label={t("common.close")}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="flex size-8 shrink-0 items-center justify-center rounded-md text-amber-600 transition-colors hover:bg-amber-100 dark:text-amber-400 dark:hover:bg-amber-900"
|
||||
aria-label={t("common.close")}
|
||||
<div
|
||||
className={`h-1 w-full overflow-hidden rounded-full ${c.track}`}
|
||||
role="progressbar"
|
||||
tabIndex={-1}
|
||||
aria-valuenow={elapsedPct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={t("subscription.trialBanner.progressLabel", { percent: elapsedPct })}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
<div className={`h-full transition-all ${c.fill}`} style={{ width: `${elapsedPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,6 +234,8 @@
|
||||
"brandsSelected": "brands selected",
|
||||
"allBrandsSelected": "All selected",
|
||||
"maxBrandsReached": "Maximum number of brands reached.",
|
||||
"brandSearchPlaceholder": "Search brand — e.g. Volkswagen",
|
||||
"brandSearchNoMatch": "No brand matches \"{query}\".",
|
||||
"features": {
|
||||
"vinSearch": "Unlimited VIN search",
|
||||
"partsCatalog": "Parts catalog",
|
||||
@@ -317,9 +319,12 @@
|
||||
"paymentTrustKVKK": "KVKK compliant",
|
||||
"trialDaysLeft": "{days} days left",
|
||||
"trialBanner": {
|
||||
"title": "Your trial is ending soon!",
|
||||
"title": "Your trial is ending soon",
|
||||
"titleUrgent": "Only {days} days left",
|
||||
"titleCritical": "Last day — access ends tomorrow",
|
||||
"description": "{days} days left. Upgrade now to continue accessing all features.",
|
||||
"cta": "Upgrade Plan"
|
||||
"cta": "Upgrade Plan",
|
||||
"progressLabel": "{percent}% of trial used"
|
||||
},
|
||||
"trialProgress": {
|
||||
"title": "{days} days left in your Full Package trial",
|
||||
|
||||
@@ -234,6 +234,8 @@
|
||||
"brandsSelected": "marka seçildi",
|
||||
"allBrandsSelected": "Tümü seçildi",
|
||||
"maxBrandsReached": "Maksimum marka sayısına ulaştınız.",
|
||||
"brandSearchPlaceholder": "Marka ara — ör. Volkswagen",
|
||||
"brandSearchNoMatch": "\"{query}\" ile eşleşen marka yok.",
|
||||
"features": {
|
||||
"vinSearch": "Sınırsız VIN arama",
|
||||
"partsCatalog": "Parça kataloğu",
|
||||
@@ -317,9 +319,12 @@
|
||||
"paymentTrustKVKK": "KVKK uyumlu",
|
||||
"trialDaysLeft": "{days} gün kaldı",
|
||||
"trialBanner": {
|
||||
"title": "Deneme süreniz dolmak üzere!",
|
||||
"title": "Denemen bitmek üzere",
|
||||
"titleUrgent": "Sadece {days} gün kaldı",
|
||||
"titleCritical": "Son gün — yarın erişimini kaybedeceksin",
|
||||
"description": "{days} gününüz kaldı. Tüm özelliklere erişmeye devam etmek için hemen yükseltin.",
|
||||
"cta": "Plana Yükselt"
|
||||
"cta": "Plana Yükselt",
|
||||
"progressLabel": "Denemenin %{percent}'i kullanıldı"
|
||||
},
|
||||
"trialProgress": {
|
||||
"title": "Full Paket denemende {days} gün kaldı",
|
||||
|
||||
Reference in New Issue
Block a user