Build New Optic website
28
.gitignore
vendored
Normal file
@@ -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/
|
||||
BIN
Assets/503311332_3027495254095990_9004003995552989516_n.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
0
Assets/About-us.md
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
Assets/New-optic-BG.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
Assets/New-optic-logo.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
Assets/PJ6639WR_es_ES_0.avif
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
Assets/RayVision-LunettesEnfants-Kidsglasses_37.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
195
Assets/new-optic-full-ai-context.md
Normal file
@@ -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
|
||||
BIN
Assets/zephyr-lunettes-de-soleil-de-luxe-noir-1889530.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
57
README.md
Normal file
@@ -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.
|
||||
174
app/globals.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
37
app/layout.tsx
Normal file
@@ -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 (
|
||||
<html lang="fr" className={`${inter.variable} ${arabic.variable} scroll-smooth`}>
|
||||
<body>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import SiteShell from "@/components/SiteShell";
|
||||
|
||||
export default function Home() {
|
||||
return <SiteShell />;
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
32
config/business.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const business = {
|
||||
name: "New Optic",
|
||||
established: "2011",
|
||||
city: "Temara",
|
||||
country: "Morocco",
|
||||
phone: "05376-03279",
|
||||
whatsapp: "+212 662-872002",
|
||||
whatsappHref: "https://wa.me/212662872002",
|
||||
address: "Residence Al amana Im 11 n 2, Angle BD My Driss 1er, Rue Boujdour Massira 1, Temara, Morocco",
|
||||
displayAddress: "Residence Al amana Im 11 n 2, Angle BD My Driss 1er, Rue Boujdour Massira 1, Temara",
|
||||
mapUrl: "https://maps.app.goo.gl/LeuFER6h887Cp5aG9",
|
||||
facebookUrl: "https://www.facebook.com/newoptic.ma/",
|
||||
assets: {
|
||||
logo: "/assets/New-optic-logo.png",
|
||||
background: "/assets/New-optic-BG.png",
|
||||
hero: "/assets/503311332_3027495254095990_9004003995552989516_n.jpg",
|
||||
listingPhoto: "/assets/new-optic-shop-exterior.jpeg",
|
||||
prescriptionGlasses: "/assets/prescription-glasses.avif",
|
||||
sunglasses: "/assets/sunglasses.webp",
|
||||
kidsGlasses: "/assets/kids-glasses.webp",
|
||||
budgetFrames: "/assets/budget-frames.avif",
|
||||
mapQr: "/assets/new-optic-map-qr.svg"
|
||||
}
|
||||
} as const;
|
||||
|
||||
export const languages = [
|
||||
{ code: "fr", label: "FR", nativeName: "Francais", dir: "ltr" },
|
||||
{ code: "ar", label: "AR", nativeName: "العربية", dir: "rtl" },
|
||||
{ code: "en", label: "EN", nativeName: "English", dir: "ltr" }
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof languages)[number]["code"];
|
||||
21
lib/i18n.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { business, languages, type Locale } from "@/config/business";
|
||||
import { messages } from "@/messages";
|
||||
|
||||
export const defaultLocale: Locale = "fr";
|
||||
|
||||
export function isLocale(value: string): value is Locale {
|
||||
return languages.some((language) => language.code === value);
|
||||
}
|
||||
|
||||
export function getDirection(locale: Locale) {
|
||||
return languages.find((language) => language.code === locale)?.dir ?? "ltr";
|
||||
}
|
||||
|
||||
export function getDictionary(locale: Locale) {
|
||||
return messages[locale] ?? messages[defaultLocale];
|
||||
}
|
||||
|
||||
export function createWhatsAppMessage(locale: Locale) {
|
||||
const copy = getDictionary(locale).contact.whatsappMessage;
|
||||
return `${business.whatsappHref}?text=${encodeURIComponent(copy)}`;
|
||||
}
|
||||
3
lib/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function cn(...classes: Array<string | false | null | undefined>) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
96
messages/ar.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export const ar = {
|
||||
meta: {
|
||||
title: "نيو أوبتيك تمارة | نظارات أصلية وخدمة موثوقة منذ 2011",
|
||||
description: "نيو أوبتيك في تمارة يقدم نظارات أصلية، فحص النظر، إصلاح النظارات، نظارات طبية وشمسية ونظارات الأطفال مع أثمنة منصفة وخدمة مهنية."
|
||||
},
|
||||
nav: {
|
||||
links: [
|
||||
{ label: "الرئيسية", href: "#home" },
|
||||
{ label: "الخدمات", href: "#services" },
|
||||
{ label: "المجموعات", href: "#collections" },
|
||||
{ label: "الثقة", href: "#trust" },
|
||||
{ label: "التواصل", href: "#contact" }
|
||||
],
|
||||
cta: "واتساب",
|
||||
menu: "القائمة"
|
||||
},
|
||||
hero: {
|
||||
eyebrow: "نظاراتي محلي في تمارة منذ 2011",
|
||||
title: "نيو أوبتيك، نظاراتي ديال الثقة في تمارة.",
|
||||
subtitle: "نظارات أصلية، خدمة مهنية، أثمنة منصفة ومواكبة كاملة حتى ما بعد الشراء.",
|
||||
primary: "تواصل معنا",
|
||||
secondary: "شاهد الخدمات",
|
||||
note: "فحص النظر، إصلاح النظارات، نظارات طبية وشمسية ونظارات الأطفال.",
|
||||
imageAlt: "محل نيو أوبتيك واختيار النظارات في تمارة"
|
||||
},
|
||||
about: {
|
||||
eyebrow: "عنوان محلي معروف",
|
||||
title: "خدمة بصرية واضحة، مهنية وموثوقة.",
|
||||
body: "منذ حوالي 2011، يرافق نيو أوبتيك زبناء تمارة بطريقة بسيطة: نصيحة واضحة، نظارات أصلية، أثمنة منصفة وخدمة متابعة بعد الشراء.",
|
||||
cards: ["سمعة محلية قوية", "نظارات أصلية فقط", "مواكبة من اختيار الإطار حتى الضمان"]
|
||||
},
|
||||
services: {
|
||||
eyebrow: "الخدمات",
|
||||
title: "كل ما تحتاجه لنظاراتك في مكان واحد.",
|
||||
items: [
|
||||
{ title: "فحص النظر", text: "فحص واضح يساعد في اختيار العدسات والتصحيح المناسب." },
|
||||
{ title: "إصلاح النظارات", text: "تعديل وإصلاح النظارات الطبية والشمسية حسب حالة الإطار." },
|
||||
{ title: "نظارات طبية", text: "إطارات أصلية وعدسات مناسبة مع نصيحة حتى الاختيار النهائي." },
|
||||
{ title: "نظارات شمسية", text: "نظارات شمسية أصلية للراحة والحماية والاستعمال اليومي." },
|
||||
{ title: "نظارات الأطفال", text: "اختيارات عملية ومريحة تناسب احتياجات العائلات." },
|
||||
{ title: "مواكبة كاملة", text: "نصيحة في العدسات والإطارات والتعديل وخدمة الضمان." }
|
||||
]
|
||||
},
|
||||
collections: {
|
||||
eyebrow: "المجموعات",
|
||||
title: "اختيارات حقيقية للنظارات، مقدمة بوضوح.",
|
||||
body: "الاختيار في المحل يغطي أهم ما يبحث عنه زبناء تمارة: نظارات طبية، شمسية، للأطفال، وإطارات بميزانية مناسبة.",
|
||||
items: [
|
||||
{ title: "نظارات طبية", text: "تصحيح وراحة ونصيحة في العدسات للاستعمال اليومي." },
|
||||
{ title: "نظارات شمسية", text: "موديلات أصلية للحماية والراحة ولمسة أنيقة." },
|
||||
{ title: "نظارات الأطفال", text: "إطارات عملية وثابتة مناسبة لاحتياجات العائلات." },
|
||||
{ title: "إطارات اقتصادية", text: "اختيارات في المتناول مع نفس جدية النصيحة والتعديل." }
|
||||
]
|
||||
},
|
||||
why: {
|
||||
eyebrow: "لماذا نيو أوبتيك",
|
||||
title: "الثقة أولا.",
|
||||
items: [
|
||||
"أثمنة منصفة وبشرح واضح",
|
||||
"سمعة محلية قوية في تمارة",
|
||||
"إطارات أصلية فقط",
|
||||
"خدمة مهنية، بسيطة وقريبة",
|
||||
"مواكبة من التصحيح حتى الضمان",
|
||||
"حلول للكبار والأطفال والعائلات"
|
||||
]
|
||||
},
|
||||
trust: {
|
||||
eyebrow: "السمعة",
|
||||
title: "نظاراتي قريب من الزبون ومعايير خدمة ثابتة.",
|
||||
stats: [
|
||||
{ value: "2011", label: "حضور محلي منذ حوالي" },
|
||||
{ value: "100%", label: "إطارات أصلية" },
|
||||
{ value: "360°", label: "نصيحة، عدسات، إطارات ومتابعة" }
|
||||
],
|
||||
body: "بدون وعود مبالغ فيها. نيو أوبتيك يبني الثقة بالنصيحة، الاستمرارية في الخدمة واختيار منتجات أصلية."
|
||||
},
|
||||
contact: {
|
||||
eyebrow: "التواصل",
|
||||
title: "زورونا في المحل أو تواصلوا معنا مباشرة.",
|
||||
body: "عندك سؤال على العدسات، إصلاح، أو إطار جديد؟ راسلنا في واتساب، اتصل بنا، أو زورنا في المسيرة 1 بتمارة.",
|
||||
whatsapp: "راسلنا في واتساب",
|
||||
call: "اتصل الآن",
|
||||
visit: "افتح الاتجاهات",
|
||||
qr: "امسح الرمز لفتح العنوان",
|
||||
phoneLabel: "الهاتف",
|
||||
whatsappLabel: "واتساب",
|
||||
addressLabel: "العنوان",
|
||||
hoursLabel: "أوقات العمل",
|
||||
hoursValue: "أوقات العمل غير منشورة على الإنترنت. تواصلوا مع المحل قبل الزيارة.",
|
||||
whatsappMessage: "السلام عليكم نيو أوبتيك، بغيت معلومات على النظارات والخدمات ديالكم."
|
||||
},
|
||||
footer: {
|
||||
tagline: "نظاراتي محلي في تمارة، نظارات أصلية وخدمة مهنية.",
|
||||
rights: "جميع الحقوق محفوظة."
|
||||
}
|
||||
} as const;
|
||||
96
messages/en.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export const en = {
|
||||
meta: {
|
||||
title: "New Optic in Temara | Trusted local optician since 2011",
|
||||
description: "New Optic in Temara offers authentic frames, eye exams, repairs, prescription glasses, sunglasses and kids eyewear with fair prices and professional service."
|
||||
},
|
||||
nav: {
|
||||
links: [
|
||||
{ label: "Home", href: "#home" },
|
||||
{ label: "Services", href: "#services" },
|
||||
{ label: "Collections", href: "#collections" },
|
||||
{ label: "Trust", href: "#trust" },
|
||||
{ label: "Contact", href: "#contact" }
|
||||
],
|
||||
cta: "WhatsApp",
|
||||
menu: "Menu"
|
||||
},
|
||||
hero: {
|
||||
eyebrow: "Local optician in Temara since 2011",
|
||||
title: "Your trusted optician in Temara.",
|
||||
subtitle: "Authentic frames, professional service, fair prices and complete support for your eyewear.",
|
||||
primary: "Contact us",
|
||||
secondary: "Explore services",
|
||||
note: "Eye exams, repairs, prescription glasses, sunglasses and kids eyewear.",
|
||||
imageAlt: "New Optic shop and eyewear selection in Temara"
|
||||
},
|
||||
about: {
|
||||
eyebrow: "A trusted local address",
|
||||
title: "Optical service that is clear, professional and reliable.",
|
||||
body: "Since around 2011, New Optic has supported customers in Temara with a simple promise: honest guidance, authentic frames, fair prices and reliable after-sales support.",
|
||||
cards: ["Strong local reputation", "Original frames only", "Guidance from frame to warranty"]
|
||||
},
|
||||
services: {
|
||||
eyebrow: "Services",
|
||||
title: "Everything essential for your eyewear, in one place.",
|
||||
items: [
|
||||
{ title: "Eye exams", text: "Clear vision checks to guide lens choices and correction needs." },
|
||||
{ title: "Repairs", text: "Adjustment and repair for prescription glasses and sunglasses depending on frame condition." },
|
||||
{ title: "Prescription glasses", text: "Authentic frames, suitable lenses and patient support through the final choice." },
|
||||
{ title: "Sunglasses", text: "Authentic sunglasses for comfort, protection and everyday style." },
|
||||
{ title: "Kids glasses", text: "Practical, comfortable options designed around family needs." },
|
||||
{ title: "Complete support", text: "Guidance for lenses, frames, fitting and warranty service." }
|
||||
]
|
||||
},
|
||||
collections: {
|
||||
eyebrow: "Collections",
|
||||
title: "Real eyewear choices, presented clearly.",
|
||||
body: "The in-store selection covers the essentials customers in Temara ask for most: optical, sun, kids and fair-budget frames.",
|
||||
items: [
|
||||
{ title: "Prescription glasses", text: "Correction, comfort and lens guidance for everyday use." },
|
||||
{ title: "Sunglasses", text: "Authentic models made for protection, comfort and a refined finish." },
|
||||
{ title: "Kids glasses", text: "Practical, stable frames selected around family needs." },
|
||||
{ title: "Budget frames", text: "Accessible options with the same serious guidance and fitting support." }
|
||||
]
|
||||
},
|
||||
why: {
|
||||
eyebrow: "Why New Optic",
|
||||
title: "Trust comes first.",
|
||||
items: [
|
||||
"Fair prices explained clearly",
|
||||
"Strong local reputation in Temara",
|
||||
"Original and authentic frames only",
|
||||
"Professional, simple and human service",
|
||||
"Support from correction to warranty",
|
||||
"Solutions for adults, children and families"
|
||||
]
|
||||
},
|
||||
trust: {
|
||||
eyebrow: "Reputation",
|
||||
title: "A neighborhood optician with lasting standards.",
|
||||
stats: [
|
||||
{ value: "2011", label: "local presence since around" },
|
||||
{ value: "100%", label: "authentic frames" },
|
||||
{ value: "360°", label: "guidance, lenses, frames and support" }
|
||||
],
|
||||
body: "No exaggerated claims. New Optic earns trust through guidance, consistent service and a selection of authentic products."
|
||||
},
|
||||
contact: {
|
||||
eyebrow: "Contact",
|
||||
title: "Visit the store or contact us directly.",
|
||||
body: "Need help with lenses, a repair or a new frame? Send a WhatsApp message, call us, or visit New Optic in Massira 1, Temara.",
|
||||
whatsapp: "Message on WhatsApp",
|
||||
call: "Call now",
|
||||
visit: "Open directions",
|
||||
qr: "Scan to open the location",
|
||||
phoneLabel: "Phone",
|
||||
whatsappLabel: "WhatsApp",
|
||||
addressLabel: "Address",
|
||||
hoursLabel: "Hours",
|
||||
hoursValue: "Hours are not published online. Contact the store before visiting.",
|
||||
whatsappMessage: "Hello New Optic, I would like information about your eyewear and services."
|
||||
},
|
||||
footer: {
|
||||
tagline: "Local optician in Temara, authentic frames and professional service.",
|
||||
rights: "All rights reserved."
|
||||
}
|
||||
} as const;
|
||||
96
messages/fr.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export const fr = {
|
||||
meta: {
|
||||
title: "New Optic a Temara | Opticien de confiance depuis 2011",
|
||||
description: "New Optic a Temara propose montures originales, examens de vue, reparations, lunettes de vue, solaires et enfants avec un service professionnel et des prix justes."
|
||||
},
|
||||
nav: {
|
||||
links: [
|
||||
{ label: "Accueil", href: "#home" },
|
||||
{ label: "Services", href: "#services" },
|
||||
{ label: "Collections", href: "#collections" },
|
||||
{ label: "Confiance", href: "#trust" },
|
||||
{ label: "Contact", href: "#contact" }
|
||||
],
|
||||
cta: "WhatsApp",
|
||||
menu: "Menu"
|
||||
},
|
||||
hero: {
|
||||
eyebrow: "Opticien local a Temara depuis 2011",
|
||||
title: "Votre opticien de confiance a Temara.",
|
||||
subtitle: "Montures originales, service professionnel, prix justes et accompagnement complet pour vos lunettes.",
|
||||
primary: "Nous contacter",
|
||||
secondary: "Voir les services",
|
||||
note: "Examens de vue, reparations, lunettes de vue, solaires et enfants.",
|
||||
imageAlt: "Interieur et selection de lunettes chez New Optic a Temara"
|
||||
},
|
||||
about: {
|
||||
eyebrow: "Une adresse locale reconnue",
|
||||
title: "Un service optique simple, professionnel et fiable.",
|
||||
body: "Depuis autour de 2011, 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"]
|
||||
},
|
||||
services: {
|
||||
eyebrow: "Services",
|
||||
title: "Tout l'essentiel pour vos lunettes, au meme endroit.",
|
||||
items: [
|
||||
{ title: "Examen de vue", text: "Un controle clair pour mieux orienter le choix des verres et de la correction." },
|
||||
{ title: "Reparation", text: "Ajustement et reparation de lunettes de vue et solaires selon l'etat de la monture." },
|
||||
{ title: "Lunettes de vue", text: "Montures originales, verres adaptes et accompagnement dans le choix final." },
|
||||
{ title: "Lunettes solaires", text: "Des solaires authentiques pour le confort, la protection et le style quotidien." },
|
||||
{ title: "Lunettes enfants", text: "Des choix pratiques, confortables et adaptes aux besoins des familles." },
|
||||
{ title: "Suivi complet", text: "Conseil sur les verres, les montures, l'ajustement et la garantie." }
|
||||
]
|
||||
},
|
||||
collections: {
|
||||
eyebrow: "Collections",
|
||||
title: "Des choix concrets, presentes clairement.",
|
||||
body: "La selection en magasin couvre les besoins essentiels des clients de Temara: correction, solaire, enfants et options a budget maitrise.",
|
||||
items: [
|
||||
{ title: "Lunettes de vue", text: "Correction, confort et conseil sur les verres pour le quotidien." },
|
||||
{ title: "Lunettes solaires", text: "Modeles authentiques pour proteger les yeux avec une finition soignee." },
|
||||
{ title: "Lunettes enfants", text: "Montures pratiques, stables et adaptees aux familles." },
|
||||
{ title: "Montures budget", text: "Des options accessibles, presentees avec le meme serieux de conseil." }
|
||||
]
|
||||
},
|
||||
why: {
|
||||
eyebrow: "Pourquoi New Optic",
|
||||
title: "La confiance avant tout.",
|
||||
items: [
|
||||
"Prix justes presentes avec transparence",
|
||||
"Forte reputation locale a Temara",
|
||||
"Montures originales et authentiques seulement",
|
||||
"Service professionnel, simple et humain",
|
||||
"Accompagnement de la correction a la garantie",
|
||||
"Solutions pour adultes, enfants et familles"
|
||||
]
|
||||
},
|
||||
trust: {
|
||||
eyebrow: "Reputation",
|
||||
title: "Un opticien de quartier avec une exigence durable.",
|
||||
stats: [
|
||||
{ value: "2011", label: "presence locale depuis autour de" },
|
||||
{ value: "100%", label: "montures originales" },
|
||||
{ value: "360°", label: "conseil, verres, montures et suivi" }
|
||||
],
|
||||
body: "Pas de promesses exagérées. New Optic construit la relation par le conseil, la régularité du service et une sélection de produits authentiques."
|
||||
},
|
||||
contact: {
|
||||
eyebrow: "Contact",
|
||||
title: "Passez en magasin ou contactez-nous directement.",
|
||||
body: "Une question sur vos verres, une reparation ou une nouvelle monture ? Envoyez un message WhatsApp, appelez, ou venez nous voir a Massira 1, Temara.",
|
||||
whatsapp: "Ecrire sur WhatsApp",
|
||||
call: "Appeler maintenant",
|
||||
visit: "Ouvrir l'itineraire",
|
||||
qr: "Scannez pour ouvrir l'adresse",
|
||||
phoneLabel: "Telephone",
|
||||
whatsappLabel: "WhatsApp",
|
||||
addressLabel: "Adresse",
|
||||
hoursLabel: "Horaires",
|
||||
hoursValue: "Horaires non publies en ligne. Contactez le magasin avant de vous deplacer.",
|
||||
whatsappMessage: "Bonjour New Optic, je souhaite avoir des informations sur vos lunettes et services."
|
||||
},
|
||||
footer: {
|
||||
tagline: "Opticien local a Temara, montures originales et service professionnel.",
|
||||
rights: "Tous droits reserves."
|
||||
}
|
||||
} as const;
|
||||
7
messages/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { ar } from "./ar";
|
||||
import { en } from "./en";
|
||||
import { fr } from "./fr";
|
||||
|
||||
export const messages = { fr, ar, en } as const;
|
||||
|
||||
export type Messages = (typeof messages)[keyof typeof messages];
|
||||
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
9
next.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"]
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6633
package-lock.json
generated
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "new-optic-website",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^16.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.10"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 94 KiB |
BIN
public/assets/New-optic-BG-mobile.webp
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/assets/New-optic-BG.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/assets/New-optic-logo.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
public/assets/budget-frames.avif
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
public/assets/kids-glasses.webp
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
public/assets/new-optic-map-qr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 33 33" shape-rendering="crispEdges"><path fill="#ffffff" d="M0 0h33v33H0z"/><path stroke="#111317" d="M2 2.5h7m3 0h1m3 0h1m1 0h5m1 0h7M2 3.5h1m5 0h1m2 0h2m4 0h2m3 0h1m1 0h1m5 0h1M2 4.5h1m1 0h3m1 0h1m1 0h1m1 0h2m6 0h3m1 0h1m1 0h3m1 0h1M2 5.5h1m1 0h3m1 0h1m1 0h3m1 0h2m1 0h1m1 0h2m3 0h1m1 0h3m1 0h1M2 6.5h1m1 0h3m1 0h1m1 0h1m1 0h3m1 0h7m1 0h1m1 0h3m1 0h1M2 7.5h1m5 0h1m1 0h1m1 0h1m3 0h4m4 0h1m5 0h1M2 8.5h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7M10 9.5h1m2 0h2m1 0h1m1 0h1m2 0h2M2 10.5h1m1 0h5m2 0h1m3 0h4m2 0h1m2 0h5M4 11.5h1m5 0h1m1 0h2m5 0h3m1 0h2m1 0h1m3 0h1M2 12.5h3m1 0h3m1 0h5m2 0h1m4 0h1m1 0h2M4 13.5h2m1 0h1m5 0h1m2 0h2m2 0h5m1 0h2m1 0h1M2 14.5h3m1 0h4m1 0h1m1 0h1m2 0h1m1 0h2m1 0h1m3 0h1m1 0h2M2 15.5h1m1 0h4m2 0h3m7 0h7m3 0h1M2 16.5h1m2 0h1m1 0h3m1 0h1m1 0h5m4 0h2m2 0h3M2 17.5h1m1 0h1m6 0h1m1 0h2m1 0h2m3 0h2m3 0h1m2 0h1M2 18.5h1m2 0h2m1 0h1m2 0h3m1 0h1m2 0h2m1 0h1m3 0h1m1 0h2M2 19.5h1m1 0h2m3 0h8m4 0h2m1 0h3m1 0h1m1 0h1M2 20.5h1m4 0h2m5 0h1m2 0h1m4 0h2m1 0h2m1 0h1M2 21.5h1m3 0h1m2 0h1m2 0h1m1 0h1m1 0h2m3 0h3m2 0h1m2 0h1M2 22.5h1m1 0h2m1 0h3m1 0h3m8 0h5m1 0h3M10 23.5h2m2 0h2m2 0h1m1 0h1m1 0h1m3 0h5M2 24.5h7m2 0h1m2 0h4m1 0h1m1 0h2m1 0h1m1 0h3M2 25.5h1m5 0h1m1 0h2m3 0h1m2 0h1m1 0h3m3 0h1M2 26.5h1m1 0h3m1 0h1m1 0h3m1 0h1m1 0h3m3 0h5m1 0h3M2 27.5h1m1 0h3m1 0h1m1 0h1m1 0h3m5 0h1m1 0h1m4 0h4M2 28.5h1m1 0h3m1 0h1m1 0h1m6 0h1m3 0h2m1 0h6M2 29.5h1m5 0h1m2 0h3m1 0h1m6 0h2m1 0h3m1 0h1M2 30.5h7m1 0h1m1 0h1m1 0h2m1 0h2m4 0h1m1 0h1m2 0h1"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/assets/new-optic-shop-exterior.jpeg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
public/assets/prescription-glasses.avif
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/assets/sunglasses.webp
Normal file
|
After Width: | Height: | Size: 16 KiB |
2104
public/scripts/liquidGL.js
Normal file
26
tailwind.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./messages/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: "#111317",
|
||||
smoke: "#f6f5f2",
|
||||
silver: "#d8dde5",
|
||||
optical: "#315f8f"
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["var(--font-sans)", "Inter", "system-ui", "sans-serif"],
|
||||
arabic: ["var(--font-arabic)", "system-ui", "sans-serif"]
|
||||
},
|
||||
boxShadow: {
|
||||
glass: "0 24px 80px rgba(17, 19, 23, 0.10)",
|
||||
soft: "0 18px 50px rgba(49, 95, 143, 0.13)"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
||||
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||