Build New Optic website
This commit is contained in:
22
components/AboutSection.tsx
Normal file
22
components/AboutSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
components/AnimatedSection.tsx
Normal file
31
components/AnimatedSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
components/CollectionsSection.tsx
Normal file
49
components/CollectionsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
components/ContactSection.tsx
Normal file
61
components/ContactSection.tsx
Normal 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
30
components/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
components/HeroSection.tsx
Normal file
87
components/HeroSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
components/LanguageSwitcher.tsx
Normal file
25
components/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
components/LiquidGlass.tsx
Normal file
92
components/LiquidGlass.tsx
Normal 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
82
components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
components/PhysicsButton.tsx
Normal file
82
components/PhysicsButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
components/SectionHeader.tsx
Normal file
13
components/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
components/ServicesSection.tsx
Normal file
31
components/ServicesSection.tsx
Normal 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
65
components/SiteShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
components/TrustSection.tsx
Normal file
22
components/TrustSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
components/WhyChooseSection.tsx
Normal file
23
components/WhyChooseSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user