Цены тарифов — с бэкенда (регион/валюта), не из PocketBase
Build & Push Image / Build and push to Gitea Registry (push) Successful in 2m28s
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:
+7
-1
@@ -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 (
|
||||
<>
|
||||
<Hero dict={dict.hero} lang={lang} />
|
||||
<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} />
|
||||
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user