From 5562f6c35b7b95e9620e73ebe30ebb695a23cecc Mon Sep 17 00:00:00 2001 From: Ksan Date: Mon, 31 Aug 2026 12:25:13 +0200 Subject: [PATCH] Add Viber/WhatsApp contact options --- components/layout/Footer.tsx | 6 +- components/sections/ContactForm.tsx | 47 ++++++- components/ui/PhoneChoiceModal.tsx | 211 ++++++++++++++++++++++++++++ lib/dictionaries.ts | 12 +- 4 files changed, 267 insertions(+), 9 deletions(-) create mode 100644 components/ui/PhoneChoiceModal.tsx diff --git a/components/layout/Footer.tsx b/components/layout/Footer.tsx index 1ebca29..62ca848 100644 --- a/components/layout/Footer.tsx +++ b/components/layout/Footer.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; 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"; @@ -32,6 +32,10 @@ export default function Footer({ locale, dict }: FooterProps) { { 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" }, ]; diff --git a/components/sections/ContactForm.tsx b/components/sections/ContactForm.tsx index 2f16daf..659ec90 100644 --- a/components/sections/ContactForm.tsx +++ b/components/sections/ContactForm.tsx @@ -1,8 +1,8 @@ "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, @@ -15,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"; @@ -23,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) { @@ -68,7 +72,14 @@ 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, @@ -78,8 +89,9 @@ export default function ContactForm({ dict }: ContactFormProps) { { icon: Phone, label: t.phoneLabel, - value: "+387 66 644 094", - href: "tel:+38766644094", + value: PHONE_NUMBER, + note: t.phoneNote, + onClick: () => setPhoneOpen(true), }, { icon: SiGitea, @@ -216,8 +228,15 @@ export default function ContactForm({ dict }: ContactFormProps) {
{item.value}
+ {item.note && ( +
+ + + {item.note} +
+ )} - {item.href && ( + {(item.href || item.onClick) && ( {inner} + ) : item.onClick ? ( + ) : (
{inner}
); })} + + setPhoneOpen(false)} + /> ); } diff --git a/components/ui/PhoneChoiceModal.tsx b/components/ui/PhoneChoiceModal.tsx new file mode 100644 index 0000000..7aa165f --- /dev/null +++ b/components/ui/PhoneChoiceModal.tsx @@ -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(null); + const restoreFocusRef = useRef(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( + '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). +
+ + + e.stopPropagation()} + > + + +

+ {t.phoneModalTitle} +

+

+ {number} +

+

+ {t.phoneModalSubtitle} +

+ +
+ {options.map((option) => ( + + + + + + + {option.label} + + + {option.hint} + + + + + ))} +
+
+
+ ); + + return createPortal(overlay, document.body); +} diff --git a/lib/dictionaries.ts b/lib/dictionaries.ts index 9739a9f..b05296b 100644 --- a/lib/dictionaries.ts +++ b/lib/dictionaries.ts @@ -271,7 +271,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", + 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", }, @@ -489,7 +493,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", + 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", },