"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); }