Files
netrunner-landing/components/sections/Pricing.tsx
nineap 899c54e1fc Unify auth with backend cookie sessions; add dev-deploy pipeline
Auth: drop localStorage session token entirely, talk to netrunner-backend
via credentials:"include" HttpOnly cookies shared over the common parent
domain, and redirect to the account subdomain (ЛК) after login instead of
a dead local /profile route.

Config: NEXT_PUBLIC_* values used by client components (API_URL,
ACCOUNT_ORIGIN, BOT_USERNAME, PB_URL) get inlined into the JS bundle at
image build time and can't be overridden by docker-compose environment at
container start. Moved these reads into server components (page.tsx) and
thread them down as props, so one built image can genuinely serve both
prod and dev by config alone.

CI/CD: split the old single auto-deploy-to-prod workflow into build.yml
(build+push on every push to main) and deploy.yml with deploy-dev (auto,
same VPS as netrunner-backend's dev environment) and deploy-prod (manual
dispatch only) — mirrors netrunner-backend's pipeline shape. Added
docker-compose.dev-remote.yml pointing at the dev backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:26:28 +07:00

299 lines
12 KiB
TypeScript
Raw Permalink 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;
}
type Period = "1m" | "6m" | "1y";
interface PeriodOption {
id: Period;
label: string;
badge?: string;
}
/**
* Секция тарифов. Тарифные планы приходят из PocketBase (`pricing_{lang}`) с ценами
* сразу на три периода (price1m/price6m/price1y) — компонент на клиенте пересчитывает
* "цену в месяц" для выбранного периода и подсвечивает скидку, если помесячная цена
* при длинном периоде ниже базовой месячной ставки.
*/
export function Pricing({
dict,
lang,
pbUrl,
}: {
dict: Dictionary["pricing"];
lang: string;
/**
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
* app/[lang]/auth/auth-client.tsx для полного объяснения).
*/
pbUrl: string;
}) {
const [period, setPeriod] = useState<Period>("1y");
const [plans, setPlans] = useState<PBPricingPlan[]>([]);
const [loading, setLoading] = useState(!!lang);
useEffect(() => {
if (!lang) return;
async function fetchPlans() {
try {
setLoading(true);
pb.baseURL = pbUrl;
const records = await pb
.collection(`pricing_${lang}`)
.getFullList<PBPricingPlan>({
sort: "order",
requestKey: null,
});
setPlans(records);
} catch (error) {
console.error("Ошибка загрузки тарифов:", error);
} finally {
setLoading(false);
}
}
fetchPlans();
}, [lang, pbUrl]);
const currencySign = lang === "ru" ? "₽" : "$";
const periods: PeriodOption[] = [
{ id: "1m", label: lang === "ru" ? "1 месяц" : "1 month" },
{ id: "6m", label: lang === "ru" ? "6 месяцев" : "6 months" },
{
id: "1y",
label: lang === "ru" ? "1 год" : "1 year",
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-8">
{dict.title}
</h2>
<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) =>
lang === "ru" ? price.toLocaleString("ru-RU") : price.toString();
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">
{lang === "ru" ? "" : currencySign}
{formatPrice(baseMonthlyPrice)}
{lang === "ru" ? 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"}`}
>
{lang === "ru" ? "" : currencySign}
{formatPrice(monthlyEquivalent)}
</span>
{lang === "ru" && (
<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">
{lang === "ru" ? "" : currencySign}
{formatPrice(totalPrice)}
{lang === "ru" ? 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>
);
}