6 Commits

Author SHA1 Message Date
539437b824 Use New York style display headings 2026-05-16 22:15:29 +01:00
6549b03d9a Use SF Pro style font stack 2026-05-16 21:52:38 +01:00
0ac37261a8 Correct French 2004 wording 2026-05-16 15:11:33 +01:00
ad070a21a5 Add responsive nav link stretch 2026-05-16 15:08:06 +01:00
a5d9eed843 Keep language switcher in mobile nav 2026-05-16 15:03:21 +01:00
d64bb10fa8 Simplify mobile menu animation 2026-05-16 15:00:39 +01:00
7 changed files with 93 additions and 92 deletions

View File

@@ -6,8 +6,13 @@ All notable changes to the New Optic website will be documented in this file.
### Changed ### Changed
- Added a New York-style display font stack for large latin headings while keeping SF-style UI text.
- Switched the latin typography to an SF Pro-style system font stack for a more Apple-like feel.
- Removed the desktop liquid glass WebGL layer from navbar and CTA controls so text remains readable and glass surfaces do not show dark rendering artifacts while scrolling. - Removed the desktop liquid glass WebGL layer from navbar and CTA controls so text remains readable and glass surfaces do not show dark rendering artifacts while scrolling.
- Made the mobile hamburger menu open with springy jelly motion and added a draggable corner grip that stretches the menu before snapping back. - Replaced the mobile hamburger dropdown with a fast, solid menu animation without bounce or stretch effects.
- Kept the language switcher visible in the mobile top navigation bar.
- Added cursor-reactive stretch motion to desktop nav links and matched the glossy dark WhatsApp CTA style on mobile.
- Corrected the French 2004 trust wording to use "depuis" without "autour de".
## [1.0.0] - 2026-05-16 ## [1.0.0] - 2026-05-16

View File

@@ -6,6 +6,8 @@
color-scheme: light; color-scheme: light;
--bg: #f6f5f2; --bg: #f6f5f2;
--ink: #111317; --ink: #111317;
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-display: "New York Large", "New York", "NewYork", ui-serif, Georgia, Cambria, "Times New Roman", serif;
} }
* { * {
@@ -24,7 +26,7 @@ body {
url('/assets/New-optic-BG-mobile.webp') center top / cover scroll, url('/assets/New-optic-BG-mobile.webp') center top / cover scroll,
linear-gradient(180deg, #fbfaf8 0%, #f6f5f2 42%, #ffffff 100%); linear-gradient(180deg, #fbfaf8 0%, #f6f5f2 42%, #ffffff 100%);
color: var(--ink); color: var(--ink);
font-family: var(--font-sans), system-ui, sans-serif; font-family: var(--font-sans);
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
@@ -67,6 +69,10 @@ body[dir="rtl"] {
font-family: var(--font-arabic), system-ui, sans-serif; font-family: var(--font-arabic), system-ui, sans-serif;
} }
body:not([dir="rtl"]) :where(h1, h2) {
font-family: var(--font-display);
}
a { a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;

View File

@@ -1,10 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Inter, Noto_Kufi_Arabic } from "next/font/google"; import { Noto_Kufi_Arabic } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { business } from "@/config/business"; import { business } from "@/config/business";
import { fr } from "@/messages/fr"; import { fr } from "@/messages/fr";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans", display: "swap" });
const arabic = Noto_Kufi_Arabic({ subsets: ["arabic"], variable: "--font-arabic", display: "swap" }); const arabic = Noto_Kufi_Arabic({ subsets: ["arabic"], variable: "--font-arabic", display: "swap" });
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -28,7 +27,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return ( return (
<html lang="fr" className={`${inter.variable} ${arabic.variable} scroll-smooth`}> <html lang="fr" className={`${arabic.variable} scroll-smooth`}>
<body> <body>
{children} {children}
</body> </body>

View File

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

View File

@@ -1,32 +1,19 @@
"use client"; "use client";
import { AnimatePresence, motion, useMotionValue, useSpring, useTransform } from "framer-motion"; import { AnimatePresence, motion, useMotionValue, useSpring, useTransform } from "framer-motion";
import { Grip, Menu, X } from "lucide-react"; import { Menu, X } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { type MouseEvent, useState } from "react"; import { type MouseEvent, type PointerEvent, useState } from "react";
import { business, type Locale } from "@/config/business"; import { business, type Locale } from "@/config/business";
import type { Messages } from "@/messages"; import type { Messages } from "@/messages";
import LanguageSwitcher from "./LanguageSwitcher"; import LanguageSwitcher from "./LanguageSwitcher";
import PhysicsButton from "./PhysicsButton"; import PhysicsButton from "./PhysicsButton";
const navCtaClassName =
"rounded-full border border-white/45 bg-[linear-gradient(180deg,#565b63_0%,#20242a_48%,#111317_100%)] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.38),inset_0_-10px_18px_rgba(0,0,0,0.18),0_14px_32px_rgba(17,19,23,0.18)] transition-colors hover:bg-[linear-gradient(180deg,#476a95_0%,#264b73_54%,#173655_100%)]";
export default function Navbar({ locale, onLocaleChange, t, whatsappUrl }: { locale: Locale; onLocaleChange: (locale: Locale) => void; t: Messages; whatsappUrl: string }) { export default function Navbar({ locale, onLocaleChange, t, whatsappUrl }: { locale: Locale; onLocaleChange: (locale: Locale) => void; t: Messages; whatsappUrl: string }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const stretchX = useMotionValue(0);
const stretchY = useMotionValue(0);
const springX = useSpring(stretchX, { stiffness: 260, damping: 13, mass: 0.34 });
const springY = useSpring(stretchY, { stiffness: 260, damping: 13, mass: 0.34 });
const menuScaleX = useTransform(springX, [-70, 0, 96], [0.9, 1, 1.22]);
const menuScaleY = useTransform(springY, [-54, 0, 104], [0.88, 1, 1.18]);
const menuSkewX = useTransform(springX, [-70, 0, 96], [-5.5, 0, 7]);
const menuRadius = useTransform(springY, [-54, 0, 104], ["2.65rem", "2rem", "2.9rem"]);
const sheenX = useTransform(springX, [-70, 0, 96], ["-18%", "0%", "20%"]);
const cornerX = useSpring(stretchX, { stiffness: 520, damping: 20, mass: 0.24 });
const cornerY = useSpring(stretchY, { stiffness: 520, damping: 20, mass: 0.24 });
function releaseMenuStretch() {
stretchX.set(0);
stretchY.set(0);
}
function scrollToSection(event: MouseEvent<HTMLAnchorElement>, href: string) { function scrollToSection(event: MouseEvent<HTMLAnchorElement>, href: string) {
if (!href.startsWith("#")) return; if (!href.startsWith("#")) return;
@@ -50,28 +37,27 @@ export default function Navbar({ locale, onLocaleChange, t, whatsappUrl }: { loc
<div className="relative z-[3] hidden items-center gap-7 lg:flex"> <div className="relative z-[3] hidden items-center gap-7 lg:flex">
{t.nav.links.map((link) => ( {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"> <StretchNavLink key={link.href} href={link.href} label={link.label} onClick={(event) => scrollToSection(event, link.href)} />
{link.label}
</a>
))} ))}
</div> </div>
<div className="relative z-[3] hidden items-center gap-3 md:flex"> <div className="relative z-[3] hidden items-center gap-3 md:flex">
<LanguageSwitcher locale={locale} onLocaleChange={onLocaleChange} /> <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"> <PhysicsButton href={whatsappUrl} external className={`${navCtaClassName} px-5 py-2.5`}>
{t.nav.cta} {t.nav.cta}
</PhysicsButton> </PhysicsButton>
</div> </div>
<div className="relative z-[3] flex items-center gap-2 md:hidden"> <div className="relative z-[3] flex items-center gap-2 md:hidden">
<PhysicsButton href={whatsappUrl} external className="rounded-full bg-ink px-4 py-2.5 text-xs font-semibold text-white shadow-soft transition-colors hover:bg-optical sm:px-5 sm:text-sm"> <LanguageSwitcher locale={locale} onLocaleChange={onLocaleChange} className="bg-white/80 backdrop-blur-none" buttonClassName="px-2 sm:px-3" />
<PhysicsButton href={whatsappUrl} external className={`${navCtaClassName} px-3 py-2.5 text-xs max-[374px]:hidden sm:px-5 sm:text-sm`}>
{t.nav.cta} {t.nav.cta}
</PhysicsButton> </PhysicsButton>
<motion.button <motion.button
type="button" type="button"
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
whileTap={{ scaleX: 1.22, scaleY: 0.82 }} whileTap={{ scale: 0.94 }}
transition={{ type: "spring", stiffness: 520, damping: 14, mass: 0.32 }} transition={{ duration: 0.08 }}
className="grid size-11 place-items-center rounded-full bg-white/70 text-ink outline-none transition focus-visible:ring-2 focus-visible:ring-optical/45 focus-visible:ring-offset-2" className="grid size-11 place-items-center rounded-full bg-white/70 text-ink outline-none transition focus-visible:ring-2 focus-visible:ring-optical/45 focus-visible:ring-offset-2"
aria-label={t.nav.menu} aria-label={t.nav.menu}
aria-expanded={open} aria-expanded={open}
@@ -84,67 +70,70 @@ export default function Navbar({ locale, onLocaleChange, t, whatsappUrl }: { loc
<AnimatePresence> <AnimatePresence>
{open ? ( {open ? (
<motion.div <motion.div
initial={{ opacity: 0, y: -28, scaleX: 0.18, scaleY: 0.08, skewX: locale === "ar" ? -10 : 10, borderRadius: "999px" }} initial={{ opacity: 0, y: -8, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scaleX: [0.18, 1.16, 0.96, 1], scaleY: [0.08, 1.2, 0.94, 1], skewX: [locale === "ar" ? -10 : 10, locale === "ar" ? 5 : -5, 0], borderRadius: ["999px", "2.85rem", "1.65rem", "2rem"] }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -18, scaleX: 0.38, scaleY: 0.14, skewX: locale === "ar" ? -7 : 7, borderRadius: "999px" }} exit={{ opacity: 0, y: -6, scale: 0.99 }}
transition={{ duration: 0.42, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 0.13, ease: "easeOut" }}
style={{ transformOrigin: locale === "ar" ? "top left" : "top right" }} className="relative mx-auto mt-3 max-w-7xl overflow-hidden rounded-[2rem] border border-ink/8 bg-white p-4 shadow-glass md:hidden"
className="mx-auto mt-3 max-w-7xl md:hidden"
> >
<motion.div <div className="grid gap-2">
style={{ scaleX: menuScaleX, scaleY: menuScaleY, skewX: menuSkewX, borderRadius: menuRadius, transformOrigin: locale === "ar" ? "bottom left" : "bottom right" }}
onPointerLeave={releaseMenuStretch}
className="relative w-full overflow-hidden border border-ink/8 bg-white p-4 pb-10 shadow-glass"
>
<motion.div
aria-hidden
className="pointer-events-none absolute -inset-x-10 top-1 h-10 rounded-full bg-silver/35 blur-xl"
initial={{ opacity: 0, scaleX: 0.4 }}
animate={{ opacity: [0.22, 0.9, 0.38], scaleX: [0.3, 1.28, 0.88], x: ["-20%", "12%", "0%"] }}
transition={{ duration: 0.38, ease: [0.22, 1, 0.36, 1] }}
/>
<motion.div aria-hidden className="pointer-events-none absolute bottom-0 right-0 h-28 w-28 rounded-full bg-optical/8 blur-2xl rtl:left-0 rtl:right-auto" style={{ x: sheenX, y: springY }} />
<div className="relative z-[2] grid gap-2">
{t.nav.links.map((link) => ( {t.nav.links.map((link) => (
<motion.a <motion.a
key={link.href} key={link.href}
href={link.href} href={link.href}
onClick={(event) => scrollToSection(event, link.href)} onClick={(event) => scrollToSection(event, link.href)}
initial={{ opacity: 0, x: locale === "ar" ? -28 : 28, scaleX: 0.86 }} initial={{ opacity: 0, x: locale === "ar" ? -8 : 8 }}
animate={{ opacity: 1, x: 0, scaleX: 1 }} animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 680, damping: 22, mass: 0.26 }} transition={{ duration: 0.1, ease: "easeOut" }}
whileTap={{ scaleX: 1.12, scaleY: 0.86 }} whileTap={{ scale: 0.98 }}
className="rounded-2xl px-4 py-3 text-sm font-semibold text-ink/72 hover:bg-white" className="rounded-2xl px-4 py-3 text-sm font-semibold text-ink/72 hover:bg-smoke"
> >
{link.label} {link.label}
</motion.a> </motion.a>
))} ))}
</div> </div>
<div className="relative z-[2] mt-4 flex items-center justify-between gap-3">
<LanguageSwitcher locale={locale} onLocaleChange={onLocaleChange} />
</div>
<motion.button
type="button"
aria-label={`${t.nav.menu} grip`}
drag
dragSnapToOrigin
dragConstraints={{ left: -56, right: 72, top: -46, bottom: 82 }}
dragElastic={0.94}
onDrag={(_, info) => {
stretchX.set(Math.max(-70, Math.min(96, info.offset.x)));
stretchY.set(Math.max(-54, Math.min(104, info.offset.y)));
}}
onDragEnd={releaseMenuStretch}
onPointerUp={releaseMenuStretch}
style={{ x: cornerX, y: cornerY }}
className="absolute bottom-3 right-3 z-[3] grid size-9 touch-none cursor-grab place-items-center rounded-full border border-ink/10 bg-white/80 text-ink/55 shadow-sm outline-none transition focus-visible:ring-2 focus-visible:ring-optical/35 active:cursor-grabbing rtl:left-3 rtl:right-auto"
>
<Grip size={15} />
</motion.button>
</motion.div>
</motion.div> </motion.div>
) : null} ) : null}
</AnimatePresence> </AnimatePresence>
</header> </header>
); );
} }
function StretchNavLink({ href, label, onClick }: { href: string; label: string; onClick: (event: MouseEvent<HTMLAnchorElement>) => void }) {
const pullX = useMotionValue(0);
const pullY = useMotionValue(0);
const x = useSpring(pullX, { stiffness: 260, damping: 16, mass: 0.3 });
const y = useSpring(pullY, { stiffness: 260, damping: 16, mass: 0.3 });
const scaleX = useTransform(x, [-18, 0, 18], [1.12, 1, 1.12]);
const scaleY = useTransform(y, [-12, 0, 12], [0.94, 1, 0.94]);
function handlePointerMove(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;
pullX.set(Math.max(-18, Math.min(18, localX * 0.42)));
pullY.set(Math.max(-12, Math.min(12, localY * 0.38)));
}
function release() {
pullX.set(0);
pullY.set(0);
}
return (
<motion.a
href={href}
onClick={onClick}
onPointerMove={handlePointerMove}
onPointerLeave={release}
onPointerCancel={release}
style={{ x, y, scaleX, scaleY }}
className="inline-flex min-h-9 items-center rounded-full px-1 text-sm font-medium text-ink/58 will-change-transform transition-colors hover:text-ink"
>
{label}
</motion.a>
);
}

View File

@@ -26,7 +26,7 @@ export const fr = {
about: { about: {
eyebrow: "Une adresse locale reconnue", eyebrow: "Une adresse locale reconnue",
title: "Un service optique simple, professionnel et fiable.", title: "Un service optique simple, professionnel et fiable.",
body: "Depuis autour de 2004, New Optic accompagne les clients de Temara avec une approche claire: bien conseiller, proposer des montures authentiques, garder des prix justes et assurer le suivi apres l'achat.", body: "Depuis 2004, New Optic accompagne les clients de Temara avec une approche claire: bien conseiller, proposer des montures authentiques, garder des prix justes et assurer le suivi apres l'achat.",
cards: ["Reputation locale solide", "Montures originales uniquement", "Conseil de la monture a la garantie"] cards: ["Reputation locale solide", "Montures originales uniquement", "Conseil de la monture a la garantie"]
}, },
services: { services: {
@@ -68,7 +68,7 @@ export const fr = {
eyebrow: "Reputation", eyebrow: "Reputation",
title: "Un opticien de quartier avec une exigence durable.", title: "Un opticien de quartier avec une exigence durable.",
stats: [ stats: [
{ value: "2004", label: "Présence locale depuis autour de" }, { value: "2004", label: "Présence locale depuis" },
{ value: "100%", label: "Montures originales" }, { value: "100%", label: "Montures originales" },
{ value: "360°", label: "Conseil, verres, montures et suivi" } { value: "360°", label: "Conseil, verres, montures et suivi" }
], ],

View File

@@ -11,7 +11,8 @@ const config: Config = {
optical: "#315f8f" optical: "#315f8f"
}, },
fontFamily: { fontFamily: {
sans: ["var(--font-sans)", "Inter", "system-ui", "sans-serif"], sans: ["var(--font-sans)"],
display: ["var(--font-display)"],
arabic: ["var(--font-arabic)", "system-ui", "sans-serif"] arabic: ["var(--font-arabic)", "system-ui", "sans-serif"]
}, },
boxShadow: { boxShadow: {