Add Viber/WhatsApp contact options
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Mail, Phone } 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";
|
||||||
|
|
||||||
@@ -32,6 +32,10 @@ export default function Footer({ locale, dict }: FooterProps) {
|
|||||||
{ 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" },
|
{ 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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"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,
|
||||||
@@ -15,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";
|
||||||
@@ -23,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>) {
|
||||||
@@ -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,
|
icon: Mail,
|
||||||
label: t.emailLabel,
|
label: t.emailLabel,
|
||||||
@@ -78,8 +89,9 @@ export default function ContactForm({ dict }: ContactFormProps) {
|
|||||||
{
|
{
|
||||||
icon: Phone,
|
icon: Phone,
|
||||||
label: t.phoneLabel,
|
label: t.phoneLabel,
|
||||||
value: "+387 66 644 094",
|
value: PHONE_NUMBER,
|
||||||
href: "tel:+38766644094",
|
note: t.phoneNote,
|
||||||
|
onClick: () => setPhoneOpen(true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: SiGitea,
|
icon: SiGitea,
|
||||||
@@ -216,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>
|
||||||
{item.href && (
|
)}
|
||||||
|
</div>
|
||||||
|
{(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}
|
||||||
@@ -240,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+10
-2
@@ -271,7 +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",
|
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",
|
||||||
},
|
},
|
||||||
@@ -489,7 +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",
|
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",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user