4b1ef9f951
Build & Push Image / Build and push to Gitea Registry (push) Successful in 2m28s
PocketBase pricing_{lang} остаётся источником переводного маркетингового
контента (название, тэглайн, описание, фичи, порядок) — но сами цифры
price1m/6m/1y теперь подменяются данными с GET /billing/plans нового
бэкенда (см. netrunner-backend::modules::geoip): валюта определяется по
IP посетителя, VPN/датацентр всегда получает базовый USD.
Соответствие тарифа PocketBase к тарифу бэкенда — по полю `name`
(ожидается "GHOST"/"NETRUNNER"/"MAINFRAME"). Бэкенд недоступен или для
конкретного тарифа не нашлось соответствия — тихий фолбэк на цену как
она есть в PocketBase, страница не ломается (тот же принцип, что уже
был у offline-кеша).
428 lines
18 KiB
TypeScript
428 lines
18 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;
|
||
}
|
||
|
||
interface BackendPlan {
|
||
plan: string; // "GHOST_1M", "NETRUNNER_6M", "MAINFRAME_12M" и т.п.
|
||
amount: number; // минорные единицы (центы/копейки), кроме WHOLE_UNIT_CURRENCIES
|
||
}
|
||
|
||
interface BackendPlansResponse {
|
||
currency: string;
|
||
detected_country: string | null;
|
||
vpn_detected: boolean;
|
||
plans: BackendPlan[];
|
||
}
|
||
|
||
// У IRR/VND/UZS нет ходовой разменной монеты — amount там уже целыми
|
||
// единицами, не центами/копейками (см. modules/geoip/currency.rs на
|
||
// бэкенде — та же логика, значения синхронизированы вручную, это два
|
||
// независимых рантайма).
|
||
const WHOLE_UNIT_CURRENCIES = new Set(["IRR", "VND", "UZS"]);
|
||
|
||
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||
USD: "$",
|
||
RUB: "₽",
|
||
CNY: "¥",
|
||
TRY: "₺",
|
||
IRR: "﷼",
|
||
UAH: "₴",
|
||
BYN: "Br",
|
||
VND: "₫",
|
||
UZS: "сум",
|
||
};
|
||
|
||
type Period = "1m" | "6m" | "1y";
|
||
|
||
interface PeriodOption {
|
||
id: Period;
|
||
label: string;
|
||
badge?: string;
|
||
}
|
||
|
||
/**
|
||
* Секция тарифов. Маркетинговый контент (название, тэглайн, описание,
|
||
* фичи, порядок) — из PocketBase (`pricing_{lang}`), переведён по языкам.
|
||
* Сами ЦИФРЫ (price1m/6m/1y) — из бэкенда (`GET /billing/plans`), одного
|
||
* источника правды на весь проект (см. modules/billing на бэкенде):
|
||
* бэкенд же определяет региональную валюту по IP посетителя и режет
|
||
* VPN/датацентр-трафик на базовый USD. Если бэкенд недоступен или для
|
||
* какого-то тарифа не нашлось соответствия по имени — используются цены
|
||
* из самого PocketBase как есть (graceful degradation, та же логика, что
|
||
* уже была для offline-кеша ниже).
|
||
*
|
||
* Соответствие между записью PocketBase и тарифами бэкенда — по полю
|
||
* `name` (ожидается "GHOST"/"NETRUNNER"/"MAINFRAME", регистронезависимо),
|
||
* плюс суффикс периода ("_1M"/"_6M"/"_12M"). Если контент-мейкеры в
|
||
* PocketBase когда-нибудь заведут `name`, не совпадающее с этим — просто
|
||
* тихо не смерджится для этого тарифа, страница не сломается.
|
||
*/
|
||
export function Pricing({
|
||
dict,
|
||
lang,
|
||
pbUrl,
|
||
apiUrl,
|
||
}: {
|
||
dict: Dictionary["pricing"];
|
||
lang: string;
|
||
/**
|
||
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
|
||
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
|
||
* app/[lang]/auth/auth-client.tsx для полного объяснения).
|
||
*/
|
||
pbUrl: string;
|
||
/** Адрес бэкенда (control plane) — тот же паттерн, что и pbUrl выше. */
|
||
apiUrl: string;
|
||
}) {
|
||
const [period, setPeriod] = useState<Period>("1y");
|
||
const [plans, setPlans] = useState<PBPricingPlan[]>([]);
|
||
const [loading, setLoading] = useState(!!lang);
|
||
// PocketBase недоступен целиком (не просто нет перевода для этого языка) —
|
||
// показываем последний успешно закэшированный список вместо пустой секции.
|
||
const [usingCache, setUsingCache] = useState(false);
|
||
const [currency, setCurrency] = useState("USD");
|
||
|
||
useEffect(() => {
|
||
if (!lang) return;
|
||
const cacheKey = `netrunner_pricing_cache_${lang}`;
|
||
|
||
async function fetchPlans() {
|
||
try {
|
||
setLoading(true);
|
||
pb.baseURL = pbUrl;
|
||
// CMS-контент переведён только для части языков — коллекция
|
||
// `pricing_<lang>` для новых языков ещё не заведена в PocketBase.
|
||
// Пока для них нет перевода, показываем английские тарифы, а не пустоту.
|
||
let records: PBPricingPlan[] = [];
|
||
let allFailed = true;
|
||
for (const fallbackLang of Array.from(new Set([lang, "en"]))) {
|
||
try {
|
||
records = await pb
|
||
.collection(`pricing_${fallbackLang}`)
|
||
.getFullList<PBPricingPlan>({
|
||
sort: "order",
|
||
requestKey: null,
|
||
});
|
||
allFailed = false;
|
||
break;
|
||
} catch (error) {
|
||
console.error(`Ошибка загрузки тарифов (${fallbackLang}):`, error);
|
||
}
|
||
}
|
||
|
||
if (allFailed) {
|
||
try {
|
||
const cached = localStorage.getItem(cacheKey);
|
||
if (cached) {
|
||
records = JSON.parse(cached);
|
||
setUsingCache(true);
|
||
}
|
||
} catch {
|
||
// localStorage недоступен (приватный режим и т.п.) — просто нет кеша.
|
||
}
|
||
} else {
|
||
setUsingCache(false);
|
||
if (records.length > 0) {
|
||
try {
|
||
localStorage.setItem(cacheKey, JSON.stringify(records));
|
||
} catch {
|
||
// Диск/квота localStorage — не критично, просто не кешируем.
|
||
}
|
||
}
|
||
}
|
||
|
||
// Реальные цены — с бэкенда, в валюте региона посетителя. Бэкенд
|
||
// недоступен/упал — просто оставляем цены как есть из PocketBase
|
||
// (не блокируем страницу).
|
||
try {
|
||
const res = await fetch(`${apiUrl}/billing/plans`, {
|
||
cache: "no-store",
|
||
});
|
||
if (res.ok) {
|
||
const data: BackendPlansResponse = await res.json();
|
||
setCurrency(data.currency);
|
||
records = records.map((plan) => {
|
||
const tier = plan.name.trim().toUpperCase();
|
||
const findAmount = (suffix: string) =>
|
||
data.plans.find((p) => p.plan === `${tier}_${suffix}`)?.amount;
|
||
|
||
const toDisplay = (amount: number | undefined, fallback: number) => {
|
||
if (amount === undefined) return fallback;
|
||
return WHOLE_UNIT_CURRENCIES.has(data.currency)
|
||
? amount
|
||
: amount / 100;
|
||
};
|
||
|
||
return {
|
||
...plan,
|
||
price1m: toDisplay(findAmount("1M"), plan.price1m),
|
||
price6m: toDisplay(findAmount("6M"), plan.price6m),
|
||
price1y: toDisplay(findAmount("12M"), plan.price1y),
|
||
};
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("Не удалось получить цены с бэкенда:", error);
|
||
}
|
||
|
||
setPlans(records);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
fetchPlans();
|
||
}, [lang, pbUrl, apiUrl]);
|
||
|
||
const currencySign = CURRENCY_SYMBOLS[currency] ?? "$";
|
||
// Валюты справа от числа (как рубль в исходном UI), не слева — только
|
||
// если так исторически было для RUB; остальные региональные — слева,
|
||
// как доллар, кроме "Br"/"сум" (буквенные, тоже справа — иначе "Br199"
|
||
// читается странно).
|
||
const symbolAfterAmount =
|
||
currency === "RUB" || currency === "BYN" || currency === "UZS";
|
||
|
||
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-4">
|
||
{dict.title}
|
||
</h2>
|
||
|
||
{usingCache && (
|
||
<p className="mb-4 text-xs font-mono uppercase tracking-widest text-muted-foreground/70">
|
||
{dict.offlineNotice}
|
||
</p>
|
||
)}
|
||
|
||
<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) =>
|
||
new Intl.NumberFormat(lang || "en").format(price);
|
||
|
||
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">
|
||
{symbolAfterAmount ? "" : currencySign}
|
||
{formatPrice(baseMonthlyPrice)}
|
||
{symbolAfterAmount ? 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"}`}
|
||
>
|
||
{symbolAfterAmount ? "" : currencySign}
|
||
{formatPrice(monthlyEquivalent)}
|
||
</span>
|
||
{symbolAfterAmount && (
|
||
<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">
|
||
{symbolAfterAmount ? "" : currencySign}
|
||
{formatPrice(totalPrice)}
|
||
{symbolAfterAmount ? 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>
|
||
);
|
||
}
|