"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 = { 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("1y"); const [plans, setPlans] = useState([]); 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_` для новых языков ещё не заведена в 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({ 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 (
{dict.badge}

{dict.title}

{usingCache && (

{dict.offlineNotice}

)}
{periods.map((p: PeriodOption) => ( ))}
{loading ? (
{dict.syncing}
) : plans.length === 0 ? (

{dict.empty}

) : (
{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 ( {plan.popular && (
{dict.recommend}
)}
{plan.level}
{plan.name}
«{plan.tagline}»
{plan.description}
{isDiscounted && ( <> {symbolAfterAmount ? "" : currencySign} {formatPrice(baseMonthlyPrice)} {symbolAfterAmount ? currencySign : ""} {dict.discount}{" "} {Math.round( (1 - monthlyEquivalent / baseMonthlyPrice) * 100, )} % )}
{symbolAfterAmount ? "" : currencySign} {formatPrice(monthlyEquivalent)} {symbolAfterAmount && ( {currencySign} )} {dict.mo}
{period !== "1m" && (
{dict.billedOnce} {symbolAfterAmount ? "" : currencySign} {formatPrice(totalPrice)} {symbolAfterAmount ? currencySign : ""}
)}
    {plan.features .split("\n") .filter((f) => f.trim()) .map((feature, i) => (
  • {feature.trim()}
  • ))}
{plan.cta}
); })}
)}
); }