Files
netrunner-landing/components/sections/Pricing.tsx
T
nineap 4b1ef9f951
Build & Push Image / Build and push to Gitea Registry (push) Successful in 2m28s
Цены тарифов — с бэкенда (регион/валюта), не из PocketBase
PocketBase pricing_{lang} остаётся источником переводного маркетингового
контента (название, тэглайн, описание, фичи, порядок) — но сами цифры
price1m/6m/1y теперь подменяются данными с GET /billing/plans нового
бэкенда (см. netrunner-backend::modules::geoip): валюта определяется по
IP посетителя, VPN/датацентр всегда получает базовый USD.

Соответствие тарифа PocketBase к тарифу бэкенда — по полю `name`
(ожидается "GHOST"/"NETRUNNER"/"MAINFRAME"). Бэкенд недоступен или для
конкретного тарифа не нашлось соответствия — тихий фолбэк на цену как
она есть в PocketBase, страница не ломается (тот же принцип, что уже
был у offline-кеша).
2026-07-11 00:55:15 +07:00

428 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect } from "react";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CheckIcon, Loader2 } from "lucide-react";
import { Dictionary } from "@/lib/IDict";
import { pb } from "@/lib/pb";
import { CyberButton } from "../ui/cyber-button";
import { cn } from "@/lib/utils";
interface PBPricingPlan {
id: string;
name: string;
level: string;
tagline: string;
description: string;
price1m: number;
price6m: number;
price1y: number;
features: string;
popular: boolean;
cta: string;
lang: string;
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 {
id: Period;
label: string;
badge?: string;
}
/**
* Секция тарифов. Маркетинговый контент (название, тэглайн, описание,
* фичи, порядок) — из 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;
/**
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
* app/[lang]/auth/auth-client.tsx для полного объяснения).
*/
pbUrl: string;
/** Адрес бэкенда (control plane) — тот же паттерн, что и pbUrl выше. */
apiUrl: string;
}) {
const [period, setPeriod] = useState<Period>("1y");
const [plans, setPlans] = useState<PBPricingPlan[]>([]);
const [loading, setLoading] = useState(!!lang);
// PocketBase недоступен целиком (не просто нет перевода для этого языка) —
// показываем последний успешно закэшированный список вместо пустой секции.
const [usingCache, setUsingCache] = useState(false);
const [currency, setCurrency] = useState("USD");
useEffect(() => {
if (!lang) return;
const cacheKey = `netrunner_pricing_cache_${lang}`;
async function fetchPlans() {
try {
setLoading(true);
pb.baseURL = pbUrl;
// CMS-контент переведён только для части языков — коллекция
// `pricing_<lang>` для новых языков ещё не заведена в PocketBase.
// Пока для них нет перевода, показываем английские тарифы, а не пустоту.
let records: PBPricingPlan[] = [];
let allFailed = true;
for (const fallbackLang of Array.from(new Set([lang, "en"]))) {
try {
records = await pb
.collection(`pricing_${fallbackLang}`)
.getFullList<PBPricingPlan>({
sort: "order",
requestKey: null,
});
allFailed = false;
break;
} catch (error) {
console.error(`Ошибка загрузки тарифов (${fallbackLang}):`, error);
}
}
if (allFailed) {
try {
const cached = localStorage.getItem(cacheKey);
if (cached) {
records = JSON.parse(cached);
setUsingCache(true);
}
} catch {
// localStorage недоступен (приватный режим и т.п.) — просто нет кеша.
}
} else {
setUsingCache(false);
if (records.length > 0) {
try {
localStorage.setItem(cacheKey, JSON.stringify(records));
} catch {
// Диск/квота localStorage — не критично, просто не кешируем.
}
}
}
// Реальные цены — с бэкенда, в валюте региона посетителя. Бэкенд
// недоступен/упал — просто оставляем цены как есть из 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, apiUrl]);
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 },
{ id: "6m", label: dict.period6m },
{
id: "1y",
label: dict.period1y,
badge: dict.recommend,
},
];
return (
<section
id="pricing"
className="py-32 px-4 container mx-auto max-w-7xl relative min-h-[600px]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,_var(--tw-gradient-stops))] from-secondary/10 via-transparent to-transparent -z-10" />
<div className="text-center mb-20 flex flex-col items-center">
<Badge
variant="outline"
className="mb-4 border-primary/30 text-primary uppercase tracking-widest font-mono text-[10px]"
>
{dict.badge}
</Badge>
<h2 className="text-4xl md:text-5xl font-bold tracking-tighter mb-4">
{dict.title}
</h2>
{usingCache && (
<p className="mb-4 text-xs font-mono uppercase tracking-widest text-muted-foreground/70">
{dict.offlineNotice}
</p>
)}
<div className="inline-flex items-center justify-center p-1.5 bg-card/50 border border-border/50 rounded-2xl backdrop-blur-sm relative">
{periods.map((p: PeriodOption) => (
<button
key={p.id}
onClick={() => setPeriod(p.id)}
className={`relative flex flex-col items-center justify-center gap-0.5 px-6 min-w-[120px] h-14 text-sm font-bold rounded-xl transition-all duration-300 ${
period === p.id
? "bg-primary text-primary-foreground shadow-lg shadow-primary/25"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span>{p.label}</span>
{p.badge && (
<span
className={`text-[9px] uppercase tracking-widest italic leading-none font-black ${
period === p.id
? "text-primary-foreground/80"
: "text-primary/70"
}`}
>
{p.badge}
</span>
)}
</button>
))}
</div>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center py-20 text-primary opacity-50">
<Loader2 className="animate-spin mb-4" />
<span className="text-sm uppercase font-mono tracking-widest">
{dict.syncing}
</span>
</div>
) : plans.length === 0 ? (
<p className="text-center text-muted-foreground font-mono">
{dict.empty}
</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 items-stretch">
{plans.map((plan) => {
const months = period === "1m" ? 1 : period === "6m" ? 6 : 12;
const totalPrice =
period === "1m"
? plan.price1m
: period === "6m"
? plan.price6m
: plan.price1y;
const baseMonthlyPrice = plan.price1m;
const monthlyEquivalent = Math.round(totalPrice / months);
const isDiscounted = monthlyEquivalent < baseMonthlyPrice;
const formatPrice = (price: number) =>
new Intl.NumberFormat(lang || "en").format(price);
return (
<Card
key={plan.id}
className={cn(
"relative flex flex-col h-full transition-all duration-300",
"cursor-net-grab active:cursor-net-grabbing", // <-- Новые курсоры
plan.popular
? "border-primary/50 shadow-[0_0_30px_-10px_rgba(var(--primary),0.3)] scale-[1.02] z-10"
: "border-border/50",
)}
>
{plan.popular && (
<div className="absolute top-0 right-10 -translate-y-1/2">
<Badge className="bg-primary text-primary-foreground rounded-full px-4 py-1 uppercase text-[10px] font-black tracking-widest shadow-lg shadow-primary/30">
{dict.recommend}
</Badge>
</div>
)}
<CardHeader className="pb-0 flex-none">
<div className="text-[10px] font-bold text-primary uppercase tracking-[0.2em] mb-2 h-4">
{plan.level}
</div>
<CardTitle className="text-3xl font-black text-foreground uppercase tracking-tight h-10 flex items-center">
{plan.name}
</CardTitle>
<div className="text-sm font-medium italic text-muted-foreground mt-4 border-l-2 border-primary/30 pl-3 min-h-[40px] flex items-center">
«{plan.tagline}»
</div>
<CardDescription className="mt-4 text-sm leading-relaxed min-h-[60px]">
{plan.description}
</CardDescription>
<div className="mt-6 mb-4 flex flex-col min-h-[110px] justify-center">
<div className="flex items-center gap-2 h-6 mb-1">
{isDiscounted && (
<>
<span className="text-sm text-muted-foreground/50 line-through decoration-primary/50 font-mono">
{symbolAfterAmount ? "" : currencySign}
{formatPrice(baseMonthlyPrice)}
{symbolAfterAmount ? currencySign : ""}
</span>
<Badge
variant="outline"
className="bg-primary/10 text-primary border-primary/30 text-[10px] px-2 py-0.5 font-bold tracking-wider"
>
{dict.discount}{" "}
{Math.round(
(1 - monthlyEquivalent / baseMonthlyPrice) * 100,
)}
%
</Badge>
</>
)}
</div>
<div className="flex items-baseline gap-1">
<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"}`}
>
{symbolAfterAmount ? "" : currencySign}
{formatPrice(monthlyEquivalent)}
</span>
{symbolAfterAmount && (
<span className="text-3xl font-bold ml-1 text-muted-foreground">
{currencySign}
</span>
)}
<span className="text-muted-foreground text-sm font-bold ml-2">
{dict.mo}
</span>
</div>
<div className="h-8 mt-4 flex items-center">
{period !== "1m" && (
<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">
{symbolAfterAmount ? "" : currencySign}
{formatPrice(totalPrice)}
{symbolAfterAmount ? currencySign : ""}
</span>
</div>
)}
</div>
</div>
</CardHeader>
<CardContent className="flex-1 pb-8">
<ul className="space-y-4 pt-6 border-t border-border/20">
{plan.features
.split("\n")
.filter((f) => f.trim())
.map((feature, i) => (
<li
key={i}
className="flex items-start gap-3 text-sm text-foreground/80 group"
>
<div className="bg-primary/10 w-5 h-5 rounded-full flex items-center justify-center mt-0.5 shrink-0 transition-colors group-hover:bg-primary/20">
<CheckIcon className="w-3 h-3 text-primary stroke-[3]" />
</div>
<span className="leading-tight font-medium">
{feature.trim()}
</span>
</li>
))}
</ul>
</CardContent>
<CardFooter className="pt-0">
<CyberButton
href="#"
variant={plan.popular ? "primary" : "secondary"}
className="w-full"
>
{plan.cta}
</CyberButton>
</CardFooter>
</Card>
);
})}
</div>
)}
</section>
);
}