diff --git a/app/[lang]/page.tsx b/app/[lang]/page.tsx index d6cc120..d2a34d0 100644 --- a/app/[lang]/page.tsx +++ b/app/[lang]/page.tsx @@ -48,12 +48,18 @@ export default async function Home({ // запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN). const pbUrl = process.env.PB_PUBLIC_URL || "https://netrunner-vpn.com/cms-api"; + // Те же соображения про NEXT_PUBLIC_ vs обычную переменную, что и у pbUrl + // выше (см. комментарий) — см. также app/[lang]/auth/page.tsx, тот же + // паттерн для того же самого бэкенда. + const accountOrigin = + process.env.ACCOUNT_ORIGIN || "https://account.netrunner-vpn.com"; + const apiUrl = process.env.API_URL || `${accountOrigin}/api/v1`; return ( <> - + }> diff --git a/components/sections/Pricing.tsx b/components/sections/Pricing.tsx index 78084d3..79ddb56 100644 --- a/components/sections/Pricing.tsx +++ b/components/sections/Pricing.tsx @@ -32,6 +32,36 @@ interface PBPricingPlan { 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 { @@ -41,15 +71,27 @@ interface PeriodOption { } /** - * Секция тарифов. Тарифные планы приходят из PocketBase (`pricing_{lang}`) с ценами - * сразу на три периода (price1m/price6m/price1y) — компонент на клиенте пересчитывает - * "цену в месяц" для выбранного периода и подсвечивает скидку, если помесячная цена - * при длинном периоде ниже базовой месячной ставки. + * Секция тарифов. Маркетинговый контент (название, тэглайн, описание, + * фичи, порядок) — из 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; @@ -59,6 +101,8 @@ export function Pricing({ * app/[lang]/auth/auth-client.tsx для полного объяснения). */ pbUrl: string; + /** Адрес бэкенда (control plane) — тот же паттерн, что и pbUrl выше. */ + apiUrl: string; }) { const [period, setPeriod] = useState("1y"); const [plans, setPlans] = useState([]); @@ -66,6 +110,7 @@ export function Pricing({ // PocketBase недоступен целиком (не просто нет перевода для этого языка) — // показываем последний успешно закэшированный список вместо пустой секции. const [usingCache, setUsingCache] = useState(false); + const [currency, setCurrency] = useState("USD"); useEffect(() => { if (!lang) return; @@ -116,15 +161,55 @@ export function Pricing({ } } + // Реальные цены — с бэкенда, в валюте региона посетителя. Бэкенд + // недоступен/упал — просто оставляем цены как есть из 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]); + }, [lang, pbUrl, apiUrl]); - const currencySign = lang === "ru" ? "₽" : "$"; + 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 }, @@ -213,7 +298,7 @@ export function Pricing({ const monthlyEquivalent = Math.round(totalPrice / months); const isDiscounted = monthlyEquivalent < baseMonthlyPrice; const formatPrice = (price: number) => - lang === "ru" ? price.toLocaleString("ru-RU") : price.toString(); + new Intl.NumberFormat(lang || "en").format(price); return ( - {lang === "ru" ? "" : currencySign} + {symbolAfterAmount ? "" : currencySign} {formatPrice(baseMonthlyPrice)} - {lang === "ru" ? currencySign : ""} + {symbolAfterAmount ? currencySign : ""} - {lang === "ru" ? "" : currencySign} + {symbolAfterAmount ? "" : currencySign} {formatPrice(monthlyEquivalent)} - {lang === "ru" && ( + {symbolAfterAmount && ( {currencySign} @@ -292,9 +377,9 @@ export function Pricing({
{dict.billedOnce} - {lang === "ru" ? "" : currencySign} + {symbolAfterAmount ? "" : currencySign} {formatPrice(totalPrice)} - {lang === "ru" ? currencySign : ""} + {symbolAfterAmount ? currencySign : ""}
)}