commit f6af80e60d56c67923b621d4893f9c80fd8eb948 Author: OpenCode Date: Sat May 16 00:04:02 2026 +0100 Build New Optic website diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22feb83 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# dependencies +node_modules/ + +# Next.js +.next/ +out/ + +# production +build/ +dist/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +dev-server.log + +# environment +.env +.env.* +!.env.example + +# OS/editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/Assets/503311332_3027495254095990_9004003995552989516_n.jpg b/Assets/503311332_3027495254095990_9004003995552989516_n.jpg new file mode 100644 index 0000000..8d19dd7 Binary files /dev/null and b/Assets/503311332_3027495254095990_9004003995552989516_n.jpg differ diff --git a/Assets/About-us.md b/Assets/About-us.md new file mode 100644 index 0000000..e69de29 diff --git a/Assets/Hot-Selling-Square-Frames-Wholesale-High-Quality-Eyewear-Blue-Light-Glasses-China-Budget-Eyewear-for-Women-Men.avif b/Assets/Hot-Selling-Square-Frames-Wholesale-High-Quality-Eyewear-Blue-Light-Glasses-China-Budget-Eyewear-for-Women-Men.avif new file mode 100644 index 0000000..4103f5b Binary files /dev/null and b/Assets/Hot-Selling-Square-Frames-Wholesale-High-Quality-Eyewear-Blue-Light-Glasses-China-Budget-Eyewear-for-Women-Men.avif differ diff --git a/Assets/New-optic-BG.png b/Assets/New-optic-BG.png new file mode 100644 index 0000000..48707da Binary files /dev/null and b/Assets/New-optic-BG.png differ diff --git a/Assets/New-optic-logo.png b/Assets/New-optic-logo.png new file mode 100644 index 0000000..d2e6d72 Binary files /dev/null and b/Assets/New-optic-logo.png differ diff --git a/Assets/PJ6639WR_es_ES_0.avif b/Assets/PJ6639WR_es_ES_0.avif new file mode 100644 index 0000000..9c249f3 Binary files /dev/null and b/Assets/PJ6639WR_es_ES_0.avif differ diff --git a/Assets/RayVision-LunettesEnfants-Kidsglasses_37.webp b/Assets/RayVision-LunettesEnfants-Kidsglasses_37.webp new file mode 100644 index 0000000..8a604e8 Binary files /dev/null and b/Assets/RayVision-LunettesEnfants-Kidsglasses_37.webp differ diff --git a/Assets/new-optic-full-ai-context.md b/Assets/new-optic-full-ai-context.md new file mode 100644 index 0000000..833b7d7 --- /dev/null +++ b/Assets/new-optic-full-ai-context.md @@ -0,0 +1,195 @@ +# New Optic - Full AI Business Context + +## Purpose of This File +This file is general context for AI systems that may help create content, write copy, structure pages, generate website text, or support branding and communication for New Optic. + +It is not for one specific task. It should be used as a base context file so AI can understand the business clearly and avoid guessing. + +## Business Name +New Optic + +## Business Type +Trusted local optical shop. + +## Core Business Summary +New Optic is a local optical business in Temara, Morocco, serving customers with professional and simple service since around 2011. + +The business offers eye exams, repairs prescription glasses and sunglasses, and sells eyewear including prescription glasses, sunglasses, kids glasses, and budget frames. + +New Optic also takes care of the full glasses process, from helping customers with lenses and frames to providing warranty as part of the service. + +A key selling point is that New Optic offers only original, authentic frames and does not sell fake products. + +## Main Services +- Eye exams +- Repair of vision/prescription glasses +- Repair of sunglasses +- Sale of prescription glasses +- Sale of sunglasses +- Sale of kids glasses +- Sale of budget frames +- Help choosing lenses and frames +- Complete glasses service from lenses to frame to warranty +- Warranty as part of the service generally + +## Main Selling Points +The website and business messaging should emphasize these points in this general order: + +1. Fair prices +2. Strong local reputation +3. Trust +4. Original/authentic frames +5. Professional service +6. Eye exams +7. Repair service +8. Complete glasses service + +## Positioning +New Optic should be positioned as a trusted local optical shop with fair prices, strong community reputation, and professional service. + +It should not sound cheap, fake-premium, overhyped, or too corporate. + +The business should come across as: +- Professional +- Simple +- Reliable +- Local +- Trustworthy +- Price-fair without sounding low-end + +## What Makes New Optic Different +- Known locally for trust +- Fair prices presented in a classy and professional way +- Strong local reputation built over time +- Offers only original/authentic frames +- Provides repairs as well as sales +- Offers eye exams on-site +- Handles the glasses process from lenses and frames to warranty +- Serves both adults and children + +## Customer Profile +New Optic mainly serves local customers in Temara, especially around Massira. + +Typical customer groups include: +- Regular local customers +- Families +- Children +- People looking for quality +- People comparing prices before buying + +This means the communication should be clear, practical, reassuring, and grounded in real value. + +## Geographic Context +New Optic serves a local Moroccan market in Temara. + +Local reality matters: +- Many customers compare prices +- Trust matters a lot +- Reputation matters a lot +- Customers want good value, not vague marketing +- Authenticity matters +- Service quality matters + +## Tone and Writing Style for AI +When AI writes for New Optic, the tone should be: +- Professional +- Simple +- Clear +- Natural +- Trustworthy +- Locally grounded + +Avoid sounding: +- Cheap +- Aggressively salesy +- Too luxurious or exaggerated +- Robotic +- Corporate for no reason +- Fake-modern + +## Messaging Rules for AI +When generating content for New Optic, AI should: +- Emphasize fair prices in a classy and professional way +- Highlight local reputation and trust +- Mention original/authentic frames when relevant +- Mention eye exams and repair service clearly +- Present New Optic as a full-service optical shop +- Keep wording simple and useful +- Write in a way that makes sense for a local business website +- Avoid vague hype and empty slogans + +## Language Preferences +Content may be needed in: +- English +- French +- Arabic + +Translations should sound natural, not word-for-word. + +## Website Content Priorities +If AI helps build or write a website for New Optic, it should prioritize: +- Clear presentation of services +- Trust-building language +- Fair prices as a key advantage +- Local reputation +- Authentic/original frames +- Easy-to-find contact details +- A professional and simple tone + +## Business Facts to Reuse +- Business name: New Optic +- Established: around 2011 +- Business type: optical store / eyewear shop +- Location: Temara, Morocco +- Area mentioned by user: Massira / Massira II context +- Offers eye exams: yes +- Offers repairs: yes +- Sells prescription glasses: yes +- Sells sunglasses: yes +- Sells kids glasses: yes +- Sells budget frames: yes +- Offers original/authentic frames only: yes +- Warranty: yes, as part of the service generally + +## Contact Information +- Category: Magasin de lunettes et lunettes de soleil +- Address: Résidence Al amana Im 11 n°2 Angle BD My Driss 1er Rue Boujdour Massira 1, Temara, Morocco +- Phone: 05376-03279 + +## Recommended Short Brand Description +New Optic is a trusted local optical shop in Temara, Morocco, offering eye exams, glasses repair, and a wide range of eyewear with fair prices, professional service, and original frames. + +## Recommended Longer Brand Description +New Optic is a trusted local optical shop based in Temara, Morocco, serving the community since around 2011. The business offers eye exams, repair services for glasses and sunglasses, and a selection of prescription glasses, sunglasses, kids glasses, and budget frames. + +New Optic is known for fair prices, strong local reputation, and trustworthy service. The business also stands out by offering only original, authentic frames and by supporting customers through the full eyewear process, from lenses and frames to warranty. + +## AI Do / Do Not +### Do +- Describe New Optic as a trusted local optical shop +- Emphasize fair prices first +- Reinforce reputation and trust +- Mention authentic frames +- Keep the tone professional and simple +- Write for real customers, not for marketing awards + +### Do Not +- Call the business luxury unless specifically requested +- Make it sound cheap or low-end +- Invent brands, services, or certifications +- Use exaggerated slogans or fake claims +- Overcomplicate the wording + +## Best Use of This File +This file should help AI with: +- Website copy +- About Us text +- Home page copy +- Service pages +- SEO descriptions +- Google Business text +- Social media captions +- Ad copy +- WhatsApp business descriptions +- Translations +- Customer-facing content diff --git a/Assets/zephyr-lunettes-de-soleil-de-luxe-noir-1889530.webp b/Assets/zephyr-lunettes-de-soleil-de-luxe-noir-1889530.webp new file mode 100644 index 0000000..dc1f7cd Binary files /dev/null and b/Assets/zephyr-lunettes-de-soleil-de-luxe-noir-1889530.webp differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..512cbc7 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# New Optic Website + +Premium multilingual marketing website for New Optic, a trusted local optical shop in Temara, Morocco. + +## Stack + +- Next.js App Router +- React +- TypeScript +- Tailwind CSS +- Framer Motion + +## Run Locally + +```bash +npm install +npm run dev +``` + +Open `http://localhost:3000`. + +## Production Build + +```bash +npm run build +npm run start +``` + +For production Open Graph URLs, set: + +```bash +NEXT_PUBLIC_SITE_URL=https://your-domain.com +``` + +## Editing Content + +- Business/contact details: `config/business.ts` +- French copy: `messages/fr.ts` +- Arabic copy: `messages/ar.ts` +- English copy: `messages/en.ts` +- Assets: `public/assets/` + +## Current Contact Data + +- Phone: `05376-03279` +- WhatsApp: `+212 662-872002` +- Maps: `https://maps.app.goo.gl/LeuFER6h887Cp5aG9` + +## QR Code + +The map QR code is stored at: + +```txt +public/assets/new-optic-map-qr.svg +``` + +Regenerate it after changing the map link. diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..401777f --- /dev/null +++ b/app/globals.css @@ -0,0 +1,174 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: light; + --bg: #f6f5f2; + --ink: #111317; +} + +* { + box-sizing: border-box; +} + +html { + background: var(--bg); +} + +body { + margin: 0; + min-height: 100vh; + background: + linear-gradient(180deg, rgba(251, 250, 248, 0.86) 0%, rgba(246, 245, 242, 0.92) 45%, rgba(255, 255, 255, 0.94) 100%), + url('/assets/New-optic-BG.png') center top / cover fixed, + radial-gradient(circle at top left, rgba(49, 95, 143, 0.13), transparent 34rem), + radial-gradient(circle at 82% 12%, rgba(216, 221, 229, 0.9), transparent 24rem), + linear-gradient(180deg, #fbfaf8 0%, #f6f5f2 42%, #ffffff 100%); + color: var(--ink); + font-family: var(--font-sans), system-ui, sans-serif; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; +} + +section[id] { + scroll-margin-top: 7rem; +} + +body::before, +body::after { + content: ""; + position: fixed; + inset: auto; + pointer-events: none; + z-index: -1; + border-radius: 999px; + filter: blur(42px); + opacity: 0.55; + animation: ambient-drift 14s ease-in-out infinite alternate; +} + +body::before { + left: 6vw; + top: 18vh; + width: 22rem; + height: 22rem; + background: rgba(49, 95, 143, 0.16); +} + +body::after { + right: 5vw; + top: 54vh; + width: 18rem; + height: 18rem; + background: rgba(216, 221, 229, 0.74); + animation-delay: -5s; +} + +body[dir="rtl"] { + font-family: var(--font-arabic), system-ui, sans-serif; +} + +a { + color: inherit; + text-decoration: none; +} + +::selection { + background: rgba(49, 95, 143, 0.18); +} + +.glass { + background: rgba(255, 255, 255, 0.68); + border: 1px solid rgba(255, 255, 255, 0.72); + box-shadow: 0 18px 60px rgba(17, 19, 23, 0.08); + backdrop-filter: blur(24px); +} + +.hairline { + background: linear-gradient(90deg, transparent, rgba(17, 19, 23, 0.15), transparent); + height: 1px; +} + +.liquidGL-pane { + overflow: hidden; + isolation: isolate; +} + +.floating-sheen { + position: absolute; + inset: -40%; + background: linear-gradient(115deg, transparent 34%, rgba(255, 255, 255, 0.34) 49%, transparent 62%); + transform: translateX(-45%) rotate(8deg); + animation: sheen-pass 8s ease-in-out infinite; + pointer-events: none; +} + +@keyframes ambient-drift { + from { + transform: translate3d(0, 0, 0) scale(1); + } + to { + transform: translate3d(2.5rem, -1.5rem, 0) scale(1.12); + } +} + +@keyframes sheen-pass { + 0%, 48% { + transform: translateX(-55%) rotate(8deg); + opacity: 0; + } + 58% { + opacity: 0.55; + } + 78%, 100% { + transform: translateX(55%) rotate(8deg); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +@media (max-width: 1023px) { + body { + background: + linear-gradient(180deg, rgba(251, 250, 248, 0.9) 0%, rgba(246, 245, 242, 0.94) 45%, rgba(255, 255, 255, 0.96) 100%), + url('/assets/New-optic-BG-mobile.webp') center top / cover scroll, + linear-gradient(180deg, #fbfaf8 0%, #f6f5f2 42%, #ffffff 100%); + } + + body::before, + body::after { + display: none; + } + + .floating-sheen { + animation: none; + opacity: 0; + } + + .glass, + [class*="backdrop-blur"] { + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; + } + + .glass { + background: rgba(255, 255, 255, 0.86); + box-shadow: 0 10px 30px rgba(17, 19, 23, 0.07); + } +} + +@media (max-width: 767px) { + .shadow-glass, + .shadow-soft { + box-shadow: 0 10px 24px rgba(17, 19, 23, 0.08) !important; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..cfeb5ed --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { Inter, Noto_Kufi_Arabic } from "next/font/google"; +import "./globals.css"; +import { business } from "@/config/business"; +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" }); + +export const metadata: Metadata = { + metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"), + title: fr.meta.title, + description: fr.meta.description, + openGraph: { + title: fr.meta.title, + description: fr.meta.description, + type: "website", + locale: "fr_MA", + images: [{ url: business.assets.hero, width: 1000, height: 668, alt: fr.hero.imageAlt }] + }, + twitter: { + card: "summary_large_image", + title: fr.meta.title, + description: fr.meta.description, + images: [business.assets.hero] + } +}; + +export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..64779d0 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import SiteShell from "@/components/SiteShell"; + +export default function Home() { + return ; +} diff --git a/components/AboutSection.tsx b/components/AboutSection.tsx new file mode 100644 index 0000000..18e6e1a --- /dev/null +++ b/components/AboutSection.tsx @@ -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 ( + +
+ +
+ {t.about.cards.map((card) => ( +
+ + {card} +
+ ))} +
+
+
+ ); +} diff --git a/components/AnimatedSection.tsx b/components/AnimatedSection.tsx new file mode 100644 index 0000000..20117f2 --- /dev/null +++ b/components/AnimatedSection.tsx @@ -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 ( + + {children} + + ); +} diff --git a/components/CollectionsSection.tsx b/components/CollectionsSection.tsx new file mode 100644 index 0000000..59277ab --- /dev/null +++ b/components/CollectionsSection.tsx @@ -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 ( + +
+ + + {t.collections.items.map((item, index) => ( + + {`${business.name} +
+
+
+
+ 0{index + 1} + +
+
+

{item.title}

+

{item.text}

+
+
+ + ))} + +
+ + ); +} diff --git a/components/ContactSection.tsx b/components/ContactSection.tsx new file mode 100644 index 0000000..7307039 --- /dev/null +++ b/components/ContactSection.tsx @@ -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 ( + +
+
+

{t.contact.eyebrow}

+

{t.contact.title}

+

{t.contact.body}

+
+ + {t.contact.whatsapp} + + + {t.contact.call} + + + {t.contact.visit} + +
+ +
+ } /> + } /> + } wide /> + } /> +
+
+ +
+
+ + {t.contact.qr} +
+
+

{t.contact.qr}

+

{business.displayAddress}

+
+
+
+
+ ); +} + +function Info({ label, value, icon, wide = false }: { label: string; value: string; icon: React.ReactNode; wide?: boolean }) { + return ( +
+
+ {icon} +

{label}

+
+

{value}

+
+ ); +} diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..8e8cbeb --- /dev/null +++ b/components/Footer.tsx @@ -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 ( +
+
+
+
+ + New Optic logo + +
+

{business.name}

+

{t.footer.tagline}

+
+
+ +
+
+
+

{business.phone} · {business.whatsapp} · Facebook

+

© {new Date().getFullYear()} {business.name}. {t.footer.rights}

+
+
+
+ ); +} diff --git a/components/HeroSection.tsx b/components/HeroSection.tsx new file mode 100644 index 0000000..fdc4ba0 --- /dev/null +++ b/components/HeroSection.tsx @@ -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 ( +
+ +
+ +
+ + {t.hero.eyebrow} +
+

{t.hero.title}

+

{t.hero.subtitle}

+
+ + {t.hero.primary} + + + + {t.hero.secondary} + +
+

{t.hero.note}

+
+ + { + 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" + > +
+
+ +
+ {t.hero.imageAlt} +
+
+
+
+

{business.name}

+

Temara, Morocco

+
+
2011
+
+
+
+ +
+
+ ); +} diff --git a/components/LanguageSwitcher.tsx b/components/LanguageSwitcher.tsx new file mode 100644 index 0000000..3bcca13 --- /dev/null +++ b/components/LanguageSwitcher.tsx @@ -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 ( +
+ {languages.map((language) => ( + + ))} +
+ ); +} diff --git a/components/LiquidGlass.tsx b/components/LiquidGlass.tsx new file mode 100644 index 0000000..e477748 --- /dev/null +++ b/components/LiquidGlass.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useEffect } from "react"; + +declare global { + interface Window { + liquidGL?: ((options: Record) => 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((resolve, reject) => { + const existing = document.querySelector(`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; +} diff --git a/components/Navbar.tsx b/components/Navbar.tsx new file mode 100644 index 0000000..ca80227 --- /dev/null +++ b/components/Navbar.tsx @@ -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, 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 ( +
+ + + + {open ? ( + + +
+ + + {t.nav.cta} + +
+
+ ) : null} +
+
+ ); +} diff --git a/components/PhysicsButton.tsx b/components/PhysicsButton.tsx new file mode 100644 index 0000000..d72e629 --- /dev/null +++ b/components/PhysicsButton.tsx @@ -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) => 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) { + 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 ( + { + 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)} + > + + {children} + + ); +} diff --git a/components/SectionHeader.tsx b/components/SectionHeader.tsx new file mode 100644 index 0000000..4654093 --- /dev/null +++ b/components/SectionHeader.tsx @@ -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 ( +
+

{eyebrow}

+

{title}

+ {body ?

{body}

: null} +
+ ); +} diff --git a/components/ServicesSection.tsx b/components/ServicesSection.tsx new file mode 100644 index 0000000..1d8ec7b --- /dev/null +++ b/components/ServicesSection.tsx @@ -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 ( + +
+ + + {t.services.items.map((item, index) => { + const Icon = icons[index]; + return ( + +
+ +
+

{item.title}

+

{item.text}

+
+ ); + })} +
+
+
+ ); +} diff --git a/components/SiteShell.tsx b/components/SiteShell.tsx new file mode 100644 index 0000000..183c582 --- /dev/null +++ b/components/SiteShell.tsx @@ -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("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 ( +
+ + +
+ + + + + + + +
+