Add Viber/WhatsApp contact options
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user