Initial commit
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Check, Lock, Sparkles } from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import GlowButton from "@/components/ui/GlowButton";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Locale } from "@/lib/dictionaries";
|
||||
import type { PlansDict } from "./pricing";
|
||||
|
||||
interface PlansContentProps {
|
||||
locale: Locale;
|
||||
plans: PlansDict;
|
||||
}
|
||||
|
||||
export default function PlansContent({ locale, plans }: PlansContentProps) {
|
||||
const t = plans;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="relative isolate flex min-h-[40svh] items-center overflow-hidden pt-32">
|
||||
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_30%,transparent_70%)] opacity-30" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={stagger(0.1)}
|
||||
className="mx-auto max-w-3xl text-center"
|
||||
>
|
||||
<motion.span
|
||||
variants={fadeUp}
|
||||
className="mb-6 inline-flex items-center gap-2 rounded-full border border-purple-400/30 bg-purple-500/10 px-3.5 py-1.5 font-mono text-xs tracking-tight text-purple-300 backdrop-blur-md"
|
||||
>
|
||||
<Lock className="h-3 w-3" strokeWidth={2} />
|
||||
{t.badge}
|
||||
</motion.span>
|
||||
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-xl font-medium tracking-tight"
|
||||
>
|
||||
<span className="text-gradient-fade">{t.title}</span>{" "}
|
||||
<span className="text-gradient-purple">{t.titleAccent}</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg"
|
||||
>
|
||||
{t.subtitle}
|
||||
</motion.p>
|
||||
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mx-auto mt-4 max-w-xl font-mono text-xs text-text-secondary/70"
|
||||
>
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Project tiers */}
|
||||
<section className="relative w-full pb-12 pt-16 sm:pb-16">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<motion.h2
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={fadeUp}
|
||||
className="mb-10 text-center font-display text-display-lg font-medium tracking-tight text-gradient-fade"
|
||||
>
|
||||
{t.tiersTitle}
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="grid grid-cols-1 gap-5 lg:grid-cols-3"
|
||||
>
|
||||
{t.tiers.map((tier) => (
|
||||
<motion.div key={tier.name} variants={fadeUp} className="group h-full">
|
||||
<GlassCard
|
||||
variant={tier.highlighted ? "strong" : "default"}
|
||||
className={`relative flex h-full flex-col p-8 ${
|
||||
tier.highlighted
|
||||
? "border-purple-400/40 shadow-glow-md"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{tier.highlighted && (
|
||||
<span className="absolute -top-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-purple-400/40 bg-purple-500/20 px-3 py-1 font-mono text-[10px] uppercase tracking-widest text-purple-200 backdrop-blur-md">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t.popularBadge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-medium text-text-primary">
|
||||
{tier.name}
|
||||
</h3>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<span className="font-display text-3xl font-medium tracking-tight text-text-primary">
|
||||
{tier.price}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{tier.period}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm leading-relaxed text-text-secondary">
|
||||
{tier.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-2.5">
|
||||
{tier.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/15 text-purple-300">
|
||||
<Check className="h-3 w-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
<span className="text-text-secondary">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Care plans */}
|
||||
<section className="relative w-full py-12 sm:py-16">
|
||||
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.06)}
|
||||
className="mx-auto mb-10 max-w-2xl text-center"
|
||||
>
|
||||
<motion.h2
|
||||
variants={fadeUp}
|
||||
className="font-display text-display-lg font-medium tracking-tight text-gradient-fade"
|
||||
>
|
||||
{t.careTitle}
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="mt-4 text-base leading-relaxed text-text-secondary"
|
||||
>
|
||||
{t.careSubtitle}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="grid grid-cols-1 gap-5 sm:grid-cols-3"
|
||||
>
|
||||
{t.care.map((plan) => (
|
||||
<motion.div key={plan.name} variants={fadeUp} className="h-full">
|
||||
<GlassCard className="flex h-full flex-col p-6">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-base font-medium text-text-primary">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<span className="font-display text-xl font-medium tracking-tight text-purple-300">
|
||||
{plan.price}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-5 space-y-2.5">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2.5 text-sm">
|
||||
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-500/15 text-purple-300">
|
||||
<Check className="h-3 w-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
<span className="text-text-secondary">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Disclaimer + CTA */}
|
||||
<section className="relative w-full pb-24 pt-8 sm:pb-32">
|
||||
<div className="relative mx-auto w-full max-w-3xl px-6 text-center lg:px-8">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.08)}
|
||||
>
|
||||
<motion.p
|
||||
variants={fadeUp}
|
||||
className="text-sm leading-relaxed text-text-secondary"
|
||||
>
|
||||
{t.disclaimer}
|
||||
</motion.p>
|
||||
<motion.div variants={fadeUp} className="mt-8 flex justify-center">
|
||||
<GlowButton
|
||||
href={`/${locale}/contact`}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
{t.cta}
|
||||
</GlowButton>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { locales, type Locale } from "@/lib/dictionaries";
|
||||
import { getPlanPage, getPlanPages, getPlansDict } from "./pricing";
|
||||
import PlansContent from "./PlansContent";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export function generateStaticParams() {
|
||||
return locales.flatMap((locale) =>
|
||||
getPlanPages().map((page) => ({ locale, code: page.code })),
|
||||
);
|
||||
}
|
||||
|
||||
export const dynamicParams = false;
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const isSR = locale === "sr";
|
||||
return {
|
||||
title: isSR ? "Paketi i cijene" : "Plans & pricing",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
noarchive: true,
|
||||
nosnippet: true,
|
||||
googleBot: { index: false, follow: false },
|
||||
},
|
||||
alternates: { canonical: null },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PlansPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string; code: string }>;
|
||||
}) {
|
||||
const { locale, code } = await params;
|
||||
|
||||
const page = getPlanPage(code);
|
||||
if (!page) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<PlansContent
|
||||
locale={locale as Locale}
|
||||
plans={getPlansDict(locale, page)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
export type Price = number | string;
|
||||
|
||||
export interface PlanPage {
|
||||
code: string;
|
||||
landing: Price;
|
||||
webApp: Price;
|
||||
care: [Price, Price, Price];
|
||||
}
|
||||
|
||||
export function getPlanPages(): PlanPage[] {
|
||||
const raw = process.env.PLANS_PAGES;
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter(
|
||||
(p): p is PlanPage =>
|
||||
!!p && typeof p.code === "string" && Array.isArray(p.care),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"PLANS_PAGES is not valid JSON — no pricing pages will render.",
|
||||
err,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlanPage(code: string): PlanPage | null {
|
||||
return getPlanPages().find((p) => p.code === code) ?? null;
|
||||
}
|
||||
|
||||
function euro(value: Price, isSR: boolean): string {
|
||||
if (typeof value !== "number") return value;
|
||||
const n = new Intl.NumberFormat(isSR ? "sr-RS" : "en-US", {
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
return `€${n}`;
|
||||
}
|
||||
|
||||
function fromPrice(value: Price, isSR: boolean): string {
|
||||
if (typeof value !== "number") return value;
|
||||
return `${isSR ? "od " : "from "}${euro(value, isSR)}`;
|
||||
}
|
||||
|
||||
function carePrice(value: Price, isSR: boolean): string {
|
||||
if (typeof value !== "number") return value;
|
||||
return `${euro(value, isSR)}${isSR ? "/mj" : "/mo"}`;
|
||||
}
|
||||
|
||||
export function getPlansDict(locale: string, page: PlanPage) {
|
||||
const isSR = locale === "sr";
|
||||
|
||||
const landing = fromPrice(page.landing, isSR);
|
||||
const webApp = fromPrice(page.webApp, isSR);
|
||||
const care = page.care.map((c) => carePrice(c, isSR)) as [
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
];
|
||||
|
||||
if (isSR) {
|
||||
return {
|
||||
badge: "Za klijente",
|
||||
title: "Paketi i",
|
||||
titleAccent: "cijene.",
|
||||
subtitle:
|
||||
"Okvirne početne cijene za najčešće tipove projekata. Svaki projekat dobija prilagođenu ponudu nakon kratkog razgovora — ova stranica služi da dobijete realan okvir.",
|
||||
tiersTitle: "Cijene projekata",
|
||||
popularBadge: "Najtraženije",
|
||||
tiers: [
|
||||
{
|
||||
name: "Landing / prezentacioni sajt",
|
||||
price: landing,
|
||||
period: "jednokratno",
|
||||
description:
|
||||
"Brz, SEO-spreman sajt za vaš biznis — dizajniran, izrađen, deployovan i hostovan.",
|
||||
features: [
|
||||
"Custom dizajn, responzivan na svim uređajima",
|
||||
"SEO podešavanje, analitika, kontakt forma",
|
||||
"Deploy sa SSL-om na upravljanoj infrastrukturi",
|
||||
"Isporuka za 1–2 sedmice",
|
||||
],
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Web aplikacija",
|
||||
price: webApp,
|
||||
period: "jednokratno",
|
||||
description:
|
||||
"Custom web aplikacija sa backendom, bazom podataka i admin panelom — prilagođena vašem načinu rada.",
|
||||
features: [
|
||||
"Frontend, backend i dizajn baze podataka",
|
||||
"Autentifikacija i upravljanje ulogama",
|
||||
"Admin panel i interni alati",
|
||||
"CI/CD, monitoring i backup uključeni",
|
||||
"Isporuka za 4–8 sedmica u zavisnosti od obima",
|
||||
],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: "Custom platforma",
|
||||
price: "po dogovoru",
|
||||
period: "zajedničko planiranje",
|
||||
description:
|
||||
"Veći proizvodi: e-commerce, SaaS, mobilna + web aplikacija. Obim i cijena se definišu nakon analize.",
|
||||
features: [
|
||||
"Sve iz paketa Web aplikacija",
|
||||
"Mobilna aplikacija (React Native) po potrebi",
|
||||
"Plaćanja, integracije, eksterni API-ji",
|
||||
"Dugoročna razvojna saradnja",
|
||||
],
|
||||
highlighted: false,
|
||||
},
|
||||
],
|
||||
careTitle: "Mjesečni paketi održavanja",
|
||||
careSubtitle:
|
||||
"Opcioni mjesečni paketi nakon lansiranja, da vaš projekat ostane brz, siguran i dostupan.",
|
||||
care: [
|
||||
{
|
||||
name: "Hosting",
|
||||
price: care[0],
|
||||
features: [
|
||||
"Upravljani hosting sa SSL-om",
|
||||
"Dnevni backup",
|
||||
"Monitoring dostupnosti",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Standard",
|
||||
price: care[1],
|
||||
features: [
|
||||
"Sve iz paketa Hosting",
|
||||
"Izmjene sadržaja i manje dorade",
|
||||
"Sigurnosna ažuriranja",
|
||||
"Prioritetna email podrška",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Priority",
|
||||
price: care[2],
|
||||
features: [
|
||||
"Sve iz paketa Standard",
|
||||
"Mjesečni sati za razvoj funkcionalnosti",
|
||||
"Odgovor isti dan",
|
||||
"Pregledi performansi",
|
||||
],
|
||||
},
|
||||
],
|
||||
disclaimer:
|
||||
"Cijene su početne, ne fiksne ponude — konačna cijena zavisi od obima i potvrđuje se pisanom ponudom prije početka rada.",
|
||||
cta: "Razgovarajmo o projektu",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
badge: "For clients",
|
||||
title: "Plans &",
|
||||
titleAccent: "pricing.",
|
||||
subtitle:
|
||||
"Indicative starting prices for the most common project types. Every project gets a tailored quote after a short discovery call — this page is just to give you a realistic frame.",
|
||||
tiersTitle: "Project pricing",
|
||||
popularBadge: "Popular",
|
||||
tiers: [
|
||||
{
|
||||
name: "Landing / marketing site",
|
||||
price: landing,
|
||||
period: "one-time",
|
||||
description:
|
||||
"A fast, SEO-ready site for your business — designed, built, deployed, and hosted.",
|
||||
features: [
|
||||
"Custom design, responsive on all devices",
|
||||
"SEO setup, analytics, contact form",
|
||||
"Deployed with SSL on managed infrastructure",
|
||||
"Delivered in 1–2 weeks",
|
||||
],
|
||||
highlighted: false,
|
||||
},
|
||||
{
|
||||
name: "Web application",
|
||||
price: webApp,
|
||||
period: "one-time",
|
||||
description:
|
||||
"A custom web app with backend, database, and admin panel — built around your workflow.",
|
||||
features: [
|
||||
"Frontend, backend, and database design",
|
||||
"Authentication and role management",
|
||||
"Admin dashboard and internal tools",
|
||||
"CI/CD, monitoring, and backups included",
|
||||
"Delivered in 4–8 weeks depending on scope",
|
||||
],
|
||||
highlighted: true,
|
||||
},
|
||||
{
|
||||
name: "Custom platform",
|
||||
price: "custom quote",
|
||||
period: "scoped together",
|
||||
description:
|
||||
"Larger products: e-commerce, SaaS, mobile + web. Scoped and priced after discovery.",
|
||||
features: [
|
||||
"Everything in Web application",
|
||||
"Mobile app (React Native) if needed",
|
||||
"Payments, integrations, third-party APIs",
|
||||
"Long-term development partnership",
|
||||
],
|
||||
highlighted: false,
|
||||
},
|
||||
],
|
||||
careTitle: "Ongoing care plans",
|
||||
careSubtitle:
|
||||
"Optional monthly plans after launch, so your project stays fast, secure, and online.",
|
||||
care: [
|
||||
{
|
||||
name: "Hosting",
|
||||
price: care[0],
|
||||
features: [
|
||||
"Managed hosting with SSL",
|
||||
"Daily backups",
|
||||
"Uptime monitoring",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Standard",
|
||||
price: care[1],
|
||||
features: [
|
||||
"Everything in Hosting",
|
||||
"Content and minor updates",
|
||||
"Security and dependency updates",
|
||||
"Priority email support",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Priority",
|
||||
price: care[2],
|
||||
features: [
|
||||
"Everything in Standard",
|
||||
"Monthly feature-development hours",
|
||||
"Same-day response",
|
||||
"Performance reviews",
|
||||
],
|
||||
},
|
||||
],
|
||||
disclaimer:
|
||||
"Prices are starting points, not fixed quotes — final pricing depends on scope and is confirmed in a written proposal before any work begins.",
|
||||
cta: "Discuss your project",
|
||||
};
|
||||
}
|
||||
|
||||
export type PlansDict = ReturnType<typeof getPlansDict>;
|
||||
Reference in New Issue
Block a user