Цены тарифов — с бэкенда (регион/валюта), не из PocketBase
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-кеша).
This commit is contained in:
2026-07-11 00:55:15 +07:00
parent d9b2e24b8e
commit 4b1ef9f951
2 changed files with 105 additions and 14 deletions
+7 -1
View File
@@ -48,12 +48,18 @@ export default async function Home({
// запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN). // запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN).
const pbUrl = const pbUrl =
process.env.PB_PUBLIC_URL || "https://netrunner-vpn.com/cms-api"; 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 ( return (
<> <>
<Hero dict={dict.hero} lang={lang} /> <Hero dict={dict.hero} lang={lang} />
<Features dict={dict.features} /> <Features dict={dict.features} />
<Pricing dict={dict.pricing} lang={lang} pbUrl={pbUrl} /> <Pricing dict={dict.pricing} lang={lang} pbUrl={pbUrl} apiUrl={apiUrl} />
<TerminalGuestbook dict={dict.guestbook} pbUrl={pbUrl} /> <TerminalGuestbook dict={dict.guestbook} pbUrl={pbUrl} />
<Suspense fallback={<SectionSkeleton />}> <Suspense fallback={<SectionSkeleton />}>
+98 -13
View File
@@ -32,6 +32,36 @@ interface PBPricingPlan {
order: number; 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"; type Period = "1m" | "6m" | "1y";
interface PeriodOption { 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({ export function Pricing({
dict, dict,
lang, lang,
pbUrl, pbUrl,
apiUrl,
}: { }: {
dict: Dictionary["pricing"]; dict: Dictionary["pricing"];
lang: string; lang: string;
@@ -59,6 +101,8 @@ export function Pricing({
* app/[lang]/auth/auth-client.tsx для полного объяснения). * app/[lang]/auth/auth-client.tsx для полного объяснения).
*/ */
pbUrl: string; pbUrl: string;
/** Адрес бэкенда (control plane) — тот же паттерн, что и pbUrl выше. */
apiUrl: string;
}) { }) {
const [period, setPeriod] = useState<Period>("1y"); const [period, setPeriod] = useState<Period>("1y");
const [plans, setPlans] = useState<PBPricingPlan[]>([]); const [plans, setPlans] = useState<PBPricingPlan[]>([]);
@@ -66,6 +110,7 @@ export function Pricing({
// PocketBase недоступен целиком (не просто нет перевода для этого языка) — // PocketBase недоступен целиком (не просто нет перевода для этого языка) —
// показываем последний успешно закэшированный список вместо пустой секции. // показываем последний успешно закэшированный список вместо пустой секции.
const [usingCache, setUsingCache] = useState(false); const [usingCache, setUsingCache] = useState(false);
const [currency, setCurrency] = useState("USD");
useEffect(() => { useEffect(() => {
if (!lang) return; 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); setPlans(records);
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
fetchPlans(); 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[] = [ const periods: PeriodOption[] = [
{ id: "1m", label: dict.period1m }, { id: "1m", label: dict.period1m },
@@ -213,7 +298,7 @@ export function Pricing({
const monthlyEquivalent = Math.round(totalPrice / months); const monthlyEquivalent = Math.round(totalPrice / months);
const isDiscounted = monthlyEquivalent < baseMonthlyPrice; const isDiscounted = monthlyEquivalent < baseMonthlyPrice;
const formatPrice = (price: number) => const formatPrice = (price: number) =>
lang === "ru" ? price.toLocaleString("ru-RU") : price.toString(); new Intl.NumberFormat(lang || "en").format(price);
return ( return (
<Card <Card
@@ -253,9 +338,9 @@ export function Pricing({
{isDiscounted && ( {isDiscounted && (
<> <>
<span className="text-sm text-muted-foreground/50 line-through decoration-primary/50 font-mono"> <span className="text-sm text-muted-foreground/50 line-through decoration-primary/50 font-mono">
{lang === "ru" ? "" : currencySign} {symbolAfterAmount ? "" : currencySign}
{formatPrice(baseMonthlyPrice)} {formatPrice(baseMonthlyPrice)}
{lang === "ru" ? currencySign : ""} {symbolAfterAmount ? currencySign : ""}
</span> </span>
<Badge <Badge
variant="outline" variant="outline"
@@ -274,10 +359,10 @@ export function Pricing({
<span <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"}`} 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} {symbolAfterAmount ? "" : currencySign}
{formatPrice(monthlyEquivalent)} {formatPrice(monthlyEquivalent)}
</span> </span>
{lang === "ru" && ( {symbolAfterAmount && (
<span className="text-3xl font-bold ml-1 text-muted-foreground"> <span className="text-3xl font-bold ml-1 text-muted-foreground">
{currencySign} {currencySign}
</span> </span>
@@ -292,9 +377,9 @@ export function Pricing({
<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"> <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>{dict.billedOnce}</span>
<span className="font-bold text-foreground"> <span className="font-bold text-foreground">
{lang === "ru" ? "" : currencySign} {symbolAfterAmount ? "" : currencySign}
{formatPrice(totalPrice)} {formatPrice(totalPrice)}
{lang === "ru" ? currencySign : ""} {symbolAfterAmount ? currencySign : ""}
</span> </span>
</div> </div>
)} )}