899c54e1fc
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>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|