Initial commit

This commit is contained in:
2026-08-01 16:07:57 +02:00
commit 20b254e614
68 changed files with 8467 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
import { notFound } from "next/navigation";
// Catch-all for unknown paths under a locale (e.g. /en/asdf) so they render
// the styled not-found page inside the locale layout instead of the bare
// default 404.
export default function CatchAllPage() {
notFound();
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import {
GitBranch,
Lock,
Zap,
Sparkles,
MapPin,
Server,
Globe,
Briefcase,
Clock,
} from "lucide-react";
import GlassCard from "@/components/ui/GlassCard";
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
import type { Dictionary, Locale } from "@/lib/dictionaries";
const highlightIcons = [GitBranch, Lock, Zap, Sparkles];
const factIcons = [MapPin, Server, Globe, Briefcase, Clock];
interface AboutContentProps {
locale: Locale;
dict: Dictionary;
}
interface TextParagraph {
type: "text";
content: string;
}
interface LinkParagraph {
type: "linkParagraph";
before: string;
linkText: string;
href: string;
}
type ApproachParagraph = TextParagraph | LinkParagraph;
export default function AboutContent({ locale, dict }: AboutContentProps) {
const t = dict.aboutPage;
return (
<>
{/* Hero */}
<section className="relative isolate flex min-h-[50svh] 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">
<div className="mx-auto max-w-3xl text-center">
<motion.span
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
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"
>
{t.badge}
</motion.span>
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
className="font-display text-display-xl font-medium tracking-tight"
>
<span className="text-gradient-fade">{t.title}</span>
<br />
<span className="text-gradient-purple">{t.titleAccent}</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.7, ease: [0.16, 1, 0.3, 1] }}
className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg"
>
{t.subtitle}
</motion.p>
</div>
</div>
</section>
{/* Approach + Quick facts */}
<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.1)}
className="grid grid-cols-1 gap-8 lg:grid-cols-3"
>
{/* Main text */}
<motion.div variants={fadeUp} className="lg:col-span-2">
<h2 className="mb-6 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl">
{t.approachTitle}
</h2>
<div className="space-y-5 text-base leading-relaxed text-text-secondary">
{t.approachParagraphs.map((p : ApproachParagraph, i : number) => {
if (p.type === "text") {
return <p key={i}>{p.content}</p>;
}
if (p.type === "linkParagraph") {
return (
<p key={i}>
{p.before}
<Link
href={`/${locale}${p.href}`}
className="text-purple-300 underline"
>
{p.linkText}
</Link>
.
</p>
);
}
return null;
})}
</div>
</motion.div>
{/* Quick facts */}
<motion.div variants={fadeUp}>
<GlassCard className="h-fit" hover={false}>
<h3 className="mb-5 font-mono text-xs uppercase tracking-widest text-text-secondary">
{t.quickFactsTitle}
</h3>
<dl className="space-y-3.5 text-sm">
{t.facts.map((fact, i) => {
const Icon = factIcons[i] || MapPin;
return (
<div
key={i}
className="flex items-center justify-between gap-3 border-b border-border/40 pb-3 last:border-0 last:pb-0"
>
<dt className="flex items-center gap-2 text-text-secondary">
<Icon className="h-3.5 w-3.5 text-purple-400/60" strokeWidth={1.5} />
{fact.label}
</dt>
<dd className="font-mono text-text-primary">{fact.value}</dd>
</div>
);
})}
</dl>
</GlassCard>
</motion.div>
</motion.div>
</div>
</section>
{/* Highlights */}
<section className="relative w-full py-20 sm:py-24">
<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-14 max-w-3xl text-center"
>
<motion.h2
variants={fadeUp}
className="font-display text-display-lg font-medium tracking-tight text-gradient-fade"
>
{t.highlightsTitle}
</motion.h2>
</motion.div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={viewportConfig}
variants={stagger(0.1)}
className="grid grid-cols-1 gap-4 sm:grid-cols-2"
>
{t.highlights.map((item, i) => {
const Icon = highlightIcons[i] || Sparkles;
return (
<motion.div key={i} variants={fadeUp} className="group">
<GlassCard className="h-full">
<div className="flex gap-5">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-purple-400/20 bg-purple-500/10 text-purple-300 transition-all duration-500 group-hover:border-purple-400/50 group-hover:bg-purple-500/20 group-hover:shadow-glow-sm">
<Icon className="h-5 w-5" strokeWidth={1.5} />
</div>
<div>
<h3 className="mb-2 text-lg font-medium text-text-primary">
{item.title}
</h3>
<p className="text-sm leading-relaxed text-text-secondary">
{item.description}
</p>
</div>
</div>
</GlassCard>
</motion.div>
);
})}
</motion.div>
</div>
</section>
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { getDictionary, type Locale } from "@/lib/dictionaries";
import CTA from "@/components/sections/CTA";
import AboutContent from "./AboutContent";
import { localeAlternates } from "@/lib/seo";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const isSR = locale === "sr";
return {
title: isSR ? "O meni" : "About",
description: isSR
? "Full-stack inženjer koji gradi, deployuje i hostuje na vlastitoj infrastrukturi."
: "End-to-end engineer who builds, deploys, and hosts on self-managed infrastructure.",
alternates: localeAlternates(locale, "/about"),
};
}
export default async function AboutPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = getDictionary(locale);
return (
<>
<AboutContent locale={locale as Locale} dict={dict} />
<CTA locale={locale as Locale} dict={dict} />
</>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { getDictionary } from "@/lib/dictionaries";
import ContactForm from "@/components/sections/ContactForm";
import { localeAlternates } from "@/lib/seo";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const isSR = locale === "sr";
return {
title: isSR ? "Kontakt" : "Contact",
description: isSR
? "Kontaktirajte me za vaš sljedeći projekat."
: "Get in touch about your next project.",
alternates: localeAlternates(locale, "/contact"),
};
}
export default async function ContactPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = getDictionary(locale);
const t = dict.contactPage;
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">
<div className="mx-auto max-w-3xl text-center">
<span className="mb-6 inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3.5 py-1.5 font-mono text-xs tracking-tight text-emerald-300 backdrop-blur-md">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
</span>
{t.badge}
</span>
<h1 className="font-display text-display-xl font-medium tracking-tight">
<span className="text-gradient-fade">{t.title}</span>
<br />
<span className="text-gradient-purple">{t.titleAccent}</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg">
{t.subtitle}
</p>
</div>
</div>
</section>
{/* Form section */}
<section className="relative w-full pb-24 pt-12 sm:pb-32">
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
<ContactForm dict={dict} />
</div>
</section>
</>
);
}
+131
View File
@@ -0,0 +1,131 @@
import type { Metadata, Viewport } from "next";
import { Geist, Sora, JetBrains_Mono } from "next/font/google";
import "../globals.css";
import {
locales,
getDictionary,
isValidLocale,
defaultLocale,
type Locale,
} from "@/lib/dictionaries";
import { SITE_URL, localeAlternates } from "@/lib/seo";
import Navbar from "@/components/layout/Navbar";
import Footer from "@/components/layout/Footer";
import ScanlineOverlay from "@/components/ui/ScanlineOverlay";
import LangBanner from "@/components/ui/LangBanner";
const geistSans = Geist({
subsets: ["latin"],
variable: "--font-geist-sans",
display: "swap",
});
const sora = Sora({
subsets: ["latin"],
variable: "--font-sora",
display: "swap",
weight: ["300", "400", "500", "600", "700"],
});
const jetbrains = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-jetbrains",
display: "swap",
weight: ["400", "500"],
});
// Pre-render both locale shells at build time
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
// Tell the browser the document is dark before any CSS/JS runs. This sets the
// <meta name="color-scheme"> / theme-color in the SSR <head> so the initial
// canvas (and mobile browser chrome) paints dark, never white.
export const viewport: Viewport = {
colorScheme: "dark",
themeColor: "#0F1117",
};
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const isSR = locale === "sr";
return {
title: {
default: isSR
? "Name — Full-stack inženjering, od početka do kraja"
: "Name — Full-stack engineering, end to end",
template: "%s · Name",
},
description: isSR
? "Premium full-stack razvoj, deployment i self-hosted infrastruktura."
: "Premium full-stack development, deployment, and self-hosted infrastructure.",
metadataBase: new URL(SITE_URL),
// Canonical — self-referencing within each locale.
// Subpages override this with their own path via localeAlternates().
alternates: localeAlternates(locale),
openGraph: {
title: isSR
? "Name — Full-stack inženjering"
: "Name — Full-stack engineering, end to end",
description: isSR
? "Premium full-stack razvoj i self-hosted infrastruktura."
: "Premium full-stack development and self-hosted infrastructure.",
type: "website",
locale: isSR ? "sr_RS" : "en_US",
alternateLocale: isSR ? ["en_US"] : ["sr_RS"],
siteName: "ksan.dev",
url: `${SITE_URL}/${locale}`,
},
twitter: {
card: "summary_large_image",
title: isSR ? "Name — Full-stack inženjering" : "Name — Full-stack engineering",
},
robots: {
index: true,
follow: true,
"max-image-preview": "large",
"max-snippet": -1,
},
};
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale: rawLocale } = await params;
const locale: Locale = isValidLocale(rawLocale) ? rawLocale : defaultLocale;
const dict = getDictionary(locale);
return (
<html
lang={locale}
className={`${geistSans.variable} ${sora.variable} ${jetbrains.variable}`}
// Inline so it is present in the very first HTML bytes — applied before
// the external stylesheet loads and before hydration. This is what kills
// the one-frame white flash (esp. in dev, where CSS is JS-injected).
style={{ colorScheme: "dark", backgroundColor: "#0F1117" }}
>
<body className="relative min-h-screen overflow-x-hidden">
<ScanlineOverlay />
<LangBanner locale={locale} dict={dict} />
<Navbar locale={locale} dict={dict} />
<main className="relative z-10">{children}</main>
<Footer locale={locale} dict={dict} />
</body>
</html>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { headers } from "next/headers";
import { ArrowRight } from "lucide-react";
import GlowButton from "@/components/ui/GlowButton";
import { isValidLocale, defaultLocale } from "@/lib/dictionaries";
export default async function NotFound() {
const headerList = await headers();
const segment = headerList.get("x-locale") ?? "";
const locale = isValidLocale(segment) ? segment : defaultLocale;
const isSR = locale === "sr";
return (
<section className="relative isolate flex min-h-[80svh] items-center overflow-hidden pt-24">
<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">
<div className="mx-auto max-w-2xl text-center">
<div className="font-display text-display-2xl font-medium tracking-tight text-gradient-purple">
404
</div>
<h1 className="mt-4 font-display text-2xl font-medium text-gradient-fade sm:text-3xl">
{isSR ? "Stranica nije pronađena." : "Page not found."}
</h1>
<p className="mx-auto mt-4 max-w-md text-base leading-relaxed text-text-secondary">
{isSR
? "Stranica koju tražite ne postoji ili je premještena."
: "The page you're looking for doesn't exist or has been moved."}
</p>
<div className="mt-10 flex justify-center">
<GlowButton
href={`/${locale}`}
variant="primary"
size="lg"
icon={<ArrowRight className="h-4 w-4" />}
>
{isSR ? "Nazad na početnu" : "Back to home"}
</GlowButton>
</div>
</div>
</div>
</section>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "ksan.dev — Full-stack engineering, end to end";
export default async function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#1c0a3d",
backgroundImage:
"radial-gradient(circle at 50% 34%, #7c4dd6 0%, #5a2ca6 34%, #2e1257 72%, #190836 100%)",
fontFamily: "monospace",
}}
>
{/* Kd monogram */}
<svg
width={300}
height={300}
viewBox="0 0 135.46666 135.46667"
xmlns="http://www.w3.org/2000/svg"
>
{/* K stem */}
<rect
x={10.583333}
y={8.20364}
width={13.804729}
height={118.32624}
fill="#F5F7FF"
/>
{/* d ascender */}
<rect
x={112.60719}
y={8.2020836}
width={13.804729}
height={118.32624}
fill="#F5F7FF"
/>
{/* K arms */}
<path
fill="#A78BFA"
d="M 24.392675,67.364089 66.876115,126.5268 H 80.680844 L 38.057204,67.366755 80.677942,8.2059598 66.876115,8.2020832 Z"
/>
{/* d bowl */}
<path
fill="#A78BFA"
d="M 112.28492,126.5237 98.48174,126.5257 71.437499,88.862363 98.48174,51.458843 h 13.80472 L 85.153597,88.860364 Z"
/>
</svg>
<div
style={{
marginTop: 36,
fontSize: 46,
fontWeight: 600,
color: "rgba(255,255,255,0.95)",
letterSpacing: "0.04em",
}}
>
www.ksan.dev
</div>
</div>
),
{ ...size },
);
}
+25
View File
@@ -0,0 +1,25 @@
import { getDictionary, type Locale } from "@/lib/dictionaries";
import Hero from "@/components/sections/Hero";
import Services from "@/components/sections/Services";
import TechStack from "@/components/sections/TechStack";
import ProjectsCarousel from "@/components/sections/ProjectsCarousel";
import CTA from "@/components/sections/CTA";
export default async function HomePage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = getDictionary(locale);
return (
<>
<Hero locale={locale as Locale} dict={dict} />
<Services locale={locale as Locale} dict={dict} />
<TechStack locale={locale as Locale} dict={dict} />
<ProjectsCarousel locale={locale as Locale} dict={dict} featured />
<CTA locale={locale as Locale} dict={dict} />
</>
);
}
+224
View File
@@ -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>
</>
);
}
+53
View File
@@ -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)}
/>
);
}
+249
View File
@@ -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 12 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 48 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 12 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 48 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>;
+77
View File
@@ -0,0 +1,77 @@
import { getDictionary, type Locale } from "@/lib/dictionaries";
import ProjectsShowcase from "@/components/sections/ProjectsShowcase";
import CTA from "@/components/sections/CTA";
import { localeAlternates } from "@/lib/seo";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const isSR = locale === "sr";
return {
title: isSR ? "Projekti" : "Projects",
description: isSR
? "Pogledajte projekte koje sam razvio i deployovao."
: "Browse the projects I've built and deployed.",
alternates: localeAlternates(locale, "/projects"),
};
}
export default async function ProjectsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = getDictionary(locale);
const t = dict.projectsPage;
return (
<>
{/* Hero */}
<section className="relative isolate flex min-h-[45svh] 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">
<div className="mx-auto max-w-3xl text-center">
<span 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">
{t.badge}
</span>
<h1 className="font-display text-display-xl font-medium tracking-tight">
<span className="text-gradient-fade">{t.title}</span>
<br />
<span className="text-gradient-purple">{t.titleAccent}</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary sm:text-lg">
{t.subtitle}
</p>
</div>
</div>
</section>
{/* Deployed client projects */}
<ProjectsShowcase
locale={locale as Locale}
dict={dict}
filter="client"
sectionTitle={t.deployedTitle}
/>
{/* Personal / featured projects */}
<ProjectsShowcase
locale={locale as Locale}
dict={dict}
filter="personal"
sectionTitle={t.featuredTitle}
/>
{/* Build with me CTA */}
<CTA locale={locale as Locale} dict={dict} />
</>
);
}
+381
View File
@@ -0,0 +1,381 @@
"use client";
import { motion } from "framer-motion";
import {
Code2,
Server,
Smartphone,
Wrench,
MessageSquare,
FileSearch,
Rocket,
HeartHandshake,
ArrowRight,
Check,
Clock,
Users,
Target,
} from "lucide-react";
import GlassCard from "@/components/ui/GlassCard";
import GlowButton from "@/components/ui/GlowButton";
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
import type { Dictionary, Locale } from "@/lib/dictionaries";
interface ServicesContentProps {
locale: Locale;
dict: Dictionary;
}
export default function ServicesContent({
locale,
dict,
}: ServicesContentProps) {
const t = dict.servicesPage;
return (
<>
{/* ──── HERO ──── */}
<section className="relative isolate flex min-h-[75svh] 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" />
{/* Floating orbs */}
<motion.div
className="pointer-events-none absolute left-[15%] top-[20%] h-[500px] w-[500px] rounded-full"
style={{
background: "radial-gradient(circle, rgba(139,92,246,0.4) 0%, transparent 70%)",
filter: "blur(60px)",
}}
animate={{ x: [0, 30, -20, 0], y: [0, -25, 15, 0] }}
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="pointer-events-none absolute right-[10%] top-[40%] h-[400px] w-[400px] rounded-full"
style={{
background: "radial-gradient(circle, rgba(167,139,250,0.25) 0%, transparent 70%)",
filter: "blur(70px)",
}}
animate={{ x: [0, -40, 25, 0], y: [0, 30, -20, 0] }}
transition={{ duration: 24, repeat: Infinity, ease: "easeInOut" }}
/>
<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"
>
{t.badge}
</motion.span>
<motion.h1
variants={fadeUp}
className="font-display text-display-xl font-medium tracking-tight"
>
<span className="text-gradient-fade">{t.heroTitle}</span>
<br />
<span className="text-gradient-purple">{t.heroTitleAccent}</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.heroSubtitle}
</motion.p>
<motion.div variants={fadeUp} className="mt-10 flex flex-wrap items-center justify-center gap-4">
<GlowButton
href={`/${locale}/contact`}
variant="primary"
size="lg"
icon={<ArrowRight className="h-4 w-4" />}
>
{t.heroCta}
</GlowButton>
</motion.div>
</motion.div>
</div>
</section>
{/* ──── SERVICE OFFERINGS ──── */}
<section className="relative w-full py-24 sm:py-32">
<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-16 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"
>
{t.offeringsLabel}
</motion.span>
<motion.h2
variants={fadeUp}
className="font-display text-display-lg font-medium tracking-tight"
>
<span className="text-gradient-fade">{t.offeringsTitle}</span>
</motion.h2>
<motion.p
variants={fadeUp}
className="mx-auto mt-6 max-w-2xl text-base leading-relaxed text-text-secondary"
>
{t.offeringsSubtitle}
</motion.p>
</motion.div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={viewportConfig}
variants={stagger(0.1)}
className="grid grid-cols-1 gap-5 md:grid-cols-2"
>
{t.offerings.map((offering, i) => {
const icons = [Code2, Server, Smartphone, Wrench];
const Icon = icons[i] || Code2;
return (
<motion.div key={i} variants={fadeUp} className="group">
<GlassCard className="h-full">
<div className="absolute -right-16 -top-16 h-40 w-40 rounded-full bg-purple-500/0 blur-3xl transition-all duration-700 group-hover:bg-purple-500/20" />
<div className="relative">
<div className="mb-5 flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl border border-purple-400/20 bg-purple-500/10 text-purple-300 transition-all duration-500 group-hover:border-purple-400/50 group-hover:bg-purple-500/20 group-hover:shadow-glow-sm">
<Icon className="h-5 w-5" strokeWidth={1.5} />
</div>
<h3 className="text-xl font-medium text-text-primary">
{offering.title}
</h3>
</div>
<p className="mb-6 text-sm leading-relaxed text-text-secondary">
{offering.description}
</p>
<div className="space-y-2.5">
{offering.includes.map((item, j) => (
<div
key={j}
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">{item}</span>
</div>
))}
</div>
</div>
</GlassCard>
</motion.div>
);
})}
</motion.div>
</div>
</section>
{/*
<section className="relative w-full py-24 sm:py-32">
<div className="pointer-events-none absolute inset-0 grid-bg [mask-image:radial-gradient(ellipse_at_center,#000_20%,transparent_65%)] opacity-20" />
<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.1)}
className="grid grid-cols-1 gap-8 lg:grid-cols-5"
>
<motion.div variants={fadeUp} className="lg:col-span-3">
<span className="mb-4 inline-flex items-center gap-2 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3.5 py-1.5 font-mono text-xs tracking-tight text-emerald-300 backdrop-blur-md">
<span className="relative flex h-2 w-2">
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]" />
</span>
{t.infraBadge}
</span>
<h2 className="mt-4 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl">
{t.infraTitle}
</h2>
<div className="mt-6 space-y-4 text-base leading-relaxed text-text-secondary">
{t.infraParagraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
</div>
</motion.div>
<motion.div variants={fadeUp} className="space-y-3 lg:col-span-2">
{t.infraPoints.map((point, i) => {
const pointIcons = [Shield, Truck, Clock];
const PIcon = pointIcons[i] || Shield;
return (
<GlassCard key={i} className="flex items-start gap-4 py-5">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-emerald-400/20 bg-emerald-500/10 text-emerald-300">
<PIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
<div>
<div className="text-sm font-medium text-text-primary">
{point.title}
</div>
<div className="mt-1 text-xs leading-relaxed text-text-secondary">
{point.description}
</div>
</div>
</GlassCard>
);
})}
</motion.div>
</motion.div>
</div>
</section>
*/}
<section className="relative w-full py-24 sm:py-32">
<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-16 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"
>
{t.processLabel}
</motion.span>
<motion.h2
variants={fadeUp}
className="font-display text-display-lg font-medium tracking-tight"
>
<span className="text-gradient-fade">{t.processTitle}</span>
</motion.h2>
<motion.p
variants={fadeUp}
className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-text-secondary"
>
{t.processSubtitle}
</motion.p>
</motion.div>
<motion.div
initial="hidden"
whileInView="visible"
viewport={viewportConfig}
variants={stagger(0.12)}
className="relative grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5"
>
{/* Connecting line — desktop only */}
<div className="pointer-events-none absolute top-12 hidden h-px bg-gradient-to-r from-transparent via-purple-400/30 to-transparent lg:inset-x-16 lg:block" />
{t.processSteps.map((step, i) => {
const stepIcons = [MessageSquare, FileSearch, Code2, Rocket, HeartHandshake];
const SIcon = stepIcons[i] || Code2;
return (
<motion.div
key={i}
variants={fadeUp}
className="group relative text-center"
>
{/* Step number */}
<div className="relative mx-auto mb-4 flex h-12 w-12 items-center justify-center">
<div className="absolute inset-0 rounded-full border border-purple-400/30 bg-purple-500/10 transition-all duration-500 group-hover:border-purple-400/60 group-hover:bg-purple-500/20 group-hover:shadow-glow-sm" />
<SIcon className="relative h-5 w-5 text-purple-300" strokeWidth={1.5} />
</div>
<div className="mb-1 font-mono text-[10px] uppercase tracking-widest text-purple-400/60">
{String(i + 1).padStart(2, "0")}
</div>
<h3 className="mb-2 text-base font-medium text-text-primary">
{step.title}
</h3>
<p className="text-xs leading-relaxed text-text-secondary">
{step.description}
</p>
</motion.div>
);
})}
</motion.div>
</div>
</section>
<section className="relative w-full py-24 sm:py-32">
<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.08)}
className="relative overflow-hidden rounded-3xl border border-border/60 bg-surface/40 backdrop-blur-md"
>
<div className="absolute inset-0 grid-bg opacity-20 [mask-image:radial-gradient(ellipse_at_center,#000_20%,transparent_70%)]" />
<div className="absolute -left-32 top-1/2 h-64 w-64 -translate-y-1/2 rounded-full bg-purple-500/10 blur-3xl" />
<div className="relative grid grid-cols-1 gap-8 px-8 py-12 sm:px-16 sm:py-16 lg:grid-cols-2 lg:gap-16">
<motion.div variants={fadeUp}>
<span className="mb-4 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">
{t.pricingLabel}
</span>
<h2 className="mt-4 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl">
{t.pricingTitle}
</h2>
<p className="mt-6 text-base leading-relaxed text-text-secondary">
{t.pricingDescription}
</p>
</motion.div>
<motion.div variants={fadeUp} className="space-y-4">
{t.pricingPoints.map((point, i) => {
const pIcons = [Target, Users, Clock];
const PIcon = pIcons[i] || Target;
return (
<div key={i} className="flex items-start gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-purple-400/20 bg-purple-500/10 text-purple-300">
<PIcon className="h-4 w-4" strokeWidth={1.5} />
</div>
<div>
<div className="text-sm font-medium text-text-primary">
{point.title}
</div>
<div className="mt-1 text-xs leading-relaxed text-text-secondary">
{point.description}
</div>
</div>
</div>
);
})}
<div className="pt-4">
<GlowButton
href={`/${locale}/contact`}
variant="primary"
size="md"
icon={<ArrowRight className="h-4 w-4" />}
>
{t.pricingCta}
</GlowButton>
</div>
</motion.div>
</div>
</motion.div>
</div>
</section>
</>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { getDictionary, type Locale } from "@/lib/dictionaries";
import ServicesContent from "./ServicesContent";
import CTA from "@/components/sections/CTA";
import { localeAlternates } from "@/lib/seo";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const isSR = locale === "sr";
return {
title: isSR ? "Usluge" : "Services",
description: isSR
? "Full-stack web razvoj, self-hosted infrastruktura i deployment iz jednih ruku."
: "Full-stack web development, self-hosted infrastructure, and deployment under one roof.",
alternates: localeAlternates(locale, "/services"),
};
}
export default async function ServicesPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const dict = getDictionary(locale);
return (
<>
<ServicesContent locale={locale as Locale} dict={dict} />
<CTA locale={locale as Locale} dict={dict} />
</>
);
}