Build New Optic website

This commit is contained in:
2026-05-16 00:04:02 +01:00
commit f6af80e60d
54 changed files with 10422 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import { CheckCircle2 } from "lucide-react";
import type { Messages } from "@/messages";
import AnimatedSection from "./AnimatedSection";
import SectionHeader from "./SectionHeader";
export default function AboutSection({ t }: { t: Messages }) {
return (
<AnimatedSection className="px-4 py-20 sm:px-6">
<div className="mx-auto max-w-7xl rounded-[2.8rem] bg-white/70 p-6 shadow-glass ring-1 ring-ink/5 backdrop-blur sm:p-10 lg:p-14">
<SectionHeader eyebrow={t.about.eyebrow} title={t.about.title} body={t.about.body} />
<div className="mt-10 grid gap-4 md:grid-cols-3">
{t.about.cards.map((card) => (
<div key={card} className="flex items-center gap-3 rounded-3xl border border-ink/8 bg-smoke/70 p-5 text-sm font-semibold text-ink/72">
<CheckCircle2 className="shrink-0 text-optical" size={20} />
<span>{card}</span>
</div>
))}
</div>
</div>
</AnimatedSection>
);
}

View File

@@ -0,0 +1,31 @@
"use client";
import { motion, type HTMLMotionProps } from "framer-motion";
import { useEffect, useState, type ReactNode } from "react";
type AnimatedSectionProps = HTMLMotionProps<"section"> & {
children: ReactNode;
};
export default function AnimatedSection({ children, className = "", ...props }: AnimatedSectionProps) {
const [canReveal, setCanReveal] = useState(false);
useEffect(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wide = window.matchMedia("(min-width: 1024px)").matches;
setCanReveal(wide && !reduced);
}, []);
return (
<motion.section
{...props}
className={className}
initial={canReveal ? { opacity: 0, y: 28 } : false}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.08 }}
transition={{ duration: 0.75, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.section>
);
}

View File

@@ -0,0 +1,49 @@
import { ArrowUpRight } from "lucide-react";
import { motion } from "framer-motion";
import Image from "next/image";
import { business } from "@/config/business";
import type { Messages } from "@/messages";
import AnimatedSection from "./AnimatedSection";
import SectionHeader from "./SectionHeader";
const collectionImages = [
business.assets.prescriptionGlasses,
business.assets.sunglasses,
business.assets.kidsGlasses,
business.assets.budgetFrames
];
export default function CollectionsSection({ t }: { t: Messages }) {
return (
<AnimatedSection id="collections" className="px-4 py-20 sm:px-6">
<div className="mx-auto max-w-7xl">
<SectionHeader eyebrow={t.collections.eyebrow} title={t.collections.title} body={t.collections.body} />
<motion.div className="mt-12 grid gap-5 md:grid-cols-2 lg:grid-cols-4" initial={false} whileInView="show" viewport={{ once: true, amount: 0.08 }} variants={{ show: { transition: { staggerChildren: 0.1 } } }}>
{t.collections.items.map((item, index) => (
<motion.article key={item.title} variants={{ show: { opacity: 1, y: 0, rotateX: 0 } }} transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }} whileHover={{ y: -10, scale: 1.015 }} className="group relative min-h-[23rem] overflow-hidden rounded-[2.2rem] bg-ink p-6 text-white shadow-glass">
<Image
src={collectionImages[index]}
alt={`${business.name} - ${item.title}`}
fill
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 50vw, 92vw"
className="object-cover opacity-68 transition duration-700 group-hover:scale-105 group-hover:opacity-78"
/>
<div className="absolute inset-0 bg-gradient-to-b from-ink/35 via-ink/35 to-ink/92" />
<div className="absolute inset-x-4 bottom-4 top-auto h-28 rounded-[1.7rem] bg-white/10 blur-2xl" />
<div className="relative z-10 flex h-full min-h-[19rem] flex-col justify-between">
<div className="flex items-center justify-between">
<span className="rounded-full bg-white/14 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-white/75 backdrop-blur">0{index + 1}</span>
<ArrowUpRight size={18} className="text-white/50 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:text-white rtl:rotate-[-90deg]" />
</div>
<div className="rounded-[1.6rem] border border-white/12 bg-ink/42 p-5 backdrop-blur-xl">
<h3 className="text-2xl font-semibold tracking-[-0.045em]">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-white/68">{item.text}</p>
</div>
</div>
</motion.article>
))}
</motion.div>
</div>
</AnimatedSection>
);
}

View File

@@ -0,0 +1,61 @@
import { MapPin, Phone, QrCode } from "lucide-react";
import Image from "next/image";
import { business } from "@/config/business";
import type { Messages } from "@/messages";
import AnimatedSection from "./AnimatedSection";
import PhysicsButton from "./PhysicsButton";
export default function ContactSection({ t, whatsappUrl }: { t: Messages; whatsappUrl: string }) {
return (
<AnimatedSection id="contact" className="px-4 py-20 sm:px-6">
<div className="mx-auto grid max-w-7xl gap-6 lg:grid-cols-[1.08fr_0.92fr]">
<div className="rounded-[3rem] bg-white p-8 shadow-glass sm:p-12">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.28em] text-optical/75">{t.contact.eyebrow}</p>
<h2 className="text-4xl font-semibold tracking-[-0.055em] text-ink sm:text-6xl">{t.contact.title}</h2>
<p className="mt-6 max-w-2xl text-base leading-8 text-ink/62 sm:text-lg">{t.contact.body}</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<PhysicsButton href={whatsappUrl} external className="rounded-full bg-ink px-7 py-4 text-sm font-semibold text-white shadow-soft transition-colors hover:bg-optical">
{t.contact.whatsapp}
</PhysicsButton>
<PhysicsButton href={`tel:${business.phone}`} className="rounded-full border border-ink/10 bg-smoke px-7 py-4 text-sm font-semibold text-ink transition-colors hover:bg-white">
{t.contact.call}
</PhysicsButton>
<PhysicsButton href={business.mapUrl} external className="rounded-full border border-ink/10 bg-smoke px-7 py-4 text-sm font-semibold text-ink transition-colors hover:bg-white">
{t.contact.visit}
</PhysicsButton>
</div>
<div className="mt-10 grid gap-4 sm:grid-cols-2">
<Info label={t.contact.phoneLabel} value={business.phone} icon={<Phone size={18} />} />
<Info label={t.contact.whatsappLabel} value={business.whatsapp} icon={<Phone size={18} />} />
<Info label={t.contact.addressLabel} value={business.displayAddress} icon={<MapPin size={18} />} wide />
<Info label={t.contact.hoursLabel} value={t.contact.hoursValue} icon={<QrCode size={18} />} />
</div>
</div>
<div className="glass liquidGL-pane flex flex-col justify-between rounded-[3rem] p-7">
<div className="relative z-[3] aspect-square overflow-hidden rounded-[2.2rem] bg-white p-8 shadow-sm">
<span className="floating-sheen" />
<Image src={business.assets.mapQr} alt={t.contact.qr} fill sizes="(min-width: 1024px) 38vw, 90vw" className="object-contain p-10" />
</div>
<div className="relative z-[3] mt-6 rounded-[2rem] bg-white/70 p-6">
<p className="text-sm font-semibold text-ink">{t.contact.qr}</p>
<p className="mt-3 text-sm leading-7 text-ink/58">{business.displayAddress}</p>
</div>
</div>
</div>
</AnimatedSection>
);
}
function Info({ label, value, icon, wide = false }: { label: string; value: string; icon: React.ReactNode; wide?: boolean }) {
return (
<div className={`rounded-[1.6rem] border border-ink/8 bg-smoke/80 p-5 ${wide ? "sm:col-span-2" : ""}`}>
<div className="mb-3 flex items-center gap-2 text-optical">
{icon}
<p className="text-xs font-semibold uppercase tracking-[0.18em]">{label}</p>
</div>
<p className="text-sm font-semibold leading-7 text-ink/72">{value}</p>
</div>
);
}

30
components/Footer.tsx Normal file
View File

@@ -0,0 +1,30 @@
import Image from "next/image";
import { business, type Locale } from "@/config/business";
import type { Messages } from "@/messages";
import LanguageSwitcher from "./LanguageSwitcher";
export default function Footer({ t, locale, onLocaleChange }: { t: Messages; locale: Locale; onLocaleChange: (locale: Locale) => void }) {
return (
<footer className="px-4 pb-8 pt-14 sm:px-6">
<div className="mx-auto max-w-7xl rounded-[2.5rem] border border-ink/8 bg-white/65 p-7 backdrop-blur">
<div className="flex flex-col gap-8 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<span className="relative grid size-12 place-items-center overflow-hidden rounded-full bg-white shadow-sm">
<Image src={business.assets.logo} alt="New Optic logo" fill sizes="48px" className="object-contain p-1" />
</span>
<div>
<p className="font-semibold text-ink">{business.name}</p>
<p className="mt-1 text-sm text-ink/55">{t.footer.tagline}</p>
</div>
</div>
<LanguageSwitcher locale={locale} onLocaleChange={onLocaleChange} />
</div>
<div className="hairline my-7" />
<div className="flex flex-col gap-3 text-sm text-ink/52 md:flex-row md:items-center md:justify-between">
<p>{business.phone} · {business.whatsapp} · <a href={business.facebookUrl} target="_blank" rel="noreferrer" className="font-semibold text-ink/70 hover:text-optical">Facebook</a></p>
<p>© {new Date().getFullYear()} {business.name}. {t.footer.rights}</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from "framer-motion";
import { ArrowRight, ShieldCheck } from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { business } from "@/config/business";
import type { Messages } from "@/messages";
import PhysicsButton from "./PhysicsButton";
export default function HeroSection({ t, whatsappUrl }: { t: Messages; whatsappUrl: string }) {
const [canAnimateIntro, setCanAnimateIntro] = useState(false);
const pointerX = useMotionValue(0);
const pointerY = useMotionValue(0);
const smoothX = useSpring(pointerX, { stiffness: 90, damping: 22, mass: 0.4 });
const smoothY = useSpring(pointerY, { stiffness: 90, damping: 22, mass: 0.4 });
const rotateX = useTransform(smoothY, [-0.5, 0.5], [4, -4]);
const rotateY = useTransform(smoothX, [-0.5, 0.5], [-5, 5]);
const imageTransform = useMotionTemplate`perspective(1200px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
useEffect(() => {
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const desktop = window.matchMedia("(min-width: 1024px)").matches;
setCanAnimateIntro(desktop && !reduced);
}, []);
return (
<section id="home" className="relative px-4 pb-20 pt-32 sm:px-6 lg:pb-28 lg:pt-40">
<motion.div className="absolute left-1/2 top-24 hidden h-72 w-72 -translate-x-1/2 rounded-full bg-optical/10 blur-3xl lg:block" animate={canAnimateIntro ? { scale: [1, 1.16, 1], opacity: [0.55, 0.88, 0.55] } : undefined} transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }} />
<div className="mx-auto grid max-w-7xl items-center gap-12 lg:grid-cols-[1fr_0.92fr]">
<motion.div initial={canAnimateIntro ? { opacity: 0, y: 24 } : false} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.8, ease: [0.22, 1, 0.36, 1] }} className="relative z-10 text-center lg:text-start">
<div className="mx-auto mb-6 inline-flex items-center gap-2 rounded-full border border-ink/10 bg-white/65 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-ink/60 shadow-sm backdrop-blur lg:mx-0">
<ShieldCheck size={15} className="text-optical" />
{t.hero.eyebrow}
</div>
<h1 className="mx-auto max-w-4xl text-5xl font-semibold tracking-[-0.075em] text-ink sm:text-7xl lg:mx-0 lg:text-8xl">{t.hero.title}</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg leading-8 text-ink/64 sm:text-xl lg:mx-0">{t.hero.subtitle}</p>
<div className="mt-9 flex flex-col justify-center gap-3 sm:flex-row lg:justify-start">
<PhysicsButton href={whatsappUrl} external className="rounded-full bg-ink px-7 py-4 text-sm font-semibold text-white shadow-soft transition-colors hover:bg-optical">
{t.hero.primary}
<ArrowRight size={16} className="transition group-hover:translate-x-0.5 rtl:rotate-180" />
</PhysicsButton>
<PhysicsButton href="#services" className="rounded-full border border-ink/10 bg-white/65 px-7 py-4 text-sm font-semibold text-ink shadow-sm backdrop-blur transition-colors hover:bg-white">
{t.hero.secondary}
</PhysicsButton>
</div>
<p className="mt-7 text-sm text-ink/48">{t.hero.note}</p>
</motion.div>
<motion.div
initial={canAnimateIntro ? { opacity: 0, scale: 0.96, y: 24 } : false}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 1, delay: 0.1, ease: [0.22, 1, 0.36, 1] }}
onPointerMove={(event) => {
if (!canAnimateIntro) return;
const bounds = event.currentTarget.getBoundingClientRect();
pointerX.set((event.clientX - bounds.left) / bounds.width - 0.5);
pointerY.set((event.clientY - bounds.top) / bounds.height - 0.5);
}}
onPointerLeave={() => {
pointerX.set(0);
pointerY.set(0);
}}
style={canAnimateIntro ? { transform: imageTransform } : undefined}
className="relative mx-auto w-full max-w-xl lg:max-w-none lg:will-change-transform"
>
<div className="absolute -inset-6 hidden rounded-[3rem] bg-gradient-to-br from-optical/14 via-white/0 to-silver/45 blur-2xl lg:block" />
<div className="glass relative overflow-hidden rounded-[2.6rem] p-3">
<span className="floating-sheen" />
<div className="relative aspect-[4/3] overflow-hidden rounded-[2rem] bg-silver/40">
<Image src={business.assets.hero} alt={t.hero.imageAlt} fill sizes="(min-width: 1024px) 45vw, 92vw" className="object-cover" priority />
</div>
<div className="absolute bottom-7 left-7 right-7 rounded-[1.7rem] border border-white/70 bg-white/72 p-4 shadow-glass backdrop-blur-xl sm:p-5">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-optical/80">{business.name}</p>
<p className="mt-1 text-sm font-semibold text-ink">Temara, Morocco</p>
</div>
<div className="grid size-12 place-items-center rounded-full bg-ink text-xs font-semibold text-white">2011</div>
</div>
</div>
</div>
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { languages, type Locale } from "@/config/business";
import { cn } from "@/lib/utils";
export default function LanguageSwitcher({ locale, onLocaleChange }: { locale: Locale; onLocaleChange: (locale: Locale) => void }) {
return (
<div className="flex rounded-full border border-ink/10 bg-white/65 p-1 shadow-sm backdrop-blur-xl" aria-label="Language switcher">
{languages.map((language) => (
<button
key={language.code}
type="button"
onClick={() => onLocaleChange(language.code)}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-semibold transition-all duration-300",
locale === language.code ? "bg-ink text-white shadow-sm" : "text-ink/55 hover:text-ink"
)}
aria-pressed={locale === language.code}
>
{language.label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,92 @@
"use client";
import { useEffect } from "react";
declare global {
interface Window {
liquidGL?: ((options: Record<string, unknown>) => unknown) & {
registerDynamic?: (elements: string | Element[]) => void;
syncWith?: () => unknown;
};
html2canvas?: unknown;
}
}
export default function LiquidGlass() {
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!window.matchMedia("(min-width: 1024px)").matches) return;
let cancelled = false;
let attempts = 0;
function loadScript(src: string) {
return new Promise<void>((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === "true") resolve();
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { once: true });
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.liquidDependency = "true";
script.addEventListener("load", () => {
script.dataset.loaded = "true";
resolve();
});
script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)));
document.body.appendChild(script);
});
}
const init = () => {
attempts += 1;
if (cancelled) return;
if (!window.liquidGL || !window.html2canvas) {
if (attempts < 50) window.setTimeout(init, 120);
return;
}
try {
window.liquidGL({
snapshot: "body",
target: ".liquidGL-pane",
resolution: 1.2,
refraction: 0.012,
bevelDepth: 0.052,
bevelWidth: 0.18,
frost: 0.85,
shadow: true,
specular: true,
reveal: "fade",
tilt: window.innerWidth >= 768,
tiltFactor: 3.5,
magnify: 1.01
});
window.liquidGL.registerDynamic?.(".liquid-dynamic");
window.liquidGL.syncWith?.();
} catch (error) {
console.warn("liquidGL could not initialise; CSS glass fallback remains active.", error);
}
};
Promise.all([
loadScript("https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"),
loadScript("/scripts/liquidGL.js")
])
.then(init)
.catch((error) => console.warn("liquidGL dependencies could not load; CSS glass fallback remains active.", error));
return () => {
cancelled = true;
};
}, []);
return null;
}

82
components/Navbar.tsx Normal file
View File

@@ -0,0 +1,82 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Menu, X } from "lucide-react";
import Image from "next/image";
import { type MouseEvent, useState } from "react";
import { business, type Locale } from "@/config/business";
import type { Messages } from "@/messages";
import LanguageSwitcher from "./LanguageSwitcher";
import PhysicsButton from "./PhysicsButton";
export default function Navbar({ locale, onLocaleChange, t, whatsappUrl }: { locale: Locale; onLocaleChange: (locale: Locale) => void; t: Messages; whatsappUrl: string }) {
const [open, setOpen] = useState(false);
function scrollToSection(event: MouseEvent<HTMLAnchorElement>, href: string) {
if (!href.startsWith("#")) return;
event.preventDefault();
const target = document.querySelector(href);
if (!target) return;
setOpen(false);
target.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", href);
}
return (
<header className="fixed inset-x-0 top-0 z-50 px-4 pt-4 sm:px-6">
<nav className="glass mx-auto flex max-w-7xl items-center justify-between rounded-full px-4 py-3 sm:px-5" aria-label="Main navigation">
<a href="#home" onClick={(event) => scrollToSection(event, "#home")} className="relative z-[3] flex items-center gap-3" aria-label="New Optic home">
<span className="relative grid size-10 place-items-center overflow-hidden rounded-full bg-white shadow-sm">
<Image src={business.assets.logo} alt="New Optic logo" fill sizes="40px" className="object-contain p-1" priority />
</span>
<span className="text-sm font-semibold tracking-[-0.02em]">{business.name}</span>
</a>
<div className="relative z-[3] hidden items-center gap-7 lg:flex">
{t.nav.links.map((link) => (
<a key={link.href} href={link.href} onClick={(event) => scrollToSection(event, link.href)} className="text-sm font-medium text-ink/58 transition hover:text-ink">
{link.label}
</a>
))}
</div>
<div className="relative z-[3] hidden items-center gap-3 md:flex">
<LanguageSwitcher locale={locale} onLocaleChange={onLocaleChange} />
<PhysicsButton href={whatsappUrl} external className="rounded-full bg-ink px-5 py-2.5 text-sm font-semibold text-white shadow-soft transition-colors hover:bg-optical">
{t.nav.cta}
</PhysicsButton>
</div>
<button type="button" onClick={() => setOpen((value) => !value)} className="relative z-[3] grid size-11 place-items-center rounded-full bg-white/70 text-ink md:hidden" aria-label={t.nav.menu} aria-expanded={open}>
{open ? <X size={18} /> : <Menu size={18} />}
</button>
</nav>
<AnimatePresence>
{open ? (
<motion.div
initial={{ opacity: 0, y: -12, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -12, scale: 0.98 }}
transition={{ duration: 0.24 }}
className="glass mx-auto mt-3 max-w-7xl rounded-[2rem] p-4 md:hidden"
>
<div className="grid gap-2">
{t.nav.links.map((link) => (
<a key={link.href} href={link.href} onClick={(event) => scrollToSection(event, link.href)} className="rounded-2xl px-4 py-3 text-sm font-semibold text-ink/72 hover:bg-white">
{link.label}
</a>
))}
</div>
<div className="mt-4 flex items-center justify-between gap-3">
<LanguageSwitcher locale={locale} onLocaleChange={onLocaleChange} />
<PhysicsButton href={whatsappUrl} external className="rounded-full bg-ink px-5 py-3 text-sm font-semibold text-white">
{t.nav.cta}
</PhysicsButton>
</div>
</motion.div>
) : null}
</AnimatePresence>
</header>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import type { MouseEvent, PointerEvent, ReactNode } from "react";
import { cn } from "@/lib/utils";
type PhysicsButtonProps = {
href: string;
children: ReactNode;
className?: string;
external?: boolean;
onClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
};
export default function PhysicsButton({ href, children, className, external = false, onClick }: PhysicsButtonProps) {
const pullX = useMotionValue(0);
const pullY = useMotionValue(0);
const press = useMotionValue(0);
const x = useSpring(pullX, { stiffness: 190, damping: 13, mass: 0.42 });
const y = useSpring(pullY, { stiffness: 190, damping: 13, mass: 0.42 });
const pressed = useSpring(press, { stiffness: 520, damping: 18, mass: 0.35 });
const pointerScaleX = useTransform(x, [-30, 0, 30], [1.18, 1, 1.18]);
const pointerScaleY = useTransform(y, [-18, 0, 18], [0.9, 1, 0.9]);
const tapScaleX = useTransform(pressed, [0, 1], [1, 1.24]);
const tapScaleY = useTransform(pressed, [0, 1], [1, 0.82]);
const scaleX = useTransform([pointerScaleX, tapScaleX], ([pointerValue, tapValue]) => (pointerValue as number) * (tapValue as number));
const scaleY = useTransform([pointerScaleY, tapScaleY], ([pointerValue, tapValue]) => (pointerValue as number) * (tapValue as number));
const rotate = useTransform(x, [-30, 0, 30], [-2.2, 0, 2.2]);
const glowX = useTransform(x, (value) => value * 0.65);
const glowY = useTransform(y, (value) => value * 0.65);
function handleMove(event: PointerEvent<HTMLAnchorElement>) {
if (event.pointerType === "touch") return;
const bounds = event.currentTarget.getBoundingClientRect();
const localX = event.clientX - bounds.left - bounds.width / 2;
const localY = event.clientY - bounds.top - bounds.height / 2;
const xLimit = 30;
const yLimit = 18;
const strength = 0.42;
pullX.set(Math.max(-xLimit, Math.min(xLimit, localX * strength)));
pullY.set(Math.max(-yLimit, Math.min(yLimit, localY * strength)));
}
function release() {
pullX.set(0);
pullY.set(0);
press.set(0);
}
return (
<motion.a
href={href}
target={external ? "_blank" : undefined}
rel={external ? "noreferrer" : undefined}
onClick={onClick}
onPointerMove={handleMove}
onPointerLeave={release}
onPointerCancel={release}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture?.(event.pointerId);
handleMove(event);
press.set(1);
pullY.set(event.pointerType === "touch" ? 10 : 12);
}}
onPointerUp={release}
onLostPointerCapture={release}
whileHover={{ scale: 1.035 }}
style={{ x, y, scaleX, scaleY, rotate }}
transition={{ type: "spring", stiffness: 520, damping: 18, mass: 0.45 }}
className={cn("group relative inline-flex touch-manipulation select-none items-center justify-center overflow-hidden will-change-transform", className)}
>
<motion.span
className="pointer-events-none absolute inset-0 rounded-full bg-white/15 opacity-0 blur-xl transition-opacity duration-300 group-hover:opacity-100"
style={{ x: glowX, y: glowY }}
/>
<span className="relative z-10 inline-flex items-center justify-center gap-2">{children}</span>
</motion.a>
);
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
export default function SectionHeader({ eyebrow, title, body, tone = "light" }: { eyebrow: string; title: string; body?: string; tone?: "light" | "dark" }) {
const isDark = tone === "dark";
return (
<div className="mx-auto max-w-3xl text-center">
<p className={cn("mb-4 text-xs font-semibold uppercase tracking-[0.28em]", isDark ? "text-optical/90" : "text-optical/75")}>{eyebrow}</p>
<h2 className={cn("text-3xl font-semibold tracking-[-0.045em] sm:text-5xl", isDark ? "text-white" : "text-ink")}>{title}</h2>
{body ? <p className={cn("mt-5 text-base leading-8 sm:text-lg", isDark ? "text-white/72" : "text-ink/62")}>{body}</p> : null}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { Eye, Glasses, HeartHandshake, LifeBuoy, Sparkles, Wrench } from "lucide-react";
import { motion } from "framer-motion";
import type { Messages } from "@/messages";
import AnimatedSection from "./AnimatedSection";
import SectionHeader from "./SectionHeader";
const icons = [Eye, Wrench, Glasses, Sparkles, HeartHandshake, LifeBuoy];
export default function ServicesSection({ t }: { t: Messages }) {
return (
<AnimatedSection id="services" className="px-4 py-20 sm:px-6">
<div className="mx-auto max-w-7xl">
<SectionHeader eyebrow={t.services.eyebrow} title={t.services.title} />
<motion.div className="mt-12 grid gap-4 md:grid-cols-2 lg:grid-cols-3" initial={false} whileInView="show" viewport={{ once: true, amount: 0.08 }} variants={{ show: { transition: { staggerChildren: 0.08 } } }}>
{t.services.items.map((item, index) => {
const Icon = icons[index];
return (
<motion.article key={item.title} variants={{ show: { opacity: 1, y: 0, scale: 1 } }} transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1] }} whileHover={{ y: -8 }} className="group rounded-[2rem] border border-ink/8 bg-white/72 p-6 shadow-sm backdrop-blur transition duration-300 hover:border-optical/20 hover:shadow-soft">
<div className="mb-8 grid size-12 place-items-center rounded-2xl bg-ink text-white transition group-hover:bg-optical">
<Icon size={20} />
</div>
<h3 className="text-xl font-semibold tracking-[-0.035em] text-ink">{item.title}</h3>
<p className="mt-3 text-sm leading-7 text-ink/58">{item.text}</p>
</motion.article>
);
})}
</motion.div>
</div>
</AnimatedSection>
);
}

65
components/SiteShell.tsx Normal file
View File

@@ -0,0 +1,65 @@
"use client";
import { useEffect, useMemo, useState, startTransition } from "react";
import { business, type Locale } from "@/config/business";
import { createWhatsAppMessage, getDictionary, getDirection } from "@/lib/i18n";
import AboutSection from "./AboutSection";
import ContactSection from "./ContactSection";
import CollectionsSection from "./CollectionsSection";
import Footer from "./Footer";
import HeroSection from "./HeroSection";
import LiquidGlass from "./LiquidGlass";
import Navbar from "./Navbar";
import ServicesSection from "./ServicesSection";
import TrustSection from "./TrustSection";
import WhyChooseSection from "./WhyChooseSection";
export default function SiteShell() {
const [locale, setLocale] = useState<Locale>("fr");
const t = useMemo(() => getDictionary(locale), [locale]);
const dir = getDirection(locale);
const whatsappUrl = createWhatsAppMessage(locale);
useEffect(() => {
document.documentElement.lang = locale;
document.documentElement.dir = dir;
document.body.dir = dir;
document.title = t.meta.title;
document.querySelector('meta[name="description"]')?.setAttribute("content", t.meta.description);
}, [dir, locale, t.meta.description, t.meta.title]);
function changeLocale(nextLocale: Locale) {
startTransition(() => setLocale(nextLocale));
}
return (
<div className="overflow-hidden">
<LiquidGlass />
<Navbar locale={locale} onLocaleChange={changeLocale} t={t} whatsappUrl={whatsappUrl} />
<main>
<HeroSection t={t} whatsappUrl={whatsappUrl} />
<AboutSection t={t} />
<ServicesSection t={t} />
<CollectionsSection t={t} />
<WhyChooseSection t={t} />
<TrustSection t={t} />
<ContactSection t={t} whatsappUrl={whatsappUrl} />
</main>
<Footer t={t} locale={locale} onLocaleChange={changeLocale} />
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Optician",
name: business.name,
address: business.address,
telephone: business.phone,
url: business.mapUrl,
areaServed: "Temara, Morocco"
})
}}
/>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import type { Messages } from "@/messages";
import { motion } from "framer-motion";
import AnimatedSection from "./AnimatedSection";
import SectionHeader from "./SectionHeader";
export default function TrustSection({ t }: { t: Messages }) {
return (
<AnimatedSection id="trust" className="px-4 py-20 sm:px-6">
<div className="mx-auto max-w-7xl overflow-hidden rounded-[3rem] bg-ink p-8 text-white shadow-glass sm:p-12 lg:p-16">
<SectionHeader eyebrow={t.trust.eyebrow} title={t.trust.title} body={t.trust.body} tone="dark" />
<motion.div className="mt-12 grid gap-4 md:grid-cols-3" initial={false} whileInView="show" viewport={{ once: true, amount: 0.08 }} variants={{ show: { transition: { staggerChildren: 0.12 } } }}>
{t.trust.stats.map((stat) => (
<motion.div key={stat.value} variants={{ show: { opacity: 1, y: 0, scale: 1 } }} transition={{ duration: 0.65, ease: [0.22, 1, 0.36, 1] }} whileHover={{ y: -6 }} className="rounded-[2rem] border border-white/10 bg-white/[0.06] p-6 text-center backdrop-blur">
<p className="text-5xl font-semibold tracking-[-0.06em] text-white">{stat.value}</p>
<p className="mt-3 text-sm leading-6 text-white/58">{stat.label}</p>
</motion.div>
))}
</motion.div>
</div>
</AnimatedSection>
);
}

View File

@@ -0,0 +1,23 @@
import { BadgeCheck } from "lucide-react";
import { motion } from "framer-motion";
import type { Messages } from "@/messages";
import AnimatedSection from "./AnimatedSection";
import SectionHeader from "./SectionHeader";
export default function WhyChooseSection({ t }: { t: Messages }) {
return (
<AnimatedSection className="px-4 py-20 sm:px-6">
<div className="mx-auto max-w-7xl">
<SectionHeader eyebrow={t.why.eyebrow} title={t.why.title} />
<motion.div className="mt-12 grid gap-4 sm:grid-cols-2 lg:grid-cols-3" initial={false} whileInView="show" viewport={{ once: true, amount: 0.08 }} variants={{ show: { transition: { staggerChildren: 0.07 } } }}>
{t.why.items.map((item) => (
<motion.div key={item} variants={{ show: { opacity: 1, y: 0 } }} transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }} whileHover={{ y: -5 }} className="flex min-h-28 items-start gap-4 rounded-[1.7rem] border border-ink/8 bg-white/58 p-5 backdrop-blur transition hover:bg-white hover:shadow-soft">
<BadgeCheck className="mt-1 shrink-0 text-optical" size={21} />
<p className="text-base font-semibold leading-7 tracking-[-0.015em] text-ink/74">{item}</p>
</motion.div>
))}
</motion.div>
</div>
</AnimatedSection>
);
}