Compare commits
8 Commits
a4cc12d763
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 92d6c8641a | |||
| 62e2e29879 | |||
| a42970e03f | |||
| 4f959d0d80 | |||
| b53f055b3a | |||
| 5562f6c35b | |||
| 9485daf99e | |||
| d6dbc71071 |
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
GitBranch,
|
||||
Lock,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Globe,
|
||||
Briefcase,
|
||||
Clock,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
@@ -42,6 +44,7 @@ type ApproachParagraph = TextParagraph | LinkParagraph;
|
||||
|
||||
export default function AboutContent({ locale, dict }: AboutContentProps) {
|
||||
const t = dict.aboutPage;
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -54,7 +57,7 @@ export default function AboutContent({ locale, dict }: AboutContentProps) {
|
||||
<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"
|
||||
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}
|
||||
</motion.span>
|
||||
@@ -152,6 +155,85 @@ export default function AboutContent({ locale, dict }: AboutContentProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<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.faqTitle}
|
||||
</motion.h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.06)}
|
||||
className="mx-auto flex max-w-3xl flex-col gap-3"
|
||||
>
|
||||
{t.faq.map((item, i) => {
|
||||
const isOpen = openFaq === i;
|
||||
return (
|
||||
<motion.div key={i} variants={fadeUp}>
|
||||
<GlassCard
|
||||
className={`p-0 ${isOpen ? "border-purple-400/30 shadow-glow-sm" : ""}`}
|
||||
hover={false}
|
||||
>
|
||||
<h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenFaq(isOpen ? null : i)}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`faq-answer-${i}`}
|
||||
className="flex w-full items-center justify-between gap-4 rounded-2xl px-6 py-5 text-left transition-colors duration-300 hover:text-purple-200"
|
||||
>
|
||||
<span className="text-base font-medium text-text-primary sm:text-lg">
|
||||
{item.question}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-purple-400/20 bg-purple-500/10 text-purple-300 transition-all duration-500 ${
|
||||
isOpen ? "rotate-45 border-purple-400/50 bg-purple-500/20" : ""
|
||||
}`}
|
||||
>
|
||||
<Plus className="h-4 w-4" strokeWidth={1.5} />
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
id={`faq-answer-${i}`}
|
||||
key="answer"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<p className="border-t border-border/40 px-6 pb-5 pt-4 text-sm leading-relaxed text-text-secondary">
|
||||
{item.answer}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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">
|
||||
|
||||
@@ -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="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="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" />
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function PlansContent({ locale, plans }: PlansContentProps) {
|
||||
>
|
||||
<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"
|
||||
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} />
|
||||
{t.badge}
|
||||
@@ -92,7 +92,7 @@ export default function PlansContent({ locale, plans }: PlansContentProps) {
|
||||
}`}
|
||||
>
|
||||
{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" />
|
||||
{t.popularBadge}
|
||||
</span>
|
||||
|
||||
@@ -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="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}
|
||||
</span>
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function ServicesContent({
|
||||
>
|
||||
<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"
|
||||
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}
|
||||
</motion.span>
|
||||
@@ -114,7 +114,7 @@ export default function ServicesContent({
|
||||
>
|
||||
<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"
|
||||
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}
|
||||
</motion.span>
|
||||
@@ -198,7 +198,7 @@ export default function ServicesContent({
|
||||
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="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="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)]" />
|
||||
@@ -255,7 +255,7 @@ export default function ServicesContent({
|
||||
>
|
||||
<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"
|
||||
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}
|
||||
</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">
|
||||
<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}
|
||||
</span>
|
||||
<h2 className="mt-4 font-display text-3xl font-medium tracking-tight text-gradient-fade sm:text-4xl">
|
||||
|
||||
@@ -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 {
|
||||
box-shadow: inset 0 0 200px 50px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import { Mail } from "lucide-react";
|
||||
import { Mail, Phone } from "lucide-react";
|
||||
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 { LogoMark } from "@/components/ui/Logo";
|
||||
import FooterPhoneLink from "@/components/layout/FooterPhoneLink";
|
||||
|
||||
interface FooterProps {
|
||||
locale: Locale;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
const PHONE_NUMBER = "+387 66 644 094";
|
||||
|
||||
export default function Footer({ locale, dict }: FooterProps) {
|
||||
const footerLinks = {
|
||||
[dict.footer.product]: [
|
||||
@@ -23,6 +26,8 @@ export default function Footer({ locale, dict }: FooterProps) {
|
||||
[dict.footer.connect]: [
|
||||
{ label: "Source Code", href: "https://git.ksan.dev/ksan", external: true },
|
||||
{ label: "Email", href: "mailto:contact@ksan.dev", external: true },
|
||||
// Opens the call / WhatsApp / Viber picker instead of jumping straight to tel:.
|
||||
{ label: PHONE_NUMBER, href: "tel:+38766644094", phone: true },
|
||||
{ label: "LinkedIn", href: "https://www.linkedin.com/in/%C4%91or%C4%91e-k%C5%A1an-886871266", external: true },
|
||||
],
|
||||
};
|
||||
@@ -30,6 +35,11 @@ export default function Footer({ locale, dict }: FooterProps) {
|
||||
const socials = [
|
||||
{ icon: SiGitea, href: "https://git.ksan.dev/ksan", label: "Gitea" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
@@ -81,11 +91,13 @@ export default function Footer({ locale, dict }: FooterProps) {
|
||||
<ul className="space-y-3">
|
||||
{links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{"external" in link && link.external ? (
|
||||
{"phone" in link && link.phone ? (
|
||||
<FooterPhoneLink number={link.label} dict={dict} />
|
||||
) : "external" in link && link.external ? (
|
||||
<a
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={link.href.startsWith("http") ? "noopener noreferrer" : undefined}
|
||||
className="text-sm text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
{link.label}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import PhoneChoiceModal from "@/components/ui/PhoneChoiceModal";
|
||||
import type { Dictionary } from "@/lib/dictionaries";
|
||||
|
||||
interface FooterPhoneLinkProps {
|
||||
number: string;
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
// Keeps the footer itself a server component — only the phone entry needs state.
|
||||
export default function FooterPhoneLink({ number, dict }: FooterPhoneLinkProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="text-left text-sm text-text-secondary transition-colors hover:text-text-primary"
|
||||
>
|
||||
{number}
|
||||
</button>
|
||||
|
||||
<PhoneChoiceModal
|
||||
open={open}
|
||||
number={number}
|
||||
dict={dict}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type ElementType } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { SiGitea } from "react-icons/si";
|
||||
import { SiGitea, SiViber, SiWhatsapp } from "react-icons/si";
|
||||
import {
|
||||
ArrowRight,
|
||||
Mail,
|
||||
Phone,
|
||||
MapPin,
|
||||
Clock,
|
||||
Send,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import PhoneChoiceModal from "@/components/ui/PhoneChoiceModal";
|
||||
import GlowButton from "@/components/ui/GlowButton";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary } from "@/lib/dictionaries";
|
||||
@@ -22,11 +24,14 @@ interface ContactFormProps {
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
const PHONE_NUMBER = "+387 66 644 094";
|
||||
|
||||
export default function ContactForm({ dict }: ContactFormProps) {
|
||||
const t = dict.contactPage;
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [phoneOpen, setPhoneOpen] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
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,
|
||||
label: t.emailLabel,
|
||||
value: "contact@ksan.dev",
|
||||
href: "mailto:contact@ksan.dev",
|
||||
},
|
||||
{
|
||||
icon: Phone,
|
||||
label: t.phoneLabel,
|
||||
value: PHONE_NUMBER,
|
||||
note: t.phoneNote,
|
||||
onClick: () => setPhoneOpen(true),
|
||||
},
|
||||
{
|
||||
icon: SiGitea,
|
||||
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">
|
||||
{item.value}
|
||||
</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>
|
||||
{item.href && (
|
||||
)}
|
||||
</div>
|
||||
{(item.href || item.onClick) && (
|
||||
<ArrowRight
|
||||
className="h-4 w-4 shrink-0 text-text-secondary transition-transform group-hover:translate-x-0.5"
|
||||
strokeWidth={1.5}
|
||||
@@ -233,11 +259,27 @@ export default function ContactForm({ dict }: ContactFormProps) {
|
||||
>
|
||||
{inner}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
<PhoneChoiceModal
|
||||
open={phoneOpen}
|
||||
number={PHONE_NUMBER}
|
||||
dict={dict}
|
||||
onClose={() => setPhoneOpen(false)}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ interface HeroProps {
|
||||
export default function Hero({ locale, dict }: HeroProps) {
|
||||
const stats = [
|
||||
{ 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: "<24h", label: dict.hero.stats.response },
|
||||
];
|
||||
@@ -65,17 +65,6 @@ export default function Hero({ locale, dict }: HeroProps) {
|
||||
animate="visible"
|
||||
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 */}
|
||||
<motion.h1
|
||||
variants={fadeUp}
|
||||
|
||||
@@ -274,7 +274,7 @@ export default function ProjectsCarousel({
|
||||
<div>
|
||||
<motion.span
|
||||
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}
|
||||
</motion.span>
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function Services({ dict }: ServicesProps) {
|
||||
>
|
||||
<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"
|
||||
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}
|
||||
</motion.span>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function TechStack({ dict }: TechStackProps) {
|
||||
>
|
||||
<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"
|
||||
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}
|
||||
</motion.span>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+15
-2
@@ -1,8 +1,21 @@
|
||||
services:
|
||||
ksan-dev:
|
||||
# Built and pushed by CI; deployed by tag, never by :latest, so a rollback
|
||||
# is a re-run of `up -d` with an older tag instead of a rebuild.
|
||||
# Normally built and pushed by CI; deployed by tag, never by :latest, so a
|
||||
# rollback is a re-run of `up -d` with an older tag instead of a rebuild.
|
||||
image: ${IMAGE_NAME:-git.ksan.dev/ksan/ksan.dev}:${IMAGE_TAG:-latest}
|
||||
# TEMPORARY — while the Gitea workflows sit at .disabled, nothing builds or
|
||||
# pushes an image, so the VPS builds from its own checkout instead:
|
||||
#
|
||||
# git pull && docker compose up -d --build
|
||||
#
|
||||
# PLANS_PAGES must be in the VPS .env for this to work; it is a build arg
|
||||
# (the codes are baked in via generateStaticParams), not a runtime var.
|
||||
# Remove this block when CI is re-enabled — a registry image is the only
|
||||
# thing `deploy.sh` and `rollback.sh` can deploy by tag.
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
PLANS_PAGES: ${PLANS_PAGES}
|
||||
container_name: ksan-dev
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
|
||||
+89
-5
@@ -17,9 +17,9 @@ export type Dictionary = typeof dictionaries.en;
|
||||
// independently of type. Lower numbers come first. `inCarousel: false` hides
|
||||
// a project from the homepage carousel while keeping it on the projects page.
|
||||
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[] },
|
||||
"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[] },
|
||||
"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[] },
|
||||
"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: 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: 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[] },
|
||||
"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[] },
|
||||
@@ -34,7 +34,6 @@ export const dictionaries = {
|
||||
en: {
|
||||
nav: { features: "Features", projects: "Projects", services: "Services", about: "About", contact: "Contact" },
|
||||
hero: {
|
||||
badge: "Available for freelance & full-time",
|
||||
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.",
|
||||
cta: "Start a project", ctaSecondary: "See what I do",
|
||||
@@ -239,6 +238,44 @@ export const dictionaries = {
|
||||
{ label: "Languages", value: "EN / SR" }, { label: "Open to", value: "Freelance, full-time" },
|
||||
{ label: "Response", value: "< 24h" },
|
||||
],
|
||||
faqTitle: "Frequently asked questions.",
|
||||
faq: [
|
||||
{
|
||||
question: "Can I get a free project estimate?",
|
||||
answer:
|
||||
"Yes. We offer a free, no-obligation estimate for your project. Once you present your ideas, requirements, and goals, we assess the scope of the project, an approximate timeline, and the price. That way you get a clearer picture of the project before you make a decision.",
|
||||
},
|
||||
{
|
||||
question: "How long does it take to build a website?",
|
||||
answer:
|
||||
"The timeline depends on the scope and complexity of the project. Smaller projects usually take 7\u201314 days, while larger and more complex ones can take longer. We define an approximate deadline before the project starts.",
|
||||
},
|
||||
{
|
||||
question: "How much does a website cost?",
|
||||
answer:
|
||||
"Prices are flexible and adapted to each project. The final price depends on your requirements, the functionality, and the scope and complexity of the website. After we talk through the project, we can prepare a concrete offer.",
|
||||
},
|
||||
{
|
||||
question: "What are the payment terms?",
|
||||
answer:
|
||||
"A 30% deposit is paid at the start of the project, and the remaining 70% on completion, before the final handover of the website.",
|
||||
},
|
||||
{
|
||||
question: "Do you offer support after launch?",
|
||||
answer:
|
||||
"Yes. We offer ongoing support and maintenance through different support packages. Depending on the package you choose, support can include a number of changes per month, regular maintenance, content updates, or changes to individual parts of the website.",
|
||||
},
|
||||
{
|
||||
question: "Do you offer hosting and domain registration?",
|
||||
answer:
|
||||
"Yes. We offer hosting for your website at an affordable price, so you can have every service you need in one place. We can also help with registering and configuring your domain, as well as the technical setup of the website on the hosting.",
|
||||
},
|
||||
{
|
||||
question: "Do I have control over the website?",
|
||||
answer:
|
||||
"Yes. The website belongs to you as soon as the project is finished. You get access to and control over your website, and we are here to provide additional support and help whenever you need it.",
|
||||
},
|
||||
],
|
||||
highlightsTitle: "What I bring.",
|
||||
highlights: [
|
||||
{
|
||||
@@ -272,6 +309,11 @@ export const dictionaries = {
|
||||
messageLabel: "Message", messagePlaceholder: "Tell me about your project...",
|
||||
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",
|
||||
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",
|
||||
location: "Location", locationValue: "Banja Luka, Bosnia and Herzegovina",
|
||||
},
|
||||
@@ -284,7 +326,6 @@ export const dictionaries = {
|
||||
sr: {
|
||||
nav: { features: "Vještine", projects: "Projekti", services: "Usluge", about: "O meni", contact: "Kontakt" },
|
||||
hero: {
|
||||
badge: "Dostupan za freelance i stalni posao",
|
||||
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.",
|
||||
cta: "Započni projekat", ctaSecondary: "Pogledaj usluge",
|
||||
@@ -473,6 +514,44 @@ export const dictionaries = {
|
||||
{ label: "Jezici", value: "EN / SR" }, { label: "Otvoren za", value: "Freelance, puno radno" },
|
||||
{ label: "Odgovor", value: "< 24h" },
|
||||
],
|
||||
faqTitle: "\u010cesto postavljana pitanja.",
|
||||
faq: [
|
||||
{
|
||||
question: "Da li mogu dobiti besplatnu procjenu projekta?",
|
||||
answer:
|
||||
"Da. Nudimo besplatnu procjenu va\u0161eg projekta, bez obaveze. Nakon \u0161to nam predstavite svoje ideje, zahtjeve i ciljeve, procijeni\u0107emo obim projekta, okvirno vrijeme izrade i cijenu. Na ovaj na\u010din mo\u017eete dobiti jasniju sliku o projektu prije nego \u0161to donesete odluku.",
|
||||
},
|
||||
{
|
||||
question: "Koliko traje izrada web stranice?",
|
||||
answer:
|
||||
"Vrijeme izrade zavisi od obima i slo\u017eenosti projekta. Za manje projekte, izrada naj\u010de\u0161\u0107e traje 7\u201314 dana, dok za ve\u0107e i kompleksnije projekte rok mo\u017ee biti du\u017ei. Prije po\u010detka projekta defini\u0161emo okvirni rok izrade.",
|
||||
},
|
||||
{
|
||||
question: "Koliko ko\u0161ta izrada web stranice?",
|
||||
answer:
|
||||
"Cijene su fleksibilne i prilago\u0111avaju se svakom projektu. Kona\u010dna cijena zavisi od va\u0161ih zahtjeva, funkcionalnosti, obima i slo\u017eenosti web stranice. Nakon razgovora o projektu mo\u017eemo pripremiti konkretnu ponudu.",
|
||||
},
|
||||
{
|
||||
question: "Koji su uslovi pla\u0107anja?",
|
||||
answer:
|
||||
"Prilikom po\u010detka projekta pla\u0107a se avans od 30%, dok se preostalih 70% pla\u0107a po zavr\u0161etku projekta, prije kona\u010dne predaje web stranice.",
|
||||
},
|
||||
{
|
||||
question: "Da li nudite podr\u0161ku nakon izrade?",
|
||||
answer:
|
||||
"Da. Nudimo kontinuiranu podr\u0161ku i odr\u017eavanje web stranice kroz razli\u010dite pakete podr\u0161ke. U zavisnosti od odabranog paketa, podr\u0161ka mo\u017ee uklju\u010divati nekoliko izmjena mjese\u010dno, redovno odr\u017eavanje, a\u017euriranje sadr\u017eaja ili izmjene pojedinih dijelova web stranice.",
|
||||
},
|
||||
{
|
||||
question: "Da li nudite hosting i registraciju domena?",
|
||||
answer:
|
||||
"Da. Nudimo hosting za va\u0161u web stranicu po povoljnoj cijeni, kako biste sve potrebne usluge mogli imati na jednom mjestu. Mo\u017eemo vam pomo\u0107i i oko registracije i pode\u0161avanja domene, kao i tehni\u010dkog postavljanja web stranice na hosting.",
|
||||
},
|
||||
{
|
||||
question: "Da li ja imam kontrolu nad web stranicom?",
|
||||
answer:
|
||||
"Da. Web stranica pripada vama odmah po zavr\u0161etku projekta. Dobijate pristup i kontrolu nad svojom web stranicom, a mi smo tu da vam pru\u017eimo dodatnu podr\u0161ku i pomo\u0107 kada vam je potrebna.",
|
||||
},
|
||||
],
|
||||
highlightsTitle: "Šta donosim.",
|
||||
highlights: [
|
||||
{ title: "End-to-end vlasništvo", description: "Od ideje do gotovog proizvoda. Radite s jednom osobom koja razumije cijeli stack i sve zahtjeve projekta, bez potrebe za posrednicima ili dijeljenjem odgovornosti." },
|
||||
@@ -490,6 +569,11 @@ export const dictionaries = {
|
||||
messageLabel: "Poruka", messagePlaceholder: "Ispričajte mi o svom projektu...",
|
||||
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",
|
||||
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",
|
||||
location: "Lokacija", locationValue: "Banja Luka, Bosna i Hercegovina",
|
||||
},
|
||||
|
||||
@@ -3,6 +3,25 @@ import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["192.168.100.6"],
|
||||
output: "standalone",
|
||||
// Files in public/ are served with `Cache-Control: public, max-age=0` by
|
||||
// default, because Next.js can't know whether they change. kd-tile.png is
|
||||
// hot-linked by an external HTML email signature, so its URL is a fixed
|
||||
// contract and its bytes never change — pin it to a long-lived immutable
|
||||
// cache so mail clients and their image proxies stop re-fetching it.
|
||||
// Headers are matched before the filesystem, so this applies to public/.
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/kd-tile.png",
|
||||
headers: [
|
||||
{
|
||||
key: "Cache-Control",
|
||||
value: "public, max-age=31536000, immutable",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Reference in New Issue
Block a user