Цены тарифов — с бэкенда (регион/валюта), не из 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
+98 -13
View File
@@ -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<string, string> = {
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<Period>("1y");
const [plans, setPlans] = useState<PBPricingPlan[]>([]);
@@ -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 (
<Card
@@ -253,9 +338,9 @@ export function Pricing({
{isDiscounted && (
<>
<span className="text-sm text-muted-foreground/50 line-through decoration-primary/50 font-mono">
{lang === "ru" ? "" : currencySign}
{symbolAfterAmount ? "" : currencySign}
{formatPrice(baseMonthlyPrice)}
{lang === "ru" ? currencySign : ""}
{symbolAfterAmount ? currencySign : ""}
</span>
<Badge
variant="outline"
@@ -274,10 +359,10 @@ export function Pricing({
<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}
{symbolAfterAmount ? "" : currencySign}
{formatPrice(monthlyEquivalent)}
</span>
{lang === "ru" && (
{symbolAfterAmount && (
<span className="text-3xl font-bold ml-1 text-muted-foreground">
{currencySign}
</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">
<span>{dict.billedOnce}</span>
<span className="font-bold text-foreground">
{lang === "ru" ? "" : currencySign}
{symbolAfterAmount ? "" : currencySign}
{formatPrice(totalPrice)}
{lang === "ru" ? currencySign : ""}
{symbolAfterAmount ? currencySign : ""}
</span>
</div>
)}