Initial commit
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import nodemailer from "nodemailer";
|
||||
|
||||
// Validate environment variables at startup
|
||||
const GMAIL_USER = process.env.GMAIL_USER;
|
||||
const GMAIL_APP_PASSWORD = process.env.GMAIL_APP_PASSWORD;
|
||||
const CONTACT_TO_EMAIL = process.env.CONTACT_TO_EMAIL || GMAIL_USER;
|
||||
|
||||
// Field length limits — keeps abuse and mail-size problems in check
|
||||
const MAX_LENGTHS = { name: 100, email: 200, subject: 200, message: 5000 };
|
||||
|
||||
// Simple in-memory rate limit: max 5 messages per IP per 10 minutes.
|
||||
// Resets on server restart, which is fine for a portfolio contact form.
|
||||
const RATE_LIMIT = 5;
|
||||
const RATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
const submissions = new Map<string, number[]>();
|
||||
|
||||
function isRateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const recent = (submissions.get(ip) || []).filter(
|
||||
(t) => now - t < RATE_WINDOW_MS,
|
||||
);
|
||||
if (recent.length >= RATE_LIMIT) return true;
|
||||
recent.push(now);
|
||||
submissions.set(ip, recent);
|
||||
// Prune old entries so the map doesn't grow unbounded
|
||||
if (submissions.size > 1000) {
|
||||
for (const [key, times] of submissions) {
|
||||
if (times.every((t) => now - t >= RATE_WINDOW_MS)) submissions.delete(key);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, email, subject, message, company } = body;
|
||||
|
||||
// Honeypot: real users never fill this hidden field. Pretend success
|
||||
// so bots don't learn they were filtered.
|
||||
if (company) {
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
"unknown";
|
||||
if (isRateLimited(ip)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many messages — please try again later" },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !email || !subject || !message) {
|
||||
return NextResponse.json(
|
||||
{ error: "All fields are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
for (const [field, max] of Object.entries(MAX_LENGTHS)) {
|
||||
const value = body[field];
|
||||
if (typeof value !== "string" || value.length > max) {
|
||||
return NextResponse.json(
|
||||
{ error: `Field "${field}" is too long (max ${max} characters)` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email address" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Check environment
|
||||
if (!GMAIL_USER || !GMAIL_APP_PASSWORD) {
|
||||
console.error(
|
||||
"Missing GMAIL_USER or GMAIL_APP_PASSWORD environment variables",
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: "Server configuration error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Create transporter
|
||||
const transporter = nodemailer.createTransport({
|
||||
service: "gmail",
|
||||
auth: {
|
||||
user: GMAIL_USER,
|
||||
pass: GMAIL_APP_PASSWORD,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Escape user input before interpolating into the HTML body
|
||||
const safe = {
|
||||
name: escapeHtml(name),
|
||||
email: escapeHtml(email),
|
||||
subject: escapeHtml(subject),
|
||||
message: escapeHtml(message),
|
||||
};
|
||||
|
||||
// Send email
|
||||
await transporter.sendMail({
|
||||
from: `"Portfolio Contact Form" <${GMAIL_USER}>`,
|
||||
to: CONTACT_TO_EMAIL,
|
||||
replyTo: email,
|
||||
subject: `[Ksan.dev contact] ${subject}`,
|
||||
text: `Name: ${name}\nEmail: ${email}\nSubject: ${subject}\n\nMessage:\n${message}`,
|
||||
html: `
|
||||
<!-- Dark wrapper: without an explicit dark background the theme's
|
||||
light text colors are unreadable on white inbox backgrounds -->
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#0F1117" style="background-color: #0F1117; padding: 32px 16px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; width: 100%; font-family: -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
||||
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 0 8px 20px 8px;">
|
||||
<span style="display: inline-block; border: 1px solid rgba(167, 139, 250, 0.4); background-color: #221A38; color: #C4B5FD; border-radius: 999px; padding: 6px 14px; font-size: 12px; letter-spacing: 1px; text-transform: uppercase;">
|
||||
ksan.dev contact form
|
||||
</span>
|
||||
<h1 style="margin: 16px 0 0 0; color: #F5F7FF; font-size: 22px; font-weight: 600; letter-spacing: -0.5px;">
|
||||
New message from your portfolio
|
||||
</h1>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Sender card -->
|
||||
<tr>
|
||||
<td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#1A1D29" style="background-color: #1A1D29; border: 1px solid #34384A; border-radius: 14px;">
|
||||
<tr>
|
||||
<td style="padding: 20px 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Name</td>
|
||||
<td style="padding: 6px 0; color: #F5F7FF; font-size: 14px; font-weight: 600;">${safe.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Email</td>
|
||||
<td style="padding: 6px 0; font-size: 14px;">
|
||||
<a href="mailto:${safe.email}" style="color: #A78BFA; text-decoration: none;">${safe.email}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="90" style="padding: 6px 0; color: #A1A8C3; font-size: 13px;">Subject</td>
|
||||
<td style="padding: 6px 0; color: #F5F7FF; font-size: 14px;">${safe.subject}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Message card -->
|
||||
<tr>
|
||||
<td style="padding-top: 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#1A1D29" style="background-color: #1A1D29; border: 1px solid #34384A; border-left: 3px solid #8B5CF6; border-radius: 14px;">
|
||||
<tr>
|
||||
<td style="padding: 20px 24px;">
|
||||
<p style="margin: 0 0 10px 0; color: #A1A8C3; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase;">
|
||||
Message
|
||||
</p>
|
||||
<p style="margin: 0; color: #F5F7FF; font-size: 15px; line-height: 1.7; white-space: pre-wrap;">${safe.message}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Reply button -->
|
||||
<tr>
|
||||
<td align="center" style="padding: 24px 0 8px 0;">
|
||||
<a href="mailto:${safe.email}?subject=Re:%20${encodeURIComponent(subject)}" style="display: inline-block; background-color: #8B5CF6; color: #FFFFFF; font-size: 14px; font-weight: 600; text-decoration: none; border-radius: 12px; padding: 12px 28px;">
|
||||
Reply to ${safe.name} →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td align="center" style="padding-top: 16px;">
|
||||
<p style="margin: 0; color: #6B7290; font-size: 12px;">
|
||||
Sent from the contact form at ksan.dev
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send message" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+225
@@ -0,0 +1,225 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ───── THEME ───── */
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-background: #0F1117;
|
||||
--color-surface: #1A1D29;
|
||||
--color-elevated: #252A3A;
|
||||
--color-border: #34384A;
|
||||
|
||||
--color-text-primary: #F5F7FF;
|
||||
--color-text-secondary: #A1A8C3;
|
||||
--color-text-muted: #6B7290;
|
||||
|
||||
--color-purple-50: #F5F3FF;
|
||||
--color-purple-100: #EDE9FE;
|
||||
--color-purple-200: #DDD6FE;
|
||||
--color-purple-300: #C4B5FD;
|
||||
--color-purple-400: #A78BFA;
|
||||
--color-purple-500: #8B5CF6;
|
||||
--color-purple-600: #7C3AED;
|
||||
--color-purple-700: #6D28D9;
|
||||
--color-purple-800: #5B21B6;
|
||||
--color-purple-900: #4C1D95;
|
||||
|
||||
--color-accent: #C4B5FD;
|
||||
--color-error: #F43F5E;
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
|
||||
--font-display: var(--font-sora), system-ui, sans-serif;
|
||||
--font-mono: var(--font-jetbrains), monospace;
|
||||
|
||||
/* Font sizes */
|
||||
--text-display-2xl: clamp(3rem, 8vw, 6.5rem);
|
||||
--text-display-2xl--line-height: 1.02;
|
||||
--text-display-2xl--letter-spacing: -0.04em;
|
||||
|
||||
--text-display-xl: clamp(2.5rem, 6vw, 5rem);
|
||||
--text-display-xl--line-height: 1.05;
|
||||
--text-display-xl--letter-spacing: -0.035em;
|
||||
|
||||
--text-display-lg: clamp(2rem, 4.5vw, 3.5rem);
|
||||
--text-display-lg--line-height: 1.08;
|
||||
--text-display-lg--letter-spacing: -0.03em;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-glow-sm: 0 0 20px -2px rgba(139, 92, 246, 0.25);
|
||||
--shadow-glow-md: 0 0 40px -4px rgba(139, 92, 246, 0.35);
|
||||
--shadow-glow-lg: 0 0 80px -8px rgba(139, 92, 246, 0.45);
|
||||
--shadow-glow-xl: 0 0 120px -12px rgba(139, 92, 246, 0.5);
|
||||
--shadow-inner-glow: inset 0 0 30px -8px rgba(139, 92, 246, 0.3);
|
||||
--shadow-card: 0 1px 0 0 rgba(255, 255, 255, 0.04) inset,
|
||||
0 0 0 0.5px rgba(255, 255, 255, 0.06);
|
||||
--shadow-card-hover: 0 1px 0 0 rgba(255, 255, 255, 0.08) inset,
|
||||
0 0 0 0.5px rgba(167, 139, 250, 0.3),
|
||||
0 20px 60px -20px rgba(139, 92, 246, 0.4);
|
||||
|
||||
/* Animations */
|
||||
--animate-fade-up: fade-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||
--animate-fade-in: fade-in 0.6s ease-out forwards;
|
||||
--animate-float: float 8s ease-in-out infinite;
|
||||
--animate-float-slow: float 14s ease-in-out infinite;
|
||||
--animate-scanline: scanline 8s linear infinite;
|
||||
--animate-pulse-glow: pulse-glow 4s ease-in-out infinite;
|
||||
--animate-shimmer: shimmer 2.5s ease-in-out infinite;
|
||||
--animate-grid-flow: grid-flow 20s linear infinite;
|
||||
}
|
||||
|
||||
/* ───── KEYFRAMES ───── */
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) translateX(0); }
|
||||
33% { transform: translateY(-15px) translateX(10px); }
|
||||
66% { transform: translateY(10px) translateX(-10px); }
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
from { transform: translateY(-100%); }
|
||||
to { transform: translateY(100vh); }
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 0.4; filter: blur(40px); }
|
||||
50% { opacity: 0.7; filter: blur(60px); }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes grid-flow {
|
||||
from { transform: translate(0, 0); }
|
||||
to { transform: translate(40px, 40px); }
|
||||
}
|
||||
|
||||
/* ───── BASE ───── */
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
@apply antialiased bg-background;
|
||||
scroll-behavior: smooth;
|
||||
/* Make the root canvas dark so the first painted frame is never white. */
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-text-primary font-sans;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Decorative gradient as a composited fixed layer instead of
|
||||
`background-attachment: fixed`, which Firefox paints on a slower,
|
||||
non-composited path that flashes on first paint. The solid
|
||||
`bg-background` on html/body already covers the canvas, so this
|
||||
layer only adds the gradient — and never causes a white frame. */
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(139, 92, 246, 0.12), transparent),
|
||||
linear-gradient(180deg, #0F1117 0%, #0A0C12 100%);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
color: #F5F7FF;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-track { background: #0F1117; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #34384A;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #0F1117;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: #5B21B6; }
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-display tracking-tight;
|
||||
}
|
||||
}
|
||||
|
||||
/* ───── UTILITIES ───── */
|
||||
|
||||
@utility glass {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.025) 0%,
|
||||
rgba(255, 255, 255, 0.01) 100%
|
||||
);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
@utility glass-strong {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(37, 42, 58, 0.6) 0%,
|
||||
rgba(26, 29, 41, 0.4) 100%
|
||||
);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@utility text-gradient-purple {
|
||||
background: linear-gradient(135deg, #C4B5FD 0%, #A78BFA 50%, #8B5CF6 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@utility text-gradient-fade {
|
||||
background: linear-gradient(180deg, #F5F7FF 0%, #A1A8C3 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@utility grid-bg {
|
||||
background-image:
|
||||
linear-gradient(rgba(139, 92, 246, 0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(139, 92, 246, 0.07) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
@utility scanline-bg {
|
||||
background-image: repeating-linear-gradient(
|
||||
0deg,
|
||||
transparent,
|
||||
transparent 2px,
|
||||
rgba(139, 92, 246, 0.04) 2px,
|
||||
rgba(139, 92, 246, 0.04) 4px
|
||||
);
|
||||
}
|
||||
|
||||
@utility vignette {
|
||||
box-shadow: inset 0 0 200px 50px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ───── REDUCED MOTION ───── */
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg width="135.46666" height="135.46667" viewBox="0 0 135.46666 135.46667" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="135.46666" height="135.46667" rx="12" fill="#1b172d"/>
|
||||
<rect x="0.75" y="0.75" width="133.96666" height="133.96667" rx="11.25" fill="none" stroke="#a78bfa" stroke-opacity="0.25" stroke-width="1.5"/>
|
||||
<!-- 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>
|
||||
|
After Width: | Height: | Size: 877 B |
@@ -0,0 +1,16 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
// Don't index bare (non-locale) paths — they redirect
|
||||
disallow: ["/api/", "/_next/"],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { locales } from "@/lib/dictionaries";
|
||||
import { SITE_URL } from "@/lib/seo";
|
||||
|
||||
// All public pages on the site (without locale prefix).
|
||||
// The private /plans/[code] page is deliberately excluded.
|
||||
const pages = [
|
||||
"", // homepage
|
||||
"/about",
|
||||
"/services",
|
||||
"/projects",
|
||||
"/contact",
|
||||
];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const page of pages) {
|
||||
for (const locale of locales) {
|
||||
const languages: Record<string, string> = {};
|
||||
for (const l of locales) {
|
||||
languages[l] = `${SITE_URL}/${l}${page}`;
|
||||
}
|
||||
// x-default points to the English version
|
||||
languages["x-default"] = `${SITE_URL}/en${page}`;
|
||||
|
||||
entries.push({
|
||||
url: `${SITE_URL}/${locale}${page}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: page === "" ? "weekly" : "monthly",
|
||||
priority: page === "" ? 1.0 : 0.8,
|
||||
alternates: {
|
||||
languages,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
Reference in New Issue
Block a user