Compare commits

...

3 Commits

Author SHA1 Message Date
ksan 5562f6c35b Add Viber/WhatsApp contact options 2026-08-31 12:25:13 +02:00
ksan 9485daf99e Give section badge pills a rhomboid shape 2026-08-31 12:12:04 +02:00
ksan d6dbc71071 added phone number and updated some things 2026-08-31 12:03:56 +02:00
14 changed files with 326 additions and 38 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ export default function AboutContent({ locale, dict }: AboutContentProps) {
<motion.span <motion.span
initial={{ opacity: 0, y: -8 }} initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }} 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" className="mb-6 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300"
> >
{t.badge} {t.badge}
</motion.span> </motion.span>
+1 -1
View File
@@ -36,7 +36,7 @@ export default async function ContactPage({
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8"> <div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
<div className="mx-auto max-w-3xl text-center"> <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="mb-6 inline-flex items-center gap-2 rhomboid rhomboid-emerald px-5 py-1.5 font-mono text-xs tracking-tight text-emerald-300">
<span className="relative flex h-1.5 w-1.5"> <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="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 className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-400" />
+2 -2
View File
@@ -31,7 +31,7 @@ export default function PlansContent({ locale, plans }: PlansContentProps) {
> >
<motion.span <motion.span
variants={fadeUp} 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" className="mb-6 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300"
> >
<Lock className="h-3 w-3" strokeWidth={2} /> <Lock className="h-3 w-3" strokeWidth={2} />
{t.badge} {t.badge}
@@ -92,7 +92,7 @@ export default function PlansContent({ locale, plans }: PlansContentProps) {
}`} }`}
> >
{tier.highlighted && ( {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"> <span className="absolute -top-3 left-1/2 inline-flex -translate-x-1/2 items-center gap-1.5 rhomboid rhomboid-strong px-4 py-1 font-mono text-[10px] uppercase tracking-widest text-purple-200">
<Sparkles className="h-3 w-3" /> <Sparkles className="h-3 w-3" />
{t.popularBadge} {t.popularBadge}
</span> </span>
+1 -1
View File
@@ -37,7 +37,7 @@ export default async function ProjectsPage({
<div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8"> <div className="relative mx-auto w-full max-w-7xl px-6 lg:px-8">
<div className="mx-auto max-w-3xl text-center"> <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"> <span className="mb-6 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300">
{t.badge} {t.badge}
</span> </span>
+5 -5
View File
@@ -67,7 +67,7 @@ export default function ServicesContent({
> >
<motion.span <motion.span
variants={fadeUp} 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" className="mb-6 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300"
> >
{t.badge} {t.badge}
</motion.span> </motion.span>
@@ -114,7 +114,7 @@ export default function ServicesContent({
> >
<motion.span <motion.span
variants={fadeUp} 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" className="mb-6 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300"
> >
{t.offeringsLabel} {t.offeringsLabel}
</motion.span> </motion.span>
@@ -198,7 +198,7 @@ export default function ServicesContent({
className="grid grid-cols-1 gap-8 lg:grid-cols-5" className="grid grid-cols-1 gap-8 lg:grid-cols-5"
> >
<motion.div variants={fadeUp} className="lg:col-span-3"> <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="mb-4 inline-flex items-center gap-2 rhomboid rhomboid-emerald px-5 py-1.5 font-mono text-xs tracking-tight text-emerald-300">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-400 opacity-75" /> <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 className="relative inline-flex h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.6)]" />
@@ -255,7 +255,7 @@ export default function ServicesContent({
> >
<motion.span <motion.span
variants={fadeUp} 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" className="mb-6 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300"
> >
{t.processLabel} {t.processLabel}
</motion.span> </motion.span>
@@ -329,7 +329,7 @@ export default function ServicesContent({
<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"> <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}> <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"> <span className="mb-4 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300">
{t.pricingLabel} {t.pricingLabel}
</span> </span>
<h2 className="mt-4 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl"> <h2 className="mt-4 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl">
+32
View File
@@ -210,6 +210,38 @@
); );
} }
@utility rhomboid {
/* Rhomboid (skewed parallelogram) chrome drawn on a pseudo-element so the
label itself stays upright. Colors come from --rhomboid-* (see the
rhomboid-emerald / rhomboid-strong modifiers). */
position: relative;
isolation: isolate;
&::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
transform: skewX(-12deg);
/* Rounded before the skew, so the slanted points read as soft corners
rather than blades. */
border-radius: var(--rhomboid-radius, 6px);
border: 1px solid var(--rhomboid-border, rgba(167, 139, 250, 0.3));
background: var(--rhomboid-bg, rgba(139, 92, 246, 0.1));
backdrop-filter: blur(12px);
}
}
@utility rhomboid-emerald {
--rhomboid-border: rgba(52, 211, 153, 0.3);
--rhomboid-bg: rgba(16, 185, 129, 0.1);
}
@utility rhomboid-strong {
--rhomboid-border: rgba(167, 139, 250, 0.4);
--rhomboid-bg: rgba(139, 92, 246, 0.2);
}
@utility vignette { @utility vignette {
box-shadow: inset 0 0 200px 50px rgba(0, 0, 0, 0.5); box-shadow: inset 0 0 200px 50px rgba(0, 0, 0, 0.5);
} }
+10 -4
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { Mail } from "lucide-react"; import { Mail, Phone } from "lucide-react";
import { FaLinkedin } from "react-icons/fa6"; import { FaLinkedin } from "react-icons/fa6";
import { SiGitea } from "react-icons/si"; import { SiGitea, SiViber, SiWhatsapp } from "react-icons/si";
import type { Locale, Dictionary } from "@/lib/dictionaries"; import type { Locale, Dictionary } from "@/lib/dictionaries";
import { LogoMark } from "@/components/ui/Logo"; import { LogoMark } from "@/components/ui/Logo";
@@ -23,6 +23,7 @@ export default function Footer({ locale, dict }: FooterProps) {
[dict.footer.connect]: [ [dict.footer.connect]: [
{ label: "Source Code", href: "https://git.ksan.dev/ksan", external: true }, { label: "Source Code", href: "https://git.ksan.dev/ksan", external: true },
{ label: "Email", href: "mailto:contact@ksan.dev", external: true }, { label: "Email", href: "mailto:contact@ksan.dev", external: true },
{ label: "+387 66 644 094", href: "tel:+38766644094", external: true },
{ label: "LinkedIn", href: "https://www.linkedin.com/in/%C4%91or%C4%91e-k%C5%A1an-886871266", external: true }, { label: "LinkedIn", href: "https://www.linkedin.com/in/%C4%91or%C4%91e-k%C5%A1an-886871266", external: true },
], ],
}; };
@@ -30,6 +31,11 @@ export default function Footer({ locale, dict }: FooterProps) {
const socials = [ const socials = [
{ icon: SiGitea, href: "https://git.ksan.dev/ksan", label: "Gitea" }, { icon: SiGitea, href: "https://git.ksan.dev/ksan", label: "Gitea" },
{ icon: Mail, href: "mailto:contact@ksan.dev", label: "Email" }, { icon: Mail, href: "mailto:contact@ksan.dev", label: "Email" },
{ icon: Phone, href: "tel:+38766644094", label: "Phone" },
// wa.me hands off to the app on mobile and WhatsApp Web on desktop;
// viber:// only resolves if the Viber app is installed.
{ icon: SiWhatsapp, href: "https://wa.me/38766644094", label: "WhatsApp" },
{ icon: SiViber, href: "viber://chat?number=%2B38766644094", label: "Viber" },
{ icon: FaLinkedin, href: "https://www.linkedin.com/in/%C4%91or%C4%91e-k%C5%A1an-886871266/", label: "LinkedIn" }, { icon: FaLinkedin, href: "https://www.linkedin.com/in/%C4%91or%C4%91e-k%C5%A1an-886871266/", label: "LinkedIn" },
]; ];
@@ -84,8 +90,8 @@ export default function Footer({ locale, dict }: FooterProps) {
{"external" in link && link.external ? ( {"external" in link && link.external ? (
<a <a
href={link.href} href={link.href}
target="_blank" target={link.href.startsWith("http") ? "_blank" : undefined}
rel="noopener noreferrer" rel={link.href.startsWith("http") ? "noopener noreferrer" : undefined}
className="text-sm text-text-secondary transition-colors hover:text-text-primary" className="text-sm text-text-secondary transition-colors hover:text-text-primary"
> >
{link.label} {link.label}
+46 -4
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useState, type ElementType } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { SiGitea } from "react-icons/si"; import { SiGitea, SiViber, SiWhatsapp } from "react-icons/si";
import { import {
ArrowRight, ArrowRight,
Mail, Mail,
Phone,
MapPin, MapPin,
Clock, Clock,
Send, Send,
@@ -14,6 +15,7 @@ import {
AlertCircle, AlertCircle,
} from "lucide-react"; } from "lucide-react";
import GlassCard from "@/components/ui/GlassCard"; import GlassCard from "@/components/ui/GlassCard";
import PhoneChoiceModal from "@/components/ui/PhoneChoiceModal";
import GlowButton from "@/components/ui/GlowButton"; import GlowButton from "@/components/ui/GlowButton";
import { fadeUp, stagger, viewportConfig } from "@/lib/animations"; import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
import type { Dictionary } from "@/lib/dictionaries"; import type { Dictionary } from "@/lib/dictionaries";
@@ -22,11 +24,14 @@ interface ContactFormProps {
dict: Dictionary; dict: Dictionary;
} }
const PHONE_NUMBER = "+387 66 644 094";
export default function ContactForm({ dict }: ContactFormProps) { export default function ContactForm({ dict }: ContactFormProps) {
const t = dict.contactPage; const t = dict.contactPage;
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">( const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">(
"idle", "idle",
); );
const [phoneOpen, setPhoneOpen] = useState(false);
const [errorMsg, setErrorMsg] = useState(""); const [errorMsg, setErrorMsg] = useState("");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
@@ -67,13 +72,27 @@ export default function ContactForm({ dict }: ContactFormProps) {
} }
} }
const contactInfo = [ const contactInfo: {
icon: ElementType;
label: string;
value: string;
href?: string;
note?: string;
onClick?: () => void;
}[] = [
{ {
icon: Mail, icon: Mail,
label: t.emailLabel, label: t.emailLabel,
value: "contact@ksan.dev", value: "contact@ksan.dev",
href: "mailto:contact@ksan.dev", href: "mailto:contact@ksan.dev",
}, },
{
icon: Phone,
label: t.phoneLabel,
value: PHONE_NUMBER,
note: t.phoneNote,
onClick: () => setPhoneOpen(true),
},
{ {
icon: SiGitea, icon: SiGitea,
label: "Source Code", label: "Source Code",
@@ -209,8 +228,15 @@ export default function ContactForm({ dict }: ContactFormProps) {
<div className="mt-0.5 truncate text-sm font-medium text-text-primary"> <div className="mt-0.5 truncate text-sm font-medium text-text-primary">
{item.value} {item.value}
</div> </div>
{item.note && (
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-text-secondary">
<SiViber className="h-3 w-3 shrink-0 text-purple-300" />
<SiWhatsapp className="h-3 w-3 shrink-0 text-emerald-300" />
<span className="truncate">{item.note}</span>
</div>
)}
</div> </div>
{item.href && ( {(item.href || item.onClick) && (
<ArrowRight <ArrowRight
className="h-4 w-4 shrink-0 text-text-secondary transition-transform group-hover:translate-x-0.5" className="h-4 w-4 shrink-0 text-text-secondary transition-transform group-hover:translate-x-0.5"
strokeWidth={1.5} strokeWidth={1.5}
@@ -233,11 +259,27 @@ export default function ContactForm({ dict }: ContactFormProps) {
> >
{inner} {inner}
</a> </a>
) : item.onClick ? (
<button
key={item.label}
type="button"
onClick={item.onClick}
className="group block w-full text-left"
>
{inner}
</button>
) : ( ) : (
<div key={item.label}>{inner}</div> <div key={item.label}>{inner}</div>
); );
})} })}
</motion.div> </motion.div>
<PhoneChoiceModal
open={phoneOpen}
number={PHONE_NUMBER}
dict={dict}
onClose={() => setPhoneOpen(false)}
/>
</motion.div> </motion.div>
); );
} }
+1 -12
View File
@@ -14,7 +14,7 @@ interface HeroProps {
export default function Hero({ locale, dict }: HeroProps) { export default function Hero({ locale, dict }: HeroProps) {
const stats = [ const stats = [
{ value: "13+", label: dict.hero.stats.projects }, { value: "13+", label: dict.hero.stats.projects },
{ value: "4", label: dict.hero.stats.clients }, { value: "5", label: dict.hero.stats.clients },
{ value: "10", label: dict.hero.stats.infra }, { value: "10", label: dict.hero.stats.infra },
{ value: "<24h", label: dict.hero.stats.response }, { value: "<24h", label: dict.hero.stats.response },
]; ];
@@ -65,17 +65,6 @@ export default function Hero({ locale, dict }: HeroProps) {
animate="visible" animate="visible"
className="mx-auto max-w-4xl text-center" className="mx-auto max-w-4xl text-center"
> >
{/* Badge */}
<motion.div variants={fadeUp} className="mb-8 flex justify-center">
<span className="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>
{dict.hero.badge}
</span>
</motion.div>
{/* Heading */} {/* Heading */}
<motion.h1 <motion.h1
variants={fadeUp} variants={fadeUp}
+1 -1
View File
@@ -274,7 +274,7 @@ export default function ProjectsCarousel({
<div> <div>
<motion.span <motion.span
variants={fadeUp} variants={fadeUp}
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 backdrop-blur-md" className="mb-4 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300"
> >
{dict.projects.badge} {dict.projects.badge}
</motion.span> </motion.span>
+1 -1
View File
@@ -32,7 +32,7 @@ export default function Services({ dict }: ServicesProps) {
> >
<motion.span <motion.span
variants={fadeUp} 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" className="mb-6 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300"
> >
{dict.services.badge} {dict.services.badge}
</motion.span> </motion.span>
+1 -1
View File
@@ -68,7 +68,7 @@ export default function TechStack({ dict }: TechStackProps) {
> >
<motion.span <motion.span
variants={fadeUp} 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" className="mb-6 inline-flex items-center gap-2 rhomboid px-5 py-1.5 font-mono text-xs tracking-tight text-purple-300"
> >
{dict.techStack.badge} {dict.techStack.badge}
</motion.span> </motion.span>
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useEffect, useId, useRef, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { motion } from "framer-motion";
import { Phone, X, ArrowRight } from "lucide-react";
import { SiViber, SiWhatsapp } from "react-icons/si";
import type { Dictionary } from "@/lib/dictionaries";
interface PhoneChoiceModalProps {
open: boolean;
number: string;
dict: Dictionary;
onClose: () => void;
}
// SSR-safe client gate for the portal: false on the server, true after
// hydration — no effect, no setState, no hydration mismatch.
const emptySubscribe = () => () => {};
function useIsClient() {
return useSyncExternalStore(emptySubscribe, () => true, () => false);
}
export default function PhoneChoiceModal({
open,
number,
dict,
onClose,
}: PhoneChoiceModalProps) {
const mounted = useIsClient();
const t = dict.contactPage;
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const restoreFocusRef = useRef<HTMLElement | null>(null);
// E.164 for the app links; the display string keeps its spaces.
const e164 = number.replace(/[^\d+]/g, "");
const bare = e164.replace(/^\+/, "");
const options = [
{
icon: Phone,
label: t.phoneCall,
hint: t.phoneCallHint,
href: `tel:${e164}`,
tint: "text-purple-300 border-purple-400/25 bg-purple-500/10",
},
{
icon: SiWhatsapp,
label: "WhatsApp",
hint: t.phoneWhatsAppHint,
href: `https://wa.me/${bare}`,
tint: "text-emerald-300 border-emerald-400/25 bg-emerald-500/10",
},
{
icon: SiViber,
label: "Viber",
hint: t.phoneViberHint,
// Only resolves when the Viber app is installed — it has no web fallback.
href: `viber://chat?number=${encodeURIComponent(e164)}`,
tint: "text-violet-300 border-violet-400/25 bg-violet-500/10",
},
];
// Body scroll lock with scrollbar-width compensation (prevents layout shift).
useEffect(() => {
if (!open) return;
const scrollbarWidth =
window.innerWidth - document.documentElement.clientWidth;
const prevOverflow = document.body.style.overflow;
const prevPadding = document.body.style.paddingRight;
document.body.style.overflow = "hidden";
if (scrollbarWidth > 0)
document.body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
document.body.style.overflow = prevOverflow;
document.body.style.paddingRight = prevPadding;
};
}, [open]);
// Focus management: trap Tab, close on Escape, restore focus on close.
useEffect(() => {
if (!open) return;
restoreFocusRef.current = document.activeElement as HTMLElement | null;
panelRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab" || !panelRef.current) return;
const focusables = panelRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
restoreFocusRef.current?.focus?.();
};
}, [open, onClose]);
if (!mounted) return null;
const overlay = (
// Static container — never animates opacity, so the backdrop-filter below
// keeps its own compositing layer (see ProjectModal for the why).
<div
className="fixed inset-0 z-[80] flex items-center justify-center p-4"
aria-hidden={!open}
style={{ pointerEvents: open ? "auto" : "none" }}
>
<motion.div
className="absolute inset-0 bg-black/75 backdrop-blur-sm"
initial={false}
animate={{ opacity: open ? 1 : 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{ willChange: "opacity", transform: "translateZ(0)" }}
onClick={onClose}
/>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
initial={false}
animate={{
opacity: open ? 1 : 0,
scale: open ? 1 : 0.96,
y: open ? 0 : 8,
}}
transition={{ duration: 0.22, ease: [0.16, 1, 0.3, 1] }}
className="relative w-full max-w-sm rounded-2xl border border-white/10 bg-[#0f0d1a] p-6 shadow-2xl outline-none"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-lg bg-white/5 text-text-secondary transition-colors hover:bg-white/10 hover:text-text-primary"
aria-label={t.phoneModalClose}
>
<X className="h-4 w-4" />
</button>
<h3
id={titleId}
className="pr-10 font-display text-lg font-medium text-text-primary"
>
{t.phoneModalTitle}
</h3>
<p className="mt-1 font-mono text-xs tracking-tight text-purple-300">
{number}
</p>
<p className="mt-3 text-sm text-text-secondary">
{t.phoneModalSubtitle}
</p>
<div className="mt-5 space-y-2">
{options.map((option) => (
<a
key={option.label}
href={option.href}
target={option.href.startsWith("http") ? "_blank" : undefined}
rel={
option.href.startsWith("http")
? "noopener noreferrer"
: undefined
}
onClick={onClose}
className="group flex items-center gap-4 rounded-xl border border-border/50 bg-surface/40 px-4 py-3 transition-all hover:border-purple-400/40 hover:bg-purple-500/5"
>
<span
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border ${option.tint}`}
>
<option.icon className="h-4 w-4" strokeWidth={1.5} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-text-primary">
{option.label}
</span>
<span className="block truncate text-xs text-text-secondary">
{option.hint}
</span>
</span>
<ArrowRight
className="h-4 w-4 shrink-0 text-text-secondary transition-transform group-hover:translate-x-0.5"
strokeWidth={1.5}
/>
</a>
))}
</div>
</motion.div>
</div>
);
return createPortal(overlay, document.body);
}
+13 -5
View File
@@ -17,9 +17,9 @@ export type Dictionary = typeof dictionaries.en;
// independently of type. Lower numbers come first. `inCarousel: false` hides // independently of type. Lower numbers come first. `inCarousel: false` hides
// a project from the homepage carousel while keeping it on the projects page. // a project from the homepage carousel while keeping it on the projects page.
const projectsMeta = { const projectsMeta = {
"security-consultancy": { order: 1, carouselOrder: 4, inCarousel: true, tags: ["React", "Node.js", "Express", "JavaScript"], live: true, type: "client" as const, url: "https://obzor.rs/", github: "", download: "", inProgress: false, screenshots: ["/site1.png"] as string[] }, "security-consultancy": { order: 2, carouselOrder: 4, inCarousel: true, tags: ["React", "Node.js", "Express", "JavaScript"], live: true, type: "client" as const, url: "https://obzor.rs/", github: "", download: "", inProgress: false, screenshots: ["/site1.png"] as string[] },
"ecommerce-platform": { order: 2, carouselOrder: 5, inCarousel: true, tags: ["React", "Express.js", "Docker", "CI/CD"], live: true, type: "client" as const, url: "https://kupitri.com", github: "", download: "", inProgress: false, screenshots: ["/site2.png"] as string[] }, "ecommerce-platform": { order: 3, carouselOrder: 5, inCarousel: true, tags: ["React", "Express.js", "Docker", "CI/CD"], live: true, type: "client" as const, url: "https://kupitri.com", github: "", download: "", inProgress: false, screenshots: ["/site2.png"] as string[] },
"pramoal": { order: 3, carouselOrder: 3, inCarousel: true, tags: ["Next.js", "Tailwind CSS", "SEO"], live: true, type: "client" as const, url: "https://pramoal.rs", github: "", download: "", inProgress: false, screenshots: ["/pramoal.png"] as string[] }, "pramoal": { order: 1, carouselOrder: 3, inCarousel: true, tags: ["Next.js", "Tailwind CSS", "SEO"], live: true, type: "client" as const, url: "https://pramoal.rs", github: "", download: "", inProgress: false, screenshots: ["/pramoal.png"] as string[] },
"mobile-companion": { order: 4, carouselOrder: 3, inCarousel: false, tags: ["Java", "Spring Boot", "React Native", "PostgreSQL"], live: false, type: "client" as const, url: "", github: "", download: "", inProgress: true, screenshots: ["/tba.png"] as string[] }, "mobile-companion": { order: 4, carouselOrder: 3, inCarousel: false, tags: ["Java", "Spring Boot", "React Native", "PostgreSQL"], live: false, type: "client" as const, url: "", github: "", download: "", inProgress: true, screenshots: ["/tba.png"] as string[] },
"business-landing": { order: 5, carouselOrder: 4, inCarousel: false, tags: ["Next.js", "Tailwind CSS"], live: false, type: "client" as const, url: "", github: "", download: "", inProgress: true, screenshots: ["/tba.png"] as string[] }, "business-landing": { order: 5, carouselOrder: 4, inCarousel: false, tags: ["Next.js", "Tailwind CSS"], live: false, type: "client" as const, url: "", github: "", download: "", inProgress: true, screenshots: ["/tba.png"] as string[] },
"etf-oglasi": { order: 1, carouselOrder: 1, inCarousel: true, tags: ["React Native", "Node.js"], live: false, type: "personal" as const, url: "", github: "https://git.ksan.dev/ksan/etf-oglasi", download: "https://git.ksan.dev/ksan/etf-oglasi/releases/tag/mobile-v6", inProgress: false, screenshots: ["/etfoglasi.png", "/etf2.png", "/etfoglasiapp.jpg", "/etf1.png"] as string[] }, "etf-oglasi": { order: 1, carouselOrder: 1, inCarousel: true, tags: ["React Native", "Node.js"], live: false, type: "personal" as const, url: "", github: "https://git.ksan.dev/ksan/etf-oglasi", download: "https://git.ksan.dev/ksan/etf-oglasi/releases/tag/mobile-v6", inProgress: false, screenshots: ["/etfoglasi.png", "/etf2.png", "/etfoglasiapp.jpg", "/etf1.png"] as string[] },
@@ -34,7 +34,6 @@ export const dictionaries = {
en: { en: {
nav: { features: "Features", projects: "Projects", services: "Services", about: "About", contact: "Contact" }, nav: { features: "Features", projects: "Projects", services: "Services", about: "About", contact: "Contact" },
hero: { hero: {
badge: "Available for freelance & full-time",
titleLine1: "Full-stack engineering,", titleLine2: "end to end.", titleLine1: "Full-stack engineering,", titleLine2: "end to end.",
subtitle: "From idea to production-ready software. I build and deploy end-to-end systems — including web applications, backend services, and APIs — into production.", subtitle: "From idea to production-ready software. I build and deploy end-to-end systems — including web applications, backend services, and APIs — into production.",
cta: "Start a project", ctaSecondary: "See what I do", cta: "Start a project", ctaSecondary: "See what I do",
@@ -272,6 +271,11 @@ export const dictionaries = {
messageLabel: "Message", messagePlaceholder: "Tell me about your project...", messageLabel: "Message", messagePlaceholder: "Tell me about your project...",
send: "Send message", sending: "Sending...", avgResponse: "Average response time: under 24h", send: "Send message", sending: "Sending...", avgResponse: "Average response time: under 24h",
successTitle: "Message sent.", successMessage: "Thanks for reaching out — I'll get back to you within 24 hours.", sendAnother: "Send another message", successTitle: "Message sent.", successMessage: "Thanks for reaching out — I'll get back to you within 24 hours.", sendAnother: "Send another message",
phoneLabel: "Phone", phoneNote: "Also on Viber & WhatsApp",
phoneModalTitle: "Reach me by phone", phoneModalSubtitle: "Pick how you'd like to get in touch.",
phoneCall: "Call", phoneCallHint: "Opens your dialer",
phoneWhatsAppHint: "Opens a WhatsApp chat", phoneViberHint: "Opens the Viber app",
phoneModalClose: "Close",
responseTime: "Response time", responseValue: "Usually within 24h", responseTime: "Response time", responseValue: "Usually within 24h",
location: "Location", locationValue: "Banja Luka, Bosnia and Herzegovina", location: "Location", locationValue: "Banja Luka, Bosnia and Herzegovina",
}, },
@@ -284,7 +288,6 @@ export const dictionaries = {
sr: { sr: {
nav: { features: "Vještine", projects: "Projekti", services: "Usluge", about: "O meni", contact: "Kontakt" }, nav: { features: "Vještine", projects: "Projekti", services: "Usluge", about: "O meni", contact: "Kontakt" },
hero: { hero: {
badge: "Dostupan za freelance i stalni posao",
titleLine1: "Full-stack inženjering,", titleLine2: "end-to-end", titleLine1: "Full-stack inženjering,", titleLine2: "end-to-end",
subtitle: "Od ideje do produkcijski spremnog softvera. Razvijam i deploy-ujem end-to-end sisteme — od web aplikacija i backend servisa do API-ja — u produkcijsko okruženje.", subtitle: "Od ideje do produkcijski spremnog softvera. Razvijam i deploy-ujem end-to-end sisteme — od web aplikacija i backend servisa do API-ja — u produkcijsko okruženje.",
cta: "Započni projekat", ctaSecondary: "Pogledaj usluge", cta: "Započni projekat", ctaSecondary: "Pogledaj usluge",
@@ -490,6 +493,11 @@ export const dictionaries = {
messageLabel: "Poruka", messagePlaceholder: "Ispričajte mi o svom projektu...", messageLabel: "Poruka", messagePlaceholder: "Ispričajte mi o svom projektu...",
send: "Pošalji poruku", sending: "Slanje...", avgResponse: "Prosječno vrijeme odgovora: ispod 24h", send: "Pošalji poruku", sending: "Slanje...", avgResponse: "Prosječno vrijeme odgovora: ispod 24h",
successTitle: "Poruka poslata.", successMessage: "Hvala na poruci — javiću vam se u roku od 24 sata.", sendAnother: "Pošalji novu poruku", successTitle: "Poruka poslata.", successMessage: "Hvala na poruci — javiću vam se u roku od 24 sata.", sendAnother: "Pošalji novu poruku",
phoneLabel: "Telefon", phoneNote: "Dostupan i na Viberu i WhatsAppu",
phoneModalTitle: "Kontakt telefonom", phoneModalSubtitle: "Izaberite način na koji želite da me kontaktirate.",
phoneCall: "Pozovi", phoneCallHint: "Otvara aplikaciju za pozive",
phoneWhatsAppHint: "Otvara WhatsApp razgovor", phoneViberHint: "Otvara Viber aplikaciju",
phoneModalClose: "Zatvori",
responseTime: "Vrijeme odgovora", responseValue: "Obično ispod 24h", responseTime: "Vrijeme odgovora", responseValue: "Obično ispod 24h",
location: "Lokacija", locationValue: "Banja Luka, Bosna i Hercegovina", location: "Lokacija", locationValue: "Banja Luka, Bosna i Hercegovina",
}, },