32b4460b66
Добавляет zh-CN, fa, ar, tr, uk, be, vi, uz словари (170 ключей, идентичная
вложенная структура) к существующим en/ru. lib/locales.ts — единый источник
списка локалей/дефолтной локали/RTL-набора вместо захардкоженного ["en","ru"]
в proxy.ts. language-switcher.tsx: переключение языка резало только
двухбуквенные коды регэкспом (/^\/[a-z]{2}/), из-за чего zh-CN никогда не
матчился и путь задваивался — заменено на honest-поиск по списку locales.
app/[lang]/layout.tsx — dir="rtl"/"ltr" на <html> по локали (SSR, в отличие
от реактивного клиентского переключения в netrunner-app). Header.tsx/
Footer.tsx — разбитое на три <span> имя бренда визуально переворачивалось
Flexbox'ом под dir=rtl ("VPNrunnerNet"), исправлено через dir="ltr" на
самом враппере.
BlogPreview/Faq/Pricing и обе blog-страницы: fallback на *_en-коллекцию
PocketBase, если для языка ещё нет переведённого контента (FAQ-ответы,
детали тарифов, посты блога не тянутся автоматически — это отдельный от
статических словарей CMS-контент), плюс i18n-ключи period1m/period6m/
period1y вместо захардкоженных ru/en-подписей периодов в Pricing.tsx.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
308 lines
12 KiB
TypeScript
308 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import {
|
||
Card,
|
||
CardHeader,
|
||
CardTitle,
|
||
CardDescription,
|
||
CardContent,
|
||
CardFooter,
|
||
} from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { CheckIcon, Loader2 } from "lucide-react";
|
||
import { Dictionary } from "@/lib/IDict";
|
||
import { pb } from "@/lib/pb";
|
||
import { CyberButton } from "../ui/cyber-button";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
interface PBPricingPlan {
|
||
id: string;
|
||
name: string;
|
||
level: string;
|
||
tagline: string;
|
||
description: string;
|
||
price1m: number;
|
||
price6m: number;
|
||
price1y: number;
|
||
features: string;
|
||
popular: boolean;
|
||
cta: string;
|
||
lang: string;
|
||
order: number;
|
||
}
|
||
|
||
type Period = "1m" | "6m" | "1y";
|
||
|
||
interface PeriodOption {
|
||
id: Period;
|
||
label: string;
|
||
badge?: string;
|
||
}
|
||
|
||
/**
|
||
* Секция тарифов. Тарифные планы приходят из PocketBase (`pricing_{lang}`) с ценами
|
||
* сразу на три периода (price1m/price6m/price1y) — компонент на клиенте пересчитывает
|
||
* "цену в месяц" для выбранного периода и подсвечивает скидку, если помесячная цена
|
||
* при длинном периоде ниже базовой месячной ставки.
|
||
*/
|
||
export function Pricing({
|
||
dict,
|
||
lang,
|
||
pbUrl,
|
||
}: {
|
||
dict: Dictionary["pricing"];
|
||
lang: string;
|
||
/**
|
||
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
|
||
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
|
||
* app/[lang]/auth/auth-client.tsx для полного объяснения).
|
||
*/
|
||
pbUrl: string;
|
||
}) {
|
||
const [period, setPeriod] = useState<Period>("1y");
|
||
const [plans, setPlans] = useState<PBPricingPlan[]>([]);
|
||
const [loading, setLoading] = useState(!!lang);
|
||
|
||
useEffect(() => {
|
||
if (!lang) return;
|
||
async function fetchPlans() {
|
||
try {
|
||
setLoading(true);
|
||
pb.baseURL = pbUrl;
|
||
// CMS-контент переведён только для части языков — коллекция
|
||
// `pricing_<lang>` для новых языков ещё не заведена в PocketBase.
|
||
// Пока для них нет перевода, показываем английские тарифы, а не пустоту.
|
||
let records: PBPricingPlan[] = [];
|
||
for (const fallbackLang of Array.from(new Set([lang, "en"]))) {
|
||
try {
|
||
records = await pb
|
||
.collection(`pricing_${fallbackLang}`)
|
||
.getFullList<PBPricingPlan>({
|
||
sort: "order",
|
||
requestKey: null,
|
||
});
|
||
break;
|
||
} catch (error) {
|
||
console.error(`Ошибка загрузки тарифов (${fallbackLang}):`, error);
|
||
}
|
||
}
|
||
setPlans(records);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
fetchPlans();
|
||
}, [lang, pbUrl]);
|
||
|
||
const currencySign = lang === "ru" ? "₽" : "$";
|
||
|
||
const periods: PeriodOption[] = [
|
||
{ id: "1m", label: dict.period1m },
|
||
{ id: "6m", label: dict.period6m },
|
||
{
|
||
id: "1y",
|
||
label: dict.period1y,
|
||
badge: dict.recommend,
|
||
},
|
||
];
|
||
|
||
return (
|
||
<section
|
||
id="pricing"
|
||
className="py-32 px-4 container mx-auto max-w-7xl relative min-h-[600px]"
|
||
>
|
||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,_var(--tw-gradient-stops))] from-secondary/10 via-transparent to-transparent -z-10" />
|
||
|
||
<div className="text-center mb-20 flex flex-col items-center">
|
||
<Badge
|
||
variant="outline"
|
||
className="mb-4 border-primary/30 text-primary uppercase tracking-widest font-mono text-[10px]"
|
||
>
|
||
{dict.badge}
|
||
</Badge>
|
||
<h2 className="text-4xl md:text-5xl font-bold tracking-tighter mb-8">
|
||
{dict.title}
|
||
</h2>
|
||
|
||
<div className="inline-flex items-center justify-center p-1.5 bg-card/50 border border-border/50 rounded-2xl backdrop-blur-sm relative">
|
||
{periods.map((p: PeriodOption) => (
|
||
<button
|
||
key={p.id}
|
||
onClick={() => setPeriod(p.id)}
|
||
className={`relative flex flex-col items-center justify-center gap-0.5 px-6 min-w-[120px] h-14 text-sm font-bold rounded-xl transition-all duration-300 ${
|
||
period === p.id
|
||
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
|
||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||
}`}
|
||
>
|
||
<span>{p.label}</span>
|
||
{p.badge && (
|
||
<span
|
||
className={`text-[9px] uppercase tracking-widest italic leading-none font-black ${
|
||
period === p.id
|
||
? "text-primary-foreground/80"
|
||
: "text-primary/70"
|
||
}`}
|
||
>
|
||
{p.badge}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex flex-col items-center justify-center py-20 text-primary opacity-50">
|
||
<Loader2 className="animate-spin mb-4" />
|
||
<span className="text-sm uppercase font-mono tracking-widest">
|
||
{dict.syncing}
|
||
</span>
|
||
</div>
|
||
) : plans.length === 0 ? (
|
||
<p className="text-center text-muted-foreground font-mono">
|
||
{dict.empty}
|
||
</p>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-stretch">
|
||
{plans.map((plan) => {
|
||
const months = period === "1m" ? 1 : period === "6m" ? 6 : 12;
|
||
const totalPrice =
|
||
period === "1m"
|
||
? plan.price1m
|
||
: period === "6m"
|
||
? plan.price6m
|
||
: plan.price1y;
|
||
const baseMonthlyPrice = plan.price1m;
|
||
const monthlyEquivalent = Math.round(totalPrice / months);
|
||
const isDiscounted = monthlyEquivalent < baseMonthlyPrice;
|
||
const formatPrice = (price: number) =>
|
||
lang === "ru" ? price.toLocaleString("ru-RU") : price.toString();
|
||
|
||
return (
|
||
<Card
|
||
key={plan.id}
|
||
className={cn(
|
||
"relative flex flex-col h-full transition-all duration-300",
|
||
"cursor-net-grab active:cursor-net-grabbing", // <-- Новые курсоры
|
||
plan.popular
|
||
? "border-primary/50 shadow-[0_0_30px_-10px_rgba(var(--primary),0.3)] scale-[1.02] z-10"
|
||
: "border-border/50",
|
||
)}
|
||
>
|
||
{plan.popular && (
|
||
<div className="absolute top-0 right-10 -translate-y-1/2">
|
||
<Badge className="bg-primary text-primary-foreground rounded-full px-4 py-1 uppercase text-[10px] font-black tracking-widest shadow-lg shadow-primary/30">
|
||
{dict.recommend}
|
||
</Badge>
|
||
</div>
|
||
)}
|
||
|
||
<CardHeader className="pb-0 flex-none">
|
||
<div className="text-[10px] font-bold text-primary uppercase tracking-[0.2em] mb-2 h-4">
|
||
{plan.level}
|
||
</div>
|
||
<CardTitle className="text-3xl font-black text-foreground uppercase tracking-tight h-10 flex items-center">
|
||
{plan.name}
|
||
</CardTitle>
|
||
<div className="text-sm font-medium italic text-muted-foreground mt-4 border-l-2 border-primary/30 pl-3 min-h-[40px] flex items-center">
|
||
«{plan.tagline}»
|
||
</div>
|
||
<CardDescription className="mt-4 text-sm leading-relaxed min-h-[60px]">
|
||
{plan.description}
|
||
</CardDescription>
|
||
|
||
<div className="mt-6 mb-4 flex flex-col min-h-[110px] justify-center">
|
||
<div className="flex items-center gap-2 h-6 mb-1">
|
||
{isDiscounted && (
|
||
<>
|
||
<span className="text-sm text-muted-foreground/50 line-through decoration-primary/50 font-mono">
|
||
{lang === "ru" ? "" : currencySign}
|
||
{formatPrice(baseMonthlyPrice)}
|
||
{lang === "ru" ? currencySign : ""}
|
||
</span>
|
||
<Badge
|
||
variant="outline"
|
||
className="bg-primary/10 text-primary border-primary/30 text-[10px] px-2 py-0.5 font-bold tracking-wider"
|
||
>
|
||
{dict.discount}{" "}
|
||
{Math.round(
|
||
(1 - monthlyEquivalent / baseMonthlyPrice) * 100,
|
||
)}
|
||
%
|
||
</Badge>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="flex items-baseline gap-1">
|
||
<span
|
||
className={`text-5xl font-black tracking-tighter font-mono ${plan.popular ? "text-foreground drop-shadow-[0_0_15px_rgba(var(--primary),0.3)]" : "text-foreground"}`}
|
||
>
|
||
{lang === "ru" ? "" : currencySign}
|
||
{formatPrice(monthlyEquivalent)}
|
||
</span>
|
||
{lang === "ru" && (
|
||
<span className="text-3xl font-bold ml-1 text-muted-foreground">
|
||
{currencySign}
|
||
</span>
|
||
)}
|
||
<span className="text-muted-foreground text-sm font-bold ml-2">
|
||
{dict.mo}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="h-8 mt-4 flex items-center">
|
||
{period !== "1m" && (
|
||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-md bg-muted/40 border border-border/50 text-[11px] text-muted-foreground font-mono uppercase tracking-wider">
|
||
<span>{dict.billedOnce}</span>
|
||
<span className="font-bold text-foreground">
|
||
{lang === "ru" ? "" : currencySign}
|
||
{formatPrice(totalPrice)}
|
||
{lang === "ru" ? currencySign : ""}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent className="flex-1 pb-8">
|
||
<ul className="space-y-4 pt-6 border-t border-border/20">
|
||
{plan.features
|
||
.split("\n")
|
||
.filter((f) => f.trim())
|
||
.map((feature, i) => (
|
||
<li
|
||
key={i}
|
||
className="flex items-start gap-3 text-sm text-foreground/80 group"
|
||
>
|
||
<div className="bg-primary/10 w-5 h-5 rounded-full flex items-center justify-center mt-0.5 shrink-0 transition-colors group-hover:bg-primary/20">
|
||
<CheckIcon className="w-3 h-3 text-primary stroke-[3]" />
|
||
</div>
|
||
<span className="leading-tight font-medium">
|
||
{feature.trim()}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</CardContent>
|
||
|
||
<CardFooter className="pt-0">
|
||
<CyberButton
|
||
href="#"
|
||
variant={plan.popular ? "primary" : "secondary"}
|
||
className="w-full"
|
||
>
|
||
{plan.cta}
|
||
</CyberButton>
|
||
</CardFooter>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|