Цены тарифов — с бэкенда (регион/валюта), не из 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).
|
// запросе (см. подробный комментарий в 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 />}>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user