Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d39e857a | ||
|
|
78aec0e08d | ||
|
|
81358b0815 | ||
|
|
1d9d620d67 | ||
|
|
bcd7bd2c7b | ||
|
|
38cc2025de | ||
|
|
8382501598 | ||
|
|
6f6536db7c | ||
|
|
8bc90b0db9 | ||
|
|
0cb5693ca3 | ||
|
|
60d50e15e4 | ||
|
|
873026a4a9 | ||
|
|
c2490947d2 | ||
|
|
7bb71dc707 | ||
|
|
60e1ea8407 | ||
|
|
e500fee42f | ||
|
|
78649c6f3b | ||
|
|
c667736ee6 | ||
|
|
e8f3bf8686 | ||
|
|
4c415cd5ea | ||
|
|
8d4fe60f28 | ||
|
|
5a40f6b733 | ||
|
|
dda358b59d | ||
|
|
de25d774af | ||
|
|
fb41977b18 | ||
|
|
e7fde29a21 | ||
|
|
2d32a1e722 | ||
|
|
5271914b16 | ||
|
|
e500409305 | ||
|
|
1967ae5402 | ||
|
|
d8502292bc | ||
|
|
37fab3fcaf | ||
|
|
574d43adb4 | ||
|
|
7c3b42e651 | ||
|
|
b5876aead5 | ||
|
|
329967df60 | ||
|
|
fa5f4f7a3e | ||
|
|
de9f358582 | ||
|
|
7458818c62 | ||
|
|
79f7f9e579 | ||
|
|
8457493f49 | ||
|
|
729ab71c2c | ||
|
|
bfe589be27 | ||
|
|
d7c343ac7d | ||
|
|
461a00384c | ||
|
|
92cf4aba3b | ||
|
|
2eac53dac3 | ||
|
|
fe5f1ce25f | ||
|
|
52b910ed93 | ||
|
|
03dbc4ac98 |
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npx next *)",
|
||||||
|
"WebFetch(domain:www.unitree.com)",
|
||||||
|
"WebFetch(domain:www.pudurobotics.com)",
|
||||||
|
"WebFetch(domain:cdn.unitree.com)",
|
||||||
|
"Bash(curl -s \"https://www.unitree.com/\")",
|
||||||
|
"Bash(curl -sL \"https://www.unitree.com/\" -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36\")",
|
||||||
|
"Bash(curl -sL \"https://www.unitree.com/\" -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\")",
|
||||||
|
"Bash(curl -sL \"https://www.unitree.com/_nuxt/entry.DnivrlYq.css\" -A \"Mozilla/5.0\")",
|
||||||
|
"Bash(curl -sL \"https://www.unitree.com/_nuxt/default.awSnd8AZ.css\" -A \"Mozilla/5.0\")",
|
||||||
|
"Bash(curl -sL \"https://www.unitree.com/images/37d3d40c1272402a9c363a50b405276d.svg\" -A \"Mozilla/5.0\" -o \"C:\\\\Users\\\\NajjarV02\\\\Desktop\\\\yslootahrobotics\\\\public\\\\brands\\\\unitree-logo-white.svg\")",
|
||||||
|
"Bash(curl -sL \"https://www.unitree.com/images/0079f8938336436e955ea3a98c4e1e59.svg\" -A \"Mozilla/5.0\" -o \"C:\\\\Users\\\\NajjarV02\\\\Desktop\\\\yslootahrobotics\\\\public\\\\brands\\\\unitree-logo-dark.svg\")",
|
||||||
|
"PowerShell(Copy-Item *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Component Rules
|
||||||
|
|
||||||
|
Per-element rules. Apply when designing or reviewing the corresponding component.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Buttons
|
||||||
|
|
||||||
|
**Primary**
|
||||||
|
- Gradient gold: `bg-gradient-to-r from-gold-light via-gold to-gold-deep`
|
||||||
|
- Text: `text-obsidian` (dark on gold for contrast)
|
||||||
|
- Shape: `rounded-full px-7 py-3.5 text-sm font-medium tracking-wide`
|
||||||
|
- Shadow: `shadow-[0_10px_40px_-12px_rgba(212,164,55,0.6)]`
|
||||||
|
- Hover shadow only: `hover:shadow-[0_18px_50px_-12px_rgba(242,194,91,0.8)]`
|
||||||
|
- Optional `magnetic` wrapper for cursor pull effect on desktop.
|
||||||
|
|
||||||
|
**Outline**
|
||||||
|
- `border border-bone/20 text-bone hover:border-gold/60 hover:text-gold-light`
|
||||||
|
- Same shape as primary.
|
||||||
|
|
||||||
|
**Ghost**
|
||||||
|
- `text-bone hover:text-gold-light` no border, no fill.
|
||||||
|
|
||||||
|
**Forbidden**
|
||||||
|
- Bright red CTA buttons (unless brand color is red).
|
||||||
|
- Square corners on action buttons.
|
||||||
|
- Solid `#000` or `#fff` backgrounds.
|
||||||
|
- Loading text inside button that breaks layout use a spinner icon + word swap with fixed min-width.
|
||||||
|
- Success/error message **inside** button render outside as a banner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cards
|
||||||
|
|
||||||
|
**Standard card**
|
||||||
|
```tsx
|
||||||
|
<div className="group relative isolate h-full overflow-hidden rounded-2xl border border-bone/10 bg-graphite/40 p-6 backdrop-blur-sm transition-all duration-500 hover:-translate-y-1 hover:border-gold/30 hover:bg-graphite/60 md:p-7">
|
||||||
|
{/* hover amber glow (decorative) */}
|
||||||
|
{/* content */}
|
||||||
|
{/* gold underline sweep at bottom */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Premium / featured card** (used for cert card, sale card, hero CTA)
|
||||||
|
- `border-gold/25`
|
||||||
|
- `bg-gradient-to-br from-[#1a1407] via-[#0c0a06] to-[#1e1409]`
|
||||||
|
- `shadow-[0_30px_120px_-30px_rgba(212,164,55,0.45)]`
|
||||||
|
- Often has animated `shimmer` hairline on top edge.
|
||||||
|
|
||||||
|
**Grid layout**
|
||||||
|
- `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-N gap-4 md:gap-5`
|
||||||
|
- Apply `cardGrid` + `cardCell` variants for staggered entry.
|
||||||
|
|
||||||
|
**Inside the card**
|
||||||
|
- Top row: small icon disc (`size-11 glass-gold`) + optional mono micro-label.
|
||||||
|
- Heading: `font-display text-2xl md:text-3xl`.
|
||||||
|
- Body: `text-sm text-mist leading-relaxed`.
|
||||||
|
- Bottom: optional "Learn more" link or gold underline sweep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section heading
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col items-center gap-6 text-center"> {/* or items-start for left-aligned */}
|
||||||
|
<SectionLabel index="01">Section name</SectionLabel>
|
||||||
|
<Reveal>
|
||||||
|
<h2 className="font-display text-5xl leading-[1.02] text-bone text-balance md:text-6xl">
|
||||||
|
Plain words and a{" "}
|
||||||
|
<span className="gold-text">gold accent phrase.</span>
|
||||||
|
</h2>
|
||||||
|
</Reveal>
|
||||||
|
<Reveal delay={0.1}>
|
||||||
|
<p className="max-w-2xl text-mist text-pretty md:text-lg">
|
||||||
|
One refined sentence describing the section.
|
||||||
|
</p>
|
||||||
|
</Reveal>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
- Always `text-balance` on h2 for clean wrapping.
|
||||||
|
- `text-pretty` on subtitle.
|
||||||
|
- Use SectionLabel with sequential index per page (01, 02, 03…). No mono font.
|
||||||
|
- One gold-accent phrase max per heading overdoing gold cheapens it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navbar
|
||||||
|
|
||||||
|
- Fixed top: `fixed inset-x-0 top-0 z-50`.
|
||||||
|
- Logo left, nav center/right, primary CTA + WhatsApp icon right.
|
||||||
|
- Scrolled state: `backdrop-blur-xl bg-obsidian/70 border-b border-bone/5`.
|
||||||
|
- Nav links: `text-sm font-medium tracking-[0.005em] text-bone/85 hover:text-gold-light`.
|
||||||
|
- Active state via `usePathname()` → `text-gold-light` + full-width underline + `aria-current="page"`.
|
||||||
|
- Mobile: hamburger right, menu drawer below, links close on click.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Footer
|
||||||
|
|
||||||
|
**Layout 4 columns, no duplication**
|
||||||
|
|
||||||
|
| Column 1 | Column 2 | Column 3 | Column 4 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Brand + contact | Navigation | Services | Legal & App |
|
||||||
|
| Logo, tagline, phone, email, WhatsApp, Instagram | Home + all top-level routes | Specific service items | Privacy Policy + 2 store badges |
|
||||||
|
|
||||||
|
**Rules**
|
||||||
|
- No page link appears in two columns.
|
||||||
|
- Privacy Policy lives only under Legal.
|
||||||
|
- App badges only in Legal & App column (footer) official SVGs at 132 px width, 8 px gap.
|
||||||
|
- External links open in new tab with rel attrs.
|
||||||
|
- Background: `bg-obsidian` with subtle "LUXAM"-style watermark behind content (translate-y-[18%], opacity 0.025).
|
||||||
|
- Bottom row: `© YYYY {brand}. All rights reserved.` + tagline + agency credit (subtle, gold-underlined on hover).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
- Field wrapper: `<label>` with small uppercase metadata label + input.
|
||||||
|
- Input class:
|
||||||
|
```
|
||||||
|
peer w-full rounded-xl border border-bone/12 bg-obsidian/60
|
||||||
|
px-4 py-3.5 text-sm text-bone placeholder-mist/40 outline-none
|
||||||
|
transition-colors duration-300
|
||||||
|
focus:border-gold/60 focus:bg-obsidian/80
|
||||||
|
focus:shadow-[0_0_0_3px_rgba(212,164,55,0.18)]
|
||||||
|
aria-[invalid=true]:border-[#a3261c]/60
|
||||||
|
```
|
||||||
|
- Honeypot field: hidden `<input name="website">` silently succeed if filled.
|
||||||
|
- Validation: server-side via `submitContact()` action; client gets `state.fieldErrors` + `state.message`.
|
||||||
|
- Submission states: `useActionState` + `useFormStatus`. Spinner inside button, success/error banner **outside** button.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
- Source: `lucide-react` (already installed).
|
||||||
|
- Size: `size-4` inline, `size-5` standalone, `size-[18px]` inside discs.
|
||||||
|
- StrokeWidth: `1.4` for elegance (not the default 2).
|
||||||
|
- Wrapper for emphasis: `glass-gold size-11 grid place-items-center rounded-full`.
|
||||||
|
- **Forbidden**: oversized icons (>32 px) standalone, multi-color icon sets, cartoonish flat icons.
|
||||||
|
|
||||||
|
**No lucide WhatsApp glyph** use custom inline SVG from `snippets/WhatsAppLink.tsx`.
|
||||||
|
|
||||||
|
**No lucide Instagram glyph in v1.16** use custom inline SVG (see footer).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App store badges
|
||||||
|
|
||||||
|
- Use **official SVG badges**, not custom-drawn pills with text.
|
||||||
|
- Sources: svgrepo.com `/show/<id>/<slug>.svg` (downloads clean SVG).
|
||||||
|
- Store locally in `public/images/app-store-badge.svg` + `public/images/google-play-badge.svg`.
|
||||||
|
- Render via plain `<img>` (next/image blocks SVG by default in Next 16).
|
||||||
|
- Width: `132 px` footer, `140-160 px` hero CTA section.
|
||||||
|
- Hover: `opacity-85` + `-translate-y-0.5`. No gold circles around them.
|
||||||
|
- Stack with `gap-2` (8 px) in footer.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<a href={app.appStore} target="_blank" rel="noopener noreferrer" aria-label="Download Luxam on the App Store"
|
||||||
|
className="inline-flex transition duration-300 hover:-translate-y-0.5 hover:opacity-85">
|
||||||
|
<img src="/images/app-store-badge.svg" alt="Download on the App Store"
|
||||||
|
className="h-auto w-[132px] max-w-full" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WhatsApp link
|
||||||
|
|
||||||
|
- URL form: `https://api.whatsapp.com/send/?phone=<digits-only>&text&type=phone_number&app_absent=0`
|
||||||
|
- Phone in URL: country code + number, **no `+` or spaces**.
|
||||||
|
- Display phone format: `+CC XX XXX XXXX`.
|
||||||
|
- Anchor: `target="_blank" rel="noopener noreferrer" aria-label="Chat with [Brand] on WhatsApp"`.
|
||||||
|
- Icon: inline SVG (lucide has no WhatsApp glyph). Refined gold tint, not bright green.
|
||||||
|
- Two surfaces: nav (circular icon button) + footer (icon + display number).
|
||||||
|
- Optional third surface: contact page row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client logo section
|
||||||
|
|
||||||
|
- Horizontal marquee, `animation: marquee 50s linear infinite`.
|
||||||
|
- Hover pauses via `onMouseEnter/Leave` setting `animationPlayState`.
|
||||||
|
- Duplicate the data array `[...clients, ...clients]` for seamless loop.
|
||||||
|
- Tiles: `h-24 w-44 sm:h-28 sm:w-52 rounded-2xl border border-bone/10 bg-graphite/40 backdrop-blur-sm`.
|
||||||
|
- Logos: `object-contain max-h-14 sm:max-h-16`. Default `grayscale opacity-70`. Hover: `grayscale-0 opacity-100 scale-105`.
|
||||||
|
- Edge fade via `mask-image: linear-gradient(90deg, transparent, black 8%, black 92%, transparent)`.
|
||||||
|
- Reduced-motion fallback: render a static `grid-cols-2 sm:grid-cols-3 lg:grid-cols-6`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Certificate gallery
|
||||||
|
|
||||||
|
- 4-col on desktop, 2-col tablet, 1-col mobile.
|
||||||
|
- Each tile: portrait `aspect-[3/4]` with `next/image fill object-cover object-top`.
|
||||||
|
- Top-left badge: page indicator (`Page 1`, `Page 2 · Annex`).
|
||||||
|
- Top-right: ArrowUpRight disc (links open in new tab).
|
||||||
|
- Click anywhere on tile opens the PDF.
|
||||||
|
- Hover: scale 1.04 (1.2s ease-out), gold ring border, amber radial glow, caption title → gold-light.
|
||||||
|
- Caption below image: `font-mono` micro-label (document name in gold) + title + "Open PDF" CTA.
|
||||||
|
- Optional "Need the full PDFs?" pill row below grid for direct downloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final CTA section
|
||||||
|
|
||||||
|
- Wraps inside a premium gold-bordered glass card.
|
||||||
|
- Animated halo (`!reduce && motion.div opacity pulse`).
|
||||||
|
- Grid: `lg:grid-cols-12` (8/4 split).
|
||||||
|
- Left col: SectionLabel + h2 with one gold-accent phrase + paragraph.
|
||||||
|
- Right col: 2 buttons (`primary` + `outline`).
|
||||||
|
- Place at the end of every long page, before the footer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy / Legal pages
|
||||||
|
|
||||||
|
- Single-column max-w-3xl reading layout.
|
||||||
|
- Each section gets `id="..."` + `scroll-mt-28` for anchored navigation.
|
||||||
|
- `font-display` h2 per section, `text-mist` body, `text-pretty` on every `<p>`.
|
||||||
|
- Effective date + `metallic-divider` at top.
|
||||||
|
- Bottom disclaimer: `text-sm text-mist/70` with mailto link.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
|
||||||
|
- Don't add hover effects without `transition-*` for smoothness.
|
||||||
|
- Don't use `transform-origin` defaults on rotated icon discs keep them small (8-12°).
|
||||||
|
- Don't use `whileHover={{ scale: 1.1 }}` on cards too theatrical. Use `-translate-y-1` instead.
|
||||||
|
- Don't add box-shadows with multiple layers and bright colors. One subtle amber glow max.
|
||||||
|
- Don't introduce a new font family per page stick to display + sans.
|
||||||
|
- Don't render placeholder images (`via.placeholder.com`, gray boxes) in production code. Always use real assets the user provided.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Component Snippets
|
||||||
|
|
||||||
|
Overview of the reusable TSX snippets in `snippets/`. Each is a copy-paste base for a new project adapt brand tokens + content via your own `lib/content.ts`.
|
||||||
|
|
||||||
|
| File | Purpose | Depends on |
|
||||||
|
|---|---|---|
|
||||||
|
| `PremiumButton.tsx` | Gold-gradient primary + outline + ghost variants, optional magnetic wrapper | `lib/cn`, lucide ArrowUpRight |
|
||||||
|
| `SectionHeading.tsx` | Premium section heading w/ eyebrow + serif h2 + sub paragraph | `lib/cn`, framer Reveal |
|
||||||
|
| `GlassCard.tsx` | Dark glass card w/ hover lift, amber glow, gold underline sweep | lucide LucideIcon |
|
||||||
|
| `Footer.tsx` | 4-column footer w/ no duplication, watermark, agency credit | brand contact data |
|
||||||
|
| `AppBadges.tsx` | Official App Store + Google Play badge anchors (plain `<img>`) | `/public/images/*-badge.svg` |
|
||||||
|
| `WhatsAppLink.tsx` | Refined gold WhatsApp icon button + label variant | inline SVG (no lucide glyph) |
|
||||||
|
| `ClientLogoMarquee.tsx` | Auto-scroll logo marquee w/ hover pause + reduced-motion grid fallback | logos array, marquee keyframe in globals.css |
|
||||||
|
| `CertificateGallery.tsx` | 4-image bento gallery linking to PDFs in new tab | `next/image`, certs array |
|
||||||
|
|
||||||
|
## How to use
|
||||||
|
|
||||||
|
1. Copy the file you need into your project's `src/components/` (or wherever your component layer lives).
|
||||||
|
2. Update import paths (`@/lib/cn`, `@/components/ui/...`) to match your project alias.
|
||||||
|
3. Wire data: the snippets accept props or read from a `lib/content.ts` you control.
|
||||||
|
4. Confirm Tailwind v4 `@theme` tokens (`obsidian`, `gold`, `bone`, `mist`, etc.) exist see `DESIGN_SYSTEM.md`.
|
||||||
|
5. Confirm Framer Motion + lucide-react installed:
|
||||||
|
```bash
|
||||||
|
npm i framer-motion lucide-react
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adapt patterns
|
||||||
|
|
||||||
|
The snippets show the **shape**. Your brand may need:
|
||||||
|
- Different palette (warm copper instead of gold? `--color-gold` → your token, keep the same usage rules).
|
||||||
|
- Different copy hierarchy (more / fewer columns in footer).
|
||||||
|
- Different surfaces (e.g., add a Resources column).
|
||||||
|
|
||||||
|
Keep the **interaction grammar** consistent:
|
||||||
|
- Hover lift `-translate-y-1`.
|
||||||
|
- Amber/gold glow bottom-right.
|
||||||
|
- Underline sweep on cards.
|
||||||
|
- 1.4 strokeWidth on icons.
|
||||||
|
- Serif on h1/h2 only.
|
||||||
|
|
||||||
|
That's what makes any palette feel "Luxam-class" premium.
|
||||||
|
|
||||||
|
## Don't fork without reason
|
||||||
|
|
||||||
|
If a snippet does 95% of what you need, **wrap** it with project-specific props rather than forking. Forks drift.
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Design System
|
||||||
|
|
||||||
|
Reusable design tokens + scales extracted from the Luxam reference build. Drop into `globals.css` (Tailwind v4 `@theme`) of any new project to start with the same DNA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color palette
|
||||||
|
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
/* Backgrounds obsidian → graphite layering */
|
||||||
|
--color-obsidian: #08080A; /* page background */
|
||||||
|
--color-graphite: #15161A; /* dark glass card */
|
||||||
|
--color-graphite-2: #1C1D22; /* slightly lifted card variant */
|
||||||
|
|
||||||
|
/* Foreground */
|
||||||
|
--color-bone: #F4F0E6; /* primary text on dark */
|
||||||
|
--color-mist: #C7C2B5; /* secondary/muted text */
|
||||||
|
|
||||||
|
/* Brand gold spectrum (use sparingly + structurally) */
|
||||||
|
--color-gold: #D4A437; /* core brand gold */
|
||||||
|
--color-gold-light: #F2C25B; /* hover, highlight, gradient stops */
|
||||||
|
--color-gold-deep: #7A4A12; /* gradient base */
|
||||||
|
--color-ember: #B5731A; /* warm secondary */
|
||||||
|
|
||||||
|
/* Sustainability accent (use only where ecology theme applies) */
|
||||||
|
--color-emerald: #1F8F6B;
|
||||||
|
--color-emerald-glow: #3FD8A4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage rules**
|
||||||
|
- **Background:** `bg-obsidian` body, `bg-graphite/40 backdrop-blur-sm` cards.
|
||||||
|
- **Borders:** `border-bone/10` default, `border-gold/30` on hover, `border-gold/40` active.
|
||||||
|
- **Text:** `text-bone` titles, `text-mist` body/secondary, `text-gold-light` brand accents.
|
||||||
|
- **Never use** raw `#fff` or pure `#000`. Always `bone` or `obsidian`.
|
||||||
|
- **Gradients:** single-axis brand gradient `from-gold-light via-gold to-gold-deep`. No rainbow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// app/layout.tsx
|
||||||
|
import { Cormorant_Garamond, Geist, Geist_Mono } from "next/font/google";
|
||||||
|
|
||||||
|
const display = Cormorant_Garamond({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-display",
|
||||||
|
weight: ["400", "500", "600"],
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
const sans = Geist({ subsets: ["latin"], variable: "--font-sans", display: "swap" });
|
||||||
|
const mono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono", display: "swap" });
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hierarchy**
|
||||||
|
|
||||||
|
| Role | Font | Size |
|
||||||
|
|---|---|---|
|
||||||
|
| Hero H1 | `font-display` (serif) | `text-[clamp(2.6rem,7vw,6.4rem)] leading-[1.0] tracking-[-0.02em]` |
|
||||||
|
| Section H2 | `font-display` | `text-[clamp(2.4rem,5vw,4.6rem)] leading-[1.02] tracking-[-0.02em]` |
|
||||||
|
| Card H3/H4 | `font-display` | `text-2xl md:text-3xl` |
|
||||||
|
| Body | `font-sans` (inherited default) | `text-base md:text-lg leading-[1.7]` |
|
||||||
|
| UI label | `font-sans` | `text-[10px] md:text-[11px] font-medium uppercase tracking-[0.22em]` |
|
||||||
|
| Nav link | `font-sans` | `text-sm font-medium tracking-[0.005em]` |
|
||||||
|
| Stats number | `font-display` | `text-5xl md:text-6xl` (responsive smaller on mobile) |
|
||||||
|
|
||||||
|
**Avoid**
|
||||||
|
- `font-mono` on long values or body text.
|
||||||
|
- `tracking-[0.3em]+` on small labels.
|
||||||
|
- `leading-[0.95]` on serif headlines (clips ascenders).
|
||||||
|
|
||||||
|
**Helper class** for one-color brand text:
|
||||||
|
```css
|
||||||
|
.gold-text {
|
||||||
|
background: linear-gradient(90deg, #FFEFC5, #F2C25B 40%, #D4A437 80%, #7A4A12);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing scale
|
||||||
|
|
||||||
|
Section padding lives **only** on the `Section` primitive:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ui/Section.tsx
|
||||||
|
className={cn("relative scroll-mt-24 py-16 md:scroll-mt-28 md:py-20 lg:py-24", className)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Mobile: `py-16` (64 px)
|
||||||
|
- Tablet: `md:py-20` (80 px)
|
||||||
|
- Desktop: `lg:py-24` (96 px)
|
||||||
|
- All anchored sections get `scroll-mt-24 md:scroll-mt-28` for sticky-nav clearance.
|
||||||
|
|
||||||
|
**Don't** override per-section unless there's a structural reason. Centralize.
|
||||||
|
|
||||||
|
Card padding: `p-6 md:p-7` standard, `p-7 md:p-10` for hero/feature cards.
|
||||||
|
|
||||||
|
Grid gaps: `gap-4 md:gap-5` standard, `gap-6 md:gap-8` for large cards.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Border radius scale
|
||||||
|
|
||||||
|
```css
|
||||||
|
--radius-xs: 4px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-md: 14px;
|
||||||
|
--radius-lg: 22px;
|
||||||
|
--radius-xl: 32px;
|
||||||
|
```
|
||||||
|
|
||||||
|
Tailwind shortcuts to match: `rounded-2xl` (cards), `rounded-3xl` (hero panels), `rounded-full` (pills + buttons).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// shared variants
|
||||||
|
const cardGrid: Variants = {
|
||||||
|
hidden: {},
|
||||||
|
visible: { transition: { staggerChildren: 0.08, delayChildren: 0.15 } },
|
||||||
|
};
|
||||||
|
const cardCell: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 24, filter: "blur(6px)" },
|
||||||
|
visible: {
|
||||||
|
opacity: 1, y: 0, filter: "blur(0px)",
|
||||||
|
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- Entrance ease: `[0.16, 1, 0.3, 1]` (cubic-bezier "out-quart-ish").
|
||||||
|
- Duration: hero 1.3s, cards 0.8s, hover 0.3-0.5s.
|
||||||
|
- Hover lift: `-translate-y-1` max. No `-translate-y-2`+ (jumpy).
|
||||||
|
- Halo pulse: `[0.5, 0.85, 0.5]` opacity, 6s loop. Wrap in `!reduce && (...)`.
|
||||||
|
- `useReducedMotion()` must be read in any animated component.
|
||||||
|
|
||||||
|
**Forbidden**
|
||||||
|
- Bouncy springs (`stiffness > 200`) on entrance.
|
||||||
|
- Scale jumps > 1.05 on hover for cards.
|
||||||
|
- Parallax > 200 px translate range.
|
||||||
|
- Marquees without `mask-image` edge fade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual textures
|
||||||
|
|
||||||
|
| Class | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `noise-bg` | Subtle film-grain background overlay |
|
||||||
|
| `grain` | Animated grain (use sparingly on cards) |
|
||||||
|
| `metallic-divider` | Thin gold-tinted divider line |
|
||||||
|
| `radial-gold` | Faint amber radial wash (positioned absolute, blur-3xl) |
|
||||||
|
| `radial-emerald` | Same, emerald variant |
|
||||||
|
| `hairline-t`, `hairline-b` | 1 px top/bottom hairline borders |
|
||||||
|
| `mask-fade-y`, `mask-fade-x` | Edge fade masks |
|
||||||
|
|
||||||
|
Define these as utilities in `globals.css` per the Luxam reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hover state pattern
|
||||||
|
|
||||||
|
Every card/button uses the same hover vocabulary:
|
||||||
|
|
||||||
|
1. `-translate-y-1` lift (cards) or `-translate-y-0.5` (buttons).
|
||||||
|
2. Border step up: `border-bone/10 → border-gold/30 (or /35 /40)`.
|
||||||
|
3. Background step up: `bg-graphite/40 → bg-graphite/60`.
|
||||||
|
4. Amber radial glow bottom-right: `radial-gradient(circle, rgba(212,164,55,0.28), transparent 70%)` blurred.
|
||||||
|
5. Gold underline sweep (cards): `absolute bottom-0 left-0 h-px w-0 ... group-hover:w-full`.
|
||||||
|
6. Optional: top hairline gold gradient fade-in.
|
||||||
|
7. Icon disc gets `rotate-[8deg]` + amber halo shadow.
|
||||||
|
|
||||||
|
Apply via `group` + `group-hover:` Tailwind variants never per-element JS listeners.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Container widths
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// components/ui/Container.tsx three variants
|
||||||
|
size="default" → max-w-7xl
|
||||||
|
size="wide" → max-w-[1480px]
|
||||||
|
size="narrow" → max-w-3xl
|
||||||
|
```
|
||||||
|
|
||||||
|
All variants: `mx-auto px-6 md:px-10`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When in doubt
|
||||||
|
|
||||||
|
- More dark, less gold.
|
||||||
|
- More space, less content.
|
||||||
|
- Less motion, more restraint.
|
||||||
|
- Real photography > illustration.
|
||||||
|
- Editorial typography > corporate sans.
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# Prompt Templates
|
||||||
|
|
||||||
|
Copy-paste prompts for common tasks. Replace `{{placeholders}}` with project specifics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Create homepage from scratch
|
||||||
|
|
||||||
|
```
|
||||||
|
Act as a senior frontend architect.
|
||||||
|
Build a premium, cinematic, dark/gold luxury homepage for {{BRAND}} ({{INDUSTRY}}) using the design DNA in .claude/skills/premium-frontend-designer/.
|
||||||
|
|
||||||
|
Stack: Next.js 16 App Router · TypeScript · Tailwind v4 · Framer Motion · lucide-react.
|
||||||
|
|
||||||
|
Composition (top → bottom):
|
||||||
|
1. Nav (fixed, scroll-aware)
|
||||||
|
2. Hero (min-h-100svh, parallax halos, 2 CTAs, eyebrow + serif h1 + sub + body)
|
||||||
|
3. BrandStrip (marquee of capability keywords)
|
||||||
|
4. About / Story (split 5/7, copy + visual)
|
||||||
|
5. WhyUCO-style problem→solution comparison
|
||||||
|
6. Services overview (6 cards)
|
||||||
|
7. Trusted Clients marquee (use ONLY logos I provide)
|
||||||
|
8. Stats / Impact metrics (animated counters)
|
||||||
|
9. Cinematic reel (lazy video)
|
||||||
|
10. Process / Closed loop (timeline)
|
||||||
|
11. Industries served
|
||||||
|
12. Certifications
|
||||||
|
13. Gallery (4-image bento)
|
||||||
|
14. Final CTA section
|
||||||
|
15. Footer (4 columns, no duplication)
|
||||||
|
|
||||||
|
Brand details I will provide:
|
||||||
|
{{BRAND_LEGAL_NAME, PHONE, EMAIL, WHATSAPP_URL, INSTAGRAM, APP_STORE, GOOGLE_PLAY, AGENCY_CREDIT}}
|
||||||
|
|
||||||
|
Do NOT invent links. Ask if missing.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Redesign an existing page
|
||||||
|
|
||||||
|
```
|
||||||
|
Inspect {{PAGE_ROUTE}}. Keep the content + intent but rebuild it to match the design DNA in .claude/skills/premium-frontend-designer/.
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
- Reuse existing primitives (Section, Container, Button, Reveal, SectionLabel, etc.) don't duplicate.
|
||||||
|
- Apply the cardGrid + cardCell motion variants for grid entrances.
|
||||||
|
- Add a single gold-accent phrase per h2.
|
||||||
|
- Centralize section padding via the Section primitive (no per-page overrides).
|
||||||
|
- Run typecheck + grep for placeholder hrefs before reporting done.
|
||||||
|
|
||||||
|
Return a clear file change list with NEW / MODIFIED markers.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Add a polished footer
|
||||||
|
|
||||||
|
```
|
||||||
|
Build a 4-column premium footer for {{BRAND}}.
|
||||||
|
|
||||||
|
Columns:
|
||||||
|
1. Brand + contact (logo, tagline, phone, email, WhatsApp, Instagram)
|
||||||
|
2. Navigation (top-level pages)
|
||||||
|
3. Services (specific service items)
|
||||||
|
4. Legal & App (Privacy Policy + 2 store badges)
|
||||||
|
|
||||||
|
No duplicate link labels across columns. External links open in new tab.
|
||||||
|
|
||||||
|
Footer bottom row: copyright + tagline + subtle agency credit (gold underline on hover).
|
||||||
|
|
||||||
|
Watermark: project name in serif, large, opacity-0.025, contained inside footer overflow.
|
||||||
|
|
||||||
|
Brand details:
|
||||||
|
{{PHONE, EMAIL, WHATSAPP_URL, INSTAGRAM_URL, APP_STORE_URL, GOOGLE_PLAY_URL, AGENCY_CREDIT_URL}}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Add WhatsApp button
|
||||||
|
|
||||||
|
```
|
||||||
|
Add a refined WhatsApp button to:
|
||||||
|
1. Footer contact stack (icon + label "WhatsApp · {{DISPLAY_NUMBER}}").
|
||||||
|
2. Nav (circular icon-only button next to primary CTA, mobile menu pill).
|
||||||
|
3. Contact page info card (full-width tile w/ icon disc).
|
||||||
|
|
||||||
|
URL: {{WHATSAPP_API_URL}}
|
||||||
|
Display number: {{+CC XX XXX XXXX}}
|
||||||
|
|
||||||
|
Use inline SVG (lucide has no WhatsApp glyph). Refined gold tint, not bright green. Hover: gold border + soft glow + scale 1.04. aria-label="Chat with {{BRAND}} on WhatsApp". target="_blank" rel="noopener noreferrer".
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Add app store badges
|
||||||
|
|
||||||
|
```
|
||||||
|
Add official App Store + Google Play badge SVGs.
|
||||||
|
|
||||||
|
URLs:
|
||||||
|
- App Store: {{APP_STORE_URL}}
|
||||||
|
- Google Play: {{GOOGLE_PLAY_URL}}
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
1. Download from svgrepo.com (use /show/ path not /download/):
|
||||||
|
- https://www.svgrepo.com/show/303128/download-on-the-app-store-apple-logo.svg
|
||||||
|
- https://www.svgrepo.com/show/303139/google-play-badge-logo.svg
|
||||||
|
2. Save to public/images/app-store-badge.svg + public/images/google-play-badge.svg.
|
||||||
|
3. Render via plain <img> (next/image blocks SVG in Next 16).
|
||||||
|
4. Width: 132 px footer / 140-160 px in hero CTA section.
|
||||||
|
5. Hover: opacity-85 + -translate-y-0.5. No gold circles.
|
||||||
|
6. Stack gap-2 (8 px) in footer.
|
||||||
|
7. aria-label on every anchor. target="_blank" rel="noopener noreferrer".
|
||||||
|
|
||||||
|
Use the snippet from snippets/AppBadges.tsx as a starting point.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Add client logo section
|
||||||
|
|
||||||
|
```
|
||||||
|
Add a premium "Trusted by leading {{INDUSTRY}} partners" client logo marquee to the homepage (after Services) and About page (after process).
|
||||||
|
|
||||||
|
Use ONLY these 12 logos I provide:
|
||||||
|
{{LIST OF LOGO URLS}}
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
1. Download each into public/images/clients/ with clean filenames.
|
||||||
|
2. Create src/lib/clients.ts with typed array { src, alt }.
|
||||||
|
3. Use recognisable brand names for alt text; for unidentified logos use "{{BRAND}} client logo".
|
||||||
|
4. Marquee: 50s linear infinite, hover-pause, mask-image edge fade.
|
||||||
|
5. Tile: h-24 w-44 mobile / h-28 w-52 tablet+. Default grayscale + opacity 70%, hover full color + scale 1.05.
|
||||||
|
6. Reduced-motion fallback: render a static grid-cols-2 sm:grid-cols-3 lg:grid-cols-6.
|
||||||
|
|
||||||
|
Use snippets/ClientLogoMarquee.tsx as the base.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Add certificate gallery
|
||||||
|
|
||||||
|
```
|
||||||
|
Replace the document-card list on /certifications with a 4-image gallery showing the actual certificate pages.
|
||||||
|
|
||||||
|
Use these exact images (download to public/images/certifications/):
|
||||||
|
{{IMG1_URL, IMG2_URL, IMG3_URL, IMG4_URL}}
|
||||||
|
|
||||||
|
Each image links to the corresponding PDF (already in public/documents/certifications/).
|
||||||
|
|
||||||
|
Layout: sm:grid-cols-2 lg:grid-cols-4 with aspect-[3/4] portrait tiles.
|
||||||
|
|
||||||
|
Tile chrome:
|
||||||
|
- Top-left page badge (Page 1 / Page 2 · Annex).
|
||||||
|
- Top-right ArrowUpRight disc.
|
||||||
|
- Bottom caption: mono category in gold + serif title + "Open PDF" CTA.
|
||||||
|
- Hover: scale 1.04, gold ring border, amber radial glow, gold underline sweep.
|
||||||
|
|
||||||
|
External: target="_blank" rel="noopener noreferrer" on every anchor.
|
||||||
|
|
||||||
|
Optional: secondary "Need the full PDFs?" pill row below grid with direct download links.
|
||||||
|
|
||||||
|
Use snippets/CertificateGallery.tsx as a starting point.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Fix mobile responsiveness
|
||||||
|
|
||||||
|
```
|
||||||
|
The {{PAGE}} layout breaks on mobile. Specific issues:
|
||||||
|
{{ISSUES_LIST}}
|
||||||
|
|
||||||
|
Walk through the responsive checklist in .claude/skills/premium-frontend-designer/RESPONSIVE_CHECKLIST.md and fix every applicable item.
|
||||||
|
|
||||||
|
Test at 320 / 375 / 390 / 414 / 768 / 1024 viewports.
|
||||||
|
|
||||||
|
Don't redesign only fix layout, spacing, overflow, clipping. Preserve the visual identity.
|
||||||
|
|
||||||
|
Return a table of issues + fixes (Before / After columns).
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Polish typography sitewide
|
||||||
|
|
||||||
|
```
|
||||||
|
Audit and refine typography across all pages:
|
||||||
|
|
||||||
|
1. Hero h1: clamp(2.4rem, 7vw, 6.4rem), leading-[1.0], tracking-[-0.02em]. Add pt buffer for sticky-nav clearance.
|
||||||
|
2. Two-font system: serif for h1/h2 only, clean sans (Geist/Inter) for everything else.
|
||||||
|
3. Remove font-mono from long values + body text. Keep only on tiny brand-pattern eyebrow tags if at all.
|
||||||
|
4. Tracking: reduce all tracking-[0.32em+] on small labels to tracking-[0.22em].
|
||||||
|
5. Nav links: text-sm font-medium tracking-[0.005em] (slightly larger + medium weight, near-zero tracking).
|
||||||
|
6. BrandStrip marquee: smaller (text-xs md:text-sm uppercase) so it doesn't compete with hero.
|
||||||
|
7. Section labels via SectionLabel primitive never inline.
|
||||||
|
8. text-balance on h1/h2, text-pretty on body paragraphs.
|
||||||
|
|
||||||
|
Confirm:
|
||||||
|
- typecheck clean
|
||||||
|
- HTTP 200 on every route
|
||||||
|
- zero tracking-[0.32em] remaining
|
||||||
|
- no font-mono on user-facing body content
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Add Privacy Policy
|
||||||
|
|
||||||
|
```
|
||||||
|
Create /privacy-policy route matching the premium dark/gold style.
|
||||||
|
|
||||||
|
9 sections with id="..." anchors + scroll-mt-28:
|
||||||
|
- introduction
|
||||||
|
- information-we-collect
|
||||||
|
- how-we-use-information
|
||||||
|
- contact-form-data
|
||||||
|
- cookies-and-analytics
|
||||||
|
- data-protection
|
||||||
|
- third-party-services
|
||||||
|
- user-rights
|
||||||
|
- contact-information
|
||||||
|
|
||||||
|
Use brand contact details from lib/content.ts.
|
||||||
|
|
||||||
|
Effective date stamp at top + metallic-divider. Bottom disclaimer with mailto link in gold underline.
|
||||||
|
|
||||||
|
Single column max-w-3xl. font-display h2 per section. text-pretty body.
|
||||||
|
|
||||||
|
Don't write fake legal claims. General professional language only. Tell users they can contact for privacy questions.
|
||||||
|
|
||||||
|
Add to footer Legal column.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Coolify deploy via Dockerfile
|
||||||
|
|
||||||
|
```
|
||||||
|
Deploy {{PROJECT}} to Coolify with a Dockerfile build pack.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
1. Add `output: "standalone"` to next.config.ts.
|
||||||
|
2. Create multi-stage Dockerfile (node:22-alpine, 3 stages: deps → build → run).
|
||||||
|
3. Add .dockerignore (skip .git, node_modules, .next cache, env files, .claude).
|
||||||
|
4. Run-stage as non-root user (UID 1001).
|
||||||
|
5. EXPOSE 3000, CMD ["node", "server.js"] using standalone output.
|
||||||
|
|
||||||
|
Then via Coolify API:
|
||||||
|
- PATCH application: build_pack="dockerfile", dockerfile_location="/Dockerfile", ports_exposes="3000", domains="https://{{DOMAIN}}"
|
||||||
|
- POST /api/v1/deploy?uuid={{APP_UUID}}&force=true
|
||||||
|
- Poll deployment until terminal status.
|
||||||
|
|
||||||
|
If "no available server" appears on the domain after deploy: verify DNS resolves to Coolify host IP, then redeploy to trigger Let's Encrypt cert issuance.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. General "make this premium" polish
|
||||||
|
|
||||||
|
```
|
||||||
|
Inspect {{COMPONENT_OR_PAGE}}. Identify what doesn't match the premium dark/gold luxury direction in .claude/skills/premium-frontend-designer/DESIGN_SYSTEM.md.
|
||||||
|
|
||||||
|
Specifically check:
|
||||||
|
- Are buttons using premium variants (gold gradient primary, outline secondary)?
|
||||||
|
- Are cards using the hover lift + amber glow + gold underline sweep pattern?
|
||||||
|
- Is there any cheap iconography or stock-photo placeholder?
|
||||||
|
- Are headings using the serif font with one gold-accent phrase?
|
||||||
|
- Is spacing centralized via Section primitive?
|
||||||
|
- Is motion subtle (no bounces, no scale > 1.05 on hover)?
|
||||||
|
|
||||||
|
Apply only the changes needed don't rewrite working sections. Return a list of refinements made.
|
||||||
|
```
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Responsive Checklist
|
||||||
|
|
||||||
|
Run through every item before declaring a page done.
|
||||||
|
|
||||||
|
## Viewports to verify
|
||||||
|
|
||||||
|
- **320 px** (iPhone SE 1st gen, smallest practical)
|
||||||
|
- **375 px** (iPhone SE 2/3, iPhone 13 mini)
|
||||||
|
- **390 px** (iPhone 14/15/16 default)
|
||||||
|
- **414 px** (iPhone Plus)
|
||||||
|
- **768 px** (iPad portrait, `md:` breakpoint)
|
||||||
|
- **1024 px** (iPad landscape / small laptop, `lg:` breakpoint)
|
||||||
|
- **1440 px** (full laptop)
|
||||||
|
|
||||||
|
## Global guards
|
||||||
|
|
||||||
|
- [ ] `body { overflow-x: hidden }` in `globals.css`.
|
||||||
|
- [ ] No `min-w-[...]` on root containers.
|
||||||
|
- [ ] All decorative absolute elements live inside `relative isolate overflow-hidden` parents.
|
||||||
|
- [ ] `max-w-*` containers always paired with `mx-auto px-6 md:px-10`.
|
||||||
|
|
||||||
|
## Header / Nav
|
||||||
|
|
||||||
|
- [ ] Logo left, menu/CTA right on mobile.
|
||||||
|
- [ ] Hamburger button is `size-10` minimum (touch target).
|
||||||
|
- [ ] Mobile menu drawer closes when a link is clicked.
|
||||||
|
- [ ] No sticky-nav overlap on anchored sections `scroll-mt-24 md:scroll-mt-28` on `<Section>`.
|
||||||
|
- [ ] Logo height responsive: `h-11 sm:h-14 md:h-16`.
|
||||||
|
|
||||||
|
## Hero
|
||||||
|
|
||||||
|
- [ ] H1 clamp: `clamp(2.4rem, 7vw, 6.4rem)` never larger upper bound.
|
||||||
|
- [ ] `leading-[1.0]` on serif H1 (not `0.95` clips ascenders).
|
||||||
|
- [ ] Top padding generous: `pt-32 sm:pt-36 md:pt-44` (clears sticky nav + breathing room).
|
||||||
|
- [ ] CTAs use `flex flex-wrap items-center gap-4` so they stack on narrow screens.
|
||||||
|
- [ ] Stats panel: collapse 1-col-tall mobile layouts to a horizontal 3-col compact row.
|
||||||
|
|
||||||
|
## Cards & grids
|
||||||
|
|
||||||
|
- [ ] Mobile: `grid-cols-1`. Tablet: `sm:grid-cols-2`. Desktop: `lg:grid-cols-N` matched to card count.
|
||||||
|
- [ ] Avoid `lg:grid-cols-3` for a 5-card list (leaves orphan) use 5 or 2-row layout matched to count.
|
||||||
|
- [ ] Aspect-ratio used over fixed heights: `aspect-[4/3]` or `aspect-[3/4]`.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
- [ ] No tracking > `0.22em` on small labels.
|
||||||
|
- [ ] `text-balance` on h1/h2.
|
||||||
|
- [ ] `text-pretty` on body paragraphs.
|
||||||
|
- [ ] Long tokens (cert numbers, emails) use `break-all` or `break-words` to wrap.
|
||||||
|
|
||||||
|
## Images
|
||||||
|
|
||||||
|
- [ ] `next/image` with `sizes` attribute matched to layout.
|
||||||
|
- [ ] Hero LCP image gets `priority`.
|
||||||
|
- [ ] `object-cover` only when crop is visually intentional.
|
||||||
|
- [ ] `object-contain` for logos.
|
||||||
|
- [ ] Alt text meaningful never "image" or filename.
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
- [ ] Field grid: `grid gap-5 sm:grid-cols-2` for first/last + email/phone.
|
||||||
|
- [ ] Inputs `text-base` (16 px+) on mobile to prevent iOS zoom-on-focus.
|
||||||
|
- [ ] Submit button respects pending state w/ spinner.
|
||||||
|
- [ ] Field errors render below field, not in a popup.
|
||||||
|
- [ ] Success/error banner renders outside button.
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
- [ ] `useReducedMotion()` checked in every component using motion props.
|
||||||
|
- [ ] Heavy halos / parallax skipped when reduced-motion is on.
|
||||||
|
- [ ] Marquee paused when reduced-motion is on (or replaced with grid).
|
||||||
|
|
||||||
|
## External links
|
||||||
|
|
||||||
|
- [ ] All `<a target="_blank">` carry `rel="noopener noreferrer"`.
|
||||||
|
- [ ] WhatsApp / Instagram / store badges have `aria-label`.
|
||||||
|
- [ ] App badges sized 132 px (footer) / 140-160 px (CTA).
|
||||||
|
|
||||||
|
## Footer
|
||||||
|
|
||||||
|
- [ ] No duplicated link labels across columns.
|
||||||
|
- [ ] Privacy Policy only in Legal column.
|
||||||
|
- [ ] Agency credit row sits below copyright, subtle.
|
||||||
|
- [ ] Watermark contained via `overflow-hidden` on footer root.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- [ ] Every interactive element keyboard-reachable.
|
||||||
|
- [ ] `focus-visible:` rings on buttons, inputs, anchors.
|
||||||
|
- [ ] Modal/lightbox: scroll-locked body + `Escape` closes + arrows navigate.
|
||||||
|
- [ ] Color contrast checked (`text-bone` on obsidian = 16:1).
|
||||||
|
|
||||||
|
## Final smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tsc --noEmit # clean
|
||||||
|
curl -s -o NUL -w "%{http_code}" http://localhost:3000/... # 200 on every route
|
||||||
|
```
|
||||||
|
|
||||||
|
Grep for placeholder leaks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn 'href="#"' src/
|
||||||
|
grep -rn 'href=""' src/
|
||||||
|
grep -rn 'href="/#' src/
|
||||||
|
```
|
||||||
|
|
||||||
|
Should all return zero (unless intentional same-page anchors with matching IDs).
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
name: premium-frontend-designer
|
||||||
|
description: Build, redesign, and polish premium dark/gold luxury websites with React/Next.js/Tailwind. Use when the user asks to create a website, redesign a page, polish a section, fix mobile responsiveness, add premium components (hero, footer, CTA, client logos, certificates, app badges, WhatsApp button), or apply the Luxam-style luxury design DNA to another project.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Premium Frontend Designer & Implementation Agent
|
||||||
|
|
||||||
|
You are a senior frontend architect + UI/UX designer + implementation engineer.
|
||||||
|
|
||||||
|
Your job: create production-ready, premium, cinematic, dark/gold luxury websites that feel high-end, trustworthy, and conversion-focused the way the Luxam reference site does.
|
||||||
|
|
||||||
|
This skill captures the design DNA, component patterns, prompt templates, and snippets so any future project can match the same quality bar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When to use this skill
|
||||||
|
|
||||||
|
Auto-trigger whenever the user:
|
||||||
|
- asks for a "premium", "luxury", "cinematic", "dark/gold", or "Luxam-style" design
|
||||||
|
- creates or redesigns a page (homepage, about, services, contact, gallery, certifications, purchase-sale)
|
||||||
|
- polishes typography, spacing, or motion
|
||||||
|
- fixes mobile responsiveness
|
||||||
|
- adds: WhatsApp link, app store buttons, client logos, certificate gallery, premium hero, premium footer
|
||||||
|
- imports brand contact details into a layout
|
||||||
|
- wants reusable React/Tailwind components in the Luxam style
|
||||||
|
|
||||||
|
Refuse silently if the project explicitly uses a different design language (e.g. bright corporate, neobrutalist, retro 8-bit). In that case ask before applying this skill's DNA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Visual principles
|
||||||
|
|
||||||
|
1. **Dark cinematic background** obsidian-class blacks (`#08080A`), graphite layering (`#15161A` / `#1C1D22`). Never pure white. Never bright primary colors as backgrounds.
|
||||||
|
2. **Gold/amber accents** restrained, structural, single-tone gradient. Use on numbers, labels, dividers, hover states, hero highlights. Never bright yellow. Never multi-color rainbow gradients.
|
||||||
|
3. **Editorial typography pairing**
|
||||||
|
- Serif (Cormorant Garamond / Playfair / Libre Baskerville) for hero H1 + section H2 only.
|
||||||
|
- Sans (Geist / Inter / Manrope / Satoshi) for nav, body, buttons, labels, metadata.
|
||||||
|
- No monospace on user-facing copy or long values.
|
||||||
|
4. **Glass + grain** semi-transparent `bg-bone/[0.03]` cards with `backdrop-blur`. Subtle noise/grain overlays to break flat surfaces.
|
||||||
|
5. **Cinematic motion** Framer Motion: slow, restrained. `cubic-bezier(0.16, 1, 0.3, 1)`, 0.8-1.2s entrance, `useReducedMotion()` respected.
|
||||||
|
6. **Responsive from the start** every layout works at 320 / 375 / 768 / 1024 / 1440 viewports.
|
||||||
|
7. **No corporate basic** no boxy stock layouts, no flat colorful cards, no generic stock-photo hero, no cheap icon dumps.
|
||||||
|
|
||||||
|
Read `DESIGN_SYSTEM.md` for the full token palette + typography clamps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coding principles
|
||||||
|
|
||||||
|
1. **Inspect before changing.** Always read the existing files, lockfile, framework version, and existing primitives. Reuse don't duplicate.
|
||||||
|
2. **Preserve project identity.** Don't rewrite the whole brand for a small request.
|
||||||
|
3. **Real content only.** Never invent links, social handles, certifications, addresses, phone numbers, or legal claims. Ask if missing.
|
||||||
|
4. **External links** carry `target="_blank"` + `rel="noopener noreferrer"`.
|
||||||
|
5. **Reusable primitives first.** Look for existing `Section`, `Container`, `Button`, `Reveal`, `SectionLabel`, `GlassCard` before writing new ones.
|
||||||
|
6. **Clean imports.** Remove unused icons / helpers after edits.
|
||||||
|
7. **One source of truth** for spacing push to a shared `Section` primitive instead of overriding per page.
|
||||||
|
8. **Typecheck after every change.** `npx tsc --noEmit` must pass.
|
||||||
|
9. **No backwards-compat shims.** Don't leave `// removed` comments, dead aliases, or "for future" stubs.
|
||||||
|
10. **No commented-out code** in committed work.
|
||||||
|
11. **No emojis** in code or files unless user asks.
|
||||||
|
|
||||||
|
Read `COMPONENT_RULES.md` for per-element rules (buttons, cards, footer, forms, icons).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accessibility requirements
|
||||||
|
|
||||||
|
- All anchors have meaningful text or `aria-label`.
|
||||||
|
- All interactive elements reachable by keyboard.
|
||||||
|
- Focus-visible states: `focus-visible:border-gold/60 focus-visible:ring-2 focus-visible:ring-gold/40`.
|
||||||
|
- Modal/lightbox: `role="dialog" aria-modal="true"`, `Escape` closes, scroll-lock on body.
|
||||||
|
- Decorative SVGs: `aria-hidden`.
|
||||||
|
- Color contrast: body text `text-bone` (≈ #F4F0E6) on obsidian = 16:1 ratio, well above WCAG AA.
|
||||||
|
- `prefers-reduced-motion` respected via Framer's `useReducedMotion()` disable transforms/parallax, keep opacity fades.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive requirements
|
||||||
|
|
||||||
|
Test mentally (or in browser) at: **320, 375, 390, 414, 768, 1024, 1440**.
|
||||||
|
|
||||||
|
- Hero h1 clamp: `clamp(2.4rem, 7vw, 6.4rem)` with `leading-[1.0]`.
|
||||||
|
- Section padding: `py-16 md:py-20 lg:py-24` (uniform).
|
||||||
|
- Anchored sections: `scroll-mt-24 md:scroll-mt-28` for sticky-nav clearance.
|
||||||
|
- `body { overflow-x: hidden }` in `globals.css` as a global guard.
|
||||||
|
- Sticky decorations (halos, watermarks, blurs) must not introduce horizontal scroll use `overflow-hidden` on parent + `translate` instead of fixed widths.
|
||||||
|
- Mobile cards stack `grid-cols-1 sm:grid-cols-2 lg:grid-cols-N`.
|
||||||
|
|
||||||
|
Read `RESPONSIVE_CHECKLIST.md` before shipping any page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output requirements
|
||||||
|
|
||||||
|
Every meaningful change returns:
|
||||||
|
|
||||||
|
1. **Files changed** explicit list with `NEW` / `MODIFIED` / `DELETED` markers.
|
||||||
|
2. **What was kept** vs **what was replaced** when redesigning.
|
||||||
|
3. **Receipts** `HTTP 200`, typecheck clean, key content rendered (grep output).
|
||||||
|
4. **Responsive breakdown** desktop / tablet / mobile behavior.
|
||||||
|
5. **Confirmations** explicit yes/no on every checklist item the user asked.
|
||||||
|
6. **No placeholder leaks** confirm zero `href="#"`, `href=""`, `href="/#"` remain in modified files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## QA checklist (run before reporting done)
|
||||||
|
|
||||||
|
```
|
||||||
|
[ ] npx tsc --noEmit clean
|
||||||
|
[ ] dev server HTTP 200 on every modified route
|
||||||
|
[ ] grep zero placeholder hrefs in modified files
|
||||||
|
[ ] grep zero hardcoded brand values that should be in lib/content.ts
|
||||||
|
[ ] mobile (<640px) no horizontal overflow, hero not clipped, CTAs not cut
|
||||||
|
[ ] tablet (≥640 <1024) grids transition cleanly to 2-col
|
||||||
|
[ ] desktop (≥1024) spacing balanced, no orphan cards
|
||||||
|
[ ] external links target="_blank" + rel="noopener noreferrer"
|
||||||
|
[ ] focus-visible visible on every button/anchor
|
||||||
|
[ ] reduced-motion heavy parallax/scale animations disabled
|
||||||
|
[ ] no leftover console.log or commented-out code
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brand-specific config (Luxam reference)
|
||||||
|
|
||||||
|
`examples/luxam-config.md` holds the exact Luxam contact details, WhatsApp URL, app store links, agency credit, etc. **Do not copy these values into other projects** use them only as the shape pattern. Each new project gets its own `lib/content.ts` populated from real values the user provides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map of this skill
|
||||||
|
|
||||||
|
```
|
||||||
|
SKILL.md this file (entry point, principles, when to use)
|
||||||
|
DESIGN_SYSTEM.md color tokens, typography, spacing scale, motion
|
||||||
|
COMPONENT_RULES.md per-component rules (buttons, cards, footer, forms, icons)
|
||||||
|
RESPONSIVE_CHECKLIST.md pre-ship checklist for every viewport
|
||||||
|
PROMPT_TEMPLATES.md copy-paste prompts for common tasks
|
||||||
|
COMPONENT_SNIPPETS.md high-level component overview
|
||||||
|
snippets/ 8 reusable TSX component files
|
||||||
|
examples/luxam-config.md Luxam-specific values (reference, not boilerplate)
|
||||||
|
```
|
||||||
|
|
||||||
|
Read the relevant section(s) for the task at hand. Don't dump the whole skill into the user's reply surface only the rules + snippets relevant to the change being made.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Luxam Reference Config
|
||||||
|
|
||||||
|
Concrete values used in the Luxam project. **Reference only** do not paste into other projects unless the user explicitly says these are also their values.
|
||||||
|
|
||||||
|
## `src/lib/content.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const brand = {
|
||||||
|
name: "LUXAM",
|
||||||
|
legalName: "Luxam Oils Trading LLC",
|
||||||
|
tagline: "Sustainable Energy. Refined.",
|
||||||
|
promise: "Converting waste into the fuel of tomorrow.",
|
||||||
|
phone: "04 262 3314",
|
||||||
|
email: "info@luxam.ae",
|
||||||
|
location: "United Arab Emirates",
|
||||||
|
instagram: "@luxamtrading",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nav = [
|
||||||
|
{ label: "Home", href: "/" },
|
||||||
|
{ label: "About us", href: "/about-us" },
|
||||||
|
{ label: "Services", href: "/services" },
|
||||||
|
{ label: "Purchase & Sale", href: "/purchase-sale" },
|
||||||
|
{ label: "Gallery", href: "/gallery" },
|
||||||
|
{ label: "Certifications", href: "/certifications" },
|
||||||
|
{ label: "Contact us", href: "/contact-us" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const socials = {
|
||||||
|
instagram: "https://www.instagram.com/luxamtrading/",
|
||||||
|
whatsapp:
|
||||||
|
"https://api.whatsapp.com/send/?phone=971527312190&text&type=phone_number&app_absent=0",
|
||||||
|
whatsappDisplay: "+971 52 731 2190",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const app = {
|
||||||
|
googlePlay: "https://play.google.com/store/apps/details?id=com.lootah.luxam",
|
||||||
|
appStore: "https://apps.apple.com/ae/app/luxam/id6740183015",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const credit = {
|
||||||
|
agency: "YS Lootah Tech",
|
||||||
|
url: "https://yslootahtech.com/",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Footer link cluster
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const navigationLinks = [
|
||||||
|
{ label: "Home", href: "/" },
|
||||||
|
{ label: "About us", href: "/about-us" },
|
||||||
|
{ label: "Services", href: "/services" },
|
||||||
|
{ label: "Purchase & Sale", href: "/purchase-sale" },
|
||||||
|
{ label: "Gallery", href: "/gallery" },
|
||||||
|
{ label: "Certifications", href: "/certifications" },
|
||||||
|
{ label: "Contact us", href: "/contact-us" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const serviceItems = [
|
||||||
|
{ label: "UCO Collection", href: "/services" },
|
||||||
|
{ label: "Logistics & Handling", href: "/services" },
|
||||||
|
{ label: "Recycling & Feedstock", href: "/services" },
|
||||||
|
{ label: "Compliance & Documentation", href: "/certifications" },
|
||||||
|
{ label: "Partner with us", href: "/contact-us" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const legalLinks = [{ label: "Privacy Policy", href: "/privacy-policy" }];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment (Coolify)
|
||||||
|
|
||||||
|
```
|
||||||
|
Coolify project luxam (uuid h0840c84kgo0g4cgwg0os88c)
|
||||||
|
env production uuid s4ow44cwgo08g88g04gwkkcs
|
||||||
|
Application uuid zc4s4k4k0c44c8cs8oc8so4k
|
||||||
|
build_pack dockerfile
|
||||||
|
dockerfile_location /Dockerfile
|
||||||
|
ports_exposes 3000
|
||||||
|
fqdn https://luxamnew.yslootahtech.com
|
||||||
|
DNS A record luxamnew → 51.255.193.254 (DNS only)
|
||||||
|
```
|
||||||
|
|
||||||
|
Git remote:
|
||||||
|
|
||||||
|
```
|
||||||
|
origin https://git.devxsupport.com/mohammad/luxam-web.git (main)
|
||||||
|
```
|
||||||
|
|
||||||
|
Token policy: never commit. Use `COOLIFY_TOKEN` env var when scripting deploys.
|
||||||
|
|
||||||
|
## Dockerfile pattern
|
||||||
|
|
||||||
|
Multi-stage:
|
||||||
|
1. `deps` (node:22-alpine, `apk add libc6-compat`, `npm ci`)
|
||||||
|
2. `build` (`next build` w/ `output: "standalone"`)
|
||||||
|
3. `run` (non-root UID 1001, copies `.next/standalone` + `.next/static` + `public`, `CMD ["node","server.js"]`)
|
||||||
|
|
||||||
|
`.dockerignore` skips `.git`, `node_modules`, `.next/cache`, `.env*`, `.claude`.
|
||||||
|
|
||||||
|
## ISCC certificate facts (Luxam-specific)
|
||||||
|
|
||||||
|
Use only as a precedent for how to surface certificate metadata. Do not copy into other projects.
|
||||||
|
|
||||||
|
```
|
||||||
|
Certificate number EU-ISCC-Cert-DE100-27002125
|
||||||
|
Standard ISCC EU
|
||||||
|
Framework RED III (Directive (EU) 2023/2413)
|
||||||
|
Issuer SGS Germany GmbH (Europa Allee 12, D-49685 Emstek, Germany)
|
||||||
|
Certified entity Luxam Oils Trading LLC
|
||||||
|
Site Al Ttay – Al Khawaneej, Dubai, UAE
|
||||||
|
Scope Collecting point, warehouse
|
||||||
|
Material UCO (entirely of vegetable origin)
|
||||||
|
Valid from 11 August 2025
|
||||||
|
Valid to 10 August 2026
|
||||||
|
```
|
||||||
|
|
||||||
|
For any new project that surfaces compliance docs, follow the same data shape: number, standard, issuer, certified entity, site, scope, material, validity dates. Pull only from official documents never invent.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Official App Store + Google Play badge anchors.
|
||||||
|
*
|
||||||
|
* Setup steps:
|
||||||
|
* 1. Download official SVGs (use the /show/ path which returns clean SVG):
|
||||||
|
* https://www.svgrepo.com/show/303128/download-on-the-app-store-apple-logo.svg
|
||||||
|
* https://www.svgrepo.com/show/303139/google-play-badge-logo.svg
|
||||||
|
* 2. Save to public/images/app-store-badge.svg + public/images/google-play-badge.svg.
|
||||||
|
* 3. Add to lib/content.ts:
|
||||||
|
* export const app = {
|
||||||
|
* googlePlay: "https://play.google.com/store/apps/details?id=...",
|
||||||
|
* appStore: "https://apps.apple.com/ae/app/.../id...",
|
||||||
|
* };
|
||||||
|
*
|
||||||
|
* next/image blocks raw SVG by default in Next 16 keep plain <img>.
|
||||||
|
*/
|
||||||
|
import { app } from "@/lib/content";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
store: "appstore" | "googleplay";
|
||||||
|
small?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppBadge({ store, small }: Props) {
|
||||||
|
const isApple = store === "appstore";
|
||||||
|
const href = isApple ? app.appStore : app.googlePlay;
|
||||||
|
const src = isApple
|
||||||
|
? "/images/app-store-badge.svg"
|
||||||
|
: "/images/google-play-badge.svg";
|
||||||
|
const alt = isApple ? "Download on the App Store" : "Get it on Google Play";
|
||||||
|
const label = isApple
|
||||||
|
? "Download on the App Store"
|
||||||
|
: "Get it on Google Play";
|
||||||
|
const width = small ? 132 : 160;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={label}
|
||||||
|
className="group inline-flex transition duration-300 hover:-translate-y-0.5 hover:opacity-85"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
width={width}
|
||||||
|
height={small ? 40 : 48}
|
||||||
|
className={
|
||||||
|
small
|
||||||
|
? "h-auto w-[132px] max-w-full select-none"
|
||||||
|
: "h-auto w-[140px] max-w-full select-none sm:w-[160px]"
|
||||||
|
}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both badges in a vertical stack. Use in footer Legal column. */
|
||||||
|
export function AppBadgeStack({ small }: { small?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<AppBadge store="appstore" small={small} />
|
||||||
|
<AppBadge store="googleplay" small={small} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4-image certificate gallery each tile links to a PDF in a new tab.
|
||||||
|
*
|
||||||
|
* Expected data shape:
|
||||||
|
* export type CertPage = {
|
||||||
|
* src: string; // /images/certifications/page.jpg
|
||||||
|
* alt: string;
|
||||||
|
* title: string; // ISCC Certificate 2025
|
||||||
|
* page: string; // Page 1 · Annex
|
||||||
|
* document: string; // ISCC Certificate
|
||||||
|
* href: string; // /documents/certifications/iscc.pdf OR upstream URL
|
||||||
|
* };
|
||||||
|
* export const certificatePages: CertPage[] = [ ... ];
|
||||||
|
*/
|
||||||
|
import Image from "next/image";
|
||||||
|
import { ArrowUpRight, FileText } from "lucide-react";
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
|
|
||||||
|
export type CertPage = {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
title: string;
|
||||||
|
page: string;
|
||||||
|
document: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const grid: Variants = {
|
||||||
|
hidden: {},
|
||||||
|
visible: { transition: { staggerChildren: 0.08, delayChildren: 0.15 } },
|
||||||
|
};
|
||||||
|
const cell: Variants = {
|
||||||
|
hidden: { opacity: 0, y: 24, filter: "blur(6px)" },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CertificateGallery({ pages }: { pages: CertPage[] }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={grid}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, amount: 0.1 }}
|
||||||
|
className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4 lg:gap-6"
|
||||||
|
>
|
||||||
|
{pages.map((p) => (
|
||||||
|
<motion.div key={p.src} variants={cell}>
|
||||||
|
<CertCard {...p} />
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CertCard({ src, alt, title, page, document, href }: CertPage) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`${title} ${page}. Opens PDF in a new tab.`}
|
||||||
|
className="group relative isolate flex h-full flex-col overflow-hidden rounded-2xl border border-bone/10 bg-graphite/40 backdrop-blur-sm transition-all duration-500 hover:-translate-y-1 hover:border-gold/35 hover:bg-graphite/60 focus:outline-none focus-visible:border-gold/60 focus-visible:ring-2 focus-visible:ring-gold/40"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[3/4] w-full overflow-hidden bg-obsidian">
|
||||||
|
<Image
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
|
||||||
|
className="object-cover object-top transition-transform duration-[1200ms] ease-[cubic-bezier(0.16,1,0.3,1)] group-hover:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-obsidian/70 via-obsidian/20 to-transparent"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-obsidian/80 via-obsidian/30 to-transparent"
|
||||||
|
/>
|
||||||
|
<span className="absolute left-3 top-3 inline-flex items-center gap-2 rounded-full border border-gold/30 bg-obsidian/70 px-3 py-1 font-mono text-[10px] uppercase tracking-[0.22em] text-gold-light backdrop-blur">
|
||||||
|
<span className="size-1 rounded-full bg-gold-light shadow-[0_0_8px_rgba(242,194,91,0.8)]" />
|
||||||
|
{page}
|
||||||
|
</span>
|
||||||
|
<span className="absolute right-3 top-3 grid size-9 place-items-center rounded-full border border-bone/20 bg-obsidian/70 text-mist backdrop-blur transition-all duration-500 group-hover:-translate-y-0.5 group-hover:border-gold/50 group-hover:text-gold-light">
|
||||||
|
<ArrowUpRight className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -bottom-16 -right-16 size-48 rounded-full opacity-0 blur-3xl transition-opacity duration-700 group-hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(212,164,55,0.32), transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 rounded-t-2xl border border-transparent transition-colors duration-500 group-hover:border-gold/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex flex-col gap-2 p-5 md:p-6">
|
||||||
|
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-gold-light">
|
||||||
|
{document}
|
||||||
|
</span>
|
||||||
|
<h4 className="font-display text-lg text-bone text-balance transition-colors duration-500 group-hover:text-gold-light md:text-xl">
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<div className="mt-1 inline-flex items-center gap-2 text-xs text-mist">
|
||||||
|
<FileText className="size-3.5 text-gold-light" />
|
||||||
|
<span>Open PDF</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute bottom-0 left-0 h-px w-0 bg-gradient-to-r from-gold via-gold-light to-transparent transition-all duration-700 group-hover:w-full"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Premium horizontal logo marquee.
|
||||||
|
*
|
||||||
|
* Setup:
|
||||||
|
* 1. globals.css must have:
|
||||||
|
* @keyframes marquee {
|
||||||
|
* 0% { transform: translateX(0); }
|
||||||
|
* 100% { transform: translateX(-50%); }
|
||||||
|
* }
|
||||||
|
* 2. Pass clients prop or create lib/clients.ts:
|
||||||
|
* export const clients = [
|
||||||
|
* { src: "/images/clients/brand-a.png", alt: "Brand A" },
|
||||||
|
* ...
|
||||||
|
* ];
|
||||||
|
*/
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useReducedMotion } from "framer-motion";
|
||||||
|
|
||||||
|
export type Client = { src: string; alt: string };
|
||||||
|
|
||||||
|
export function ClientLogoMarquee({
|
||||||
|
clients,
|
||||||
|
eyebrow = "Our Clients",
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: {
|
||||||
|
clients: Client[];
|
||||||
|
eyebrow?: string;
|
||||||
|
title: React.ReactNode;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const loop = [...clients, ...clients];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative overflow-hidden py-16 md:py-20 lg:py-24">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -left-32 top-1/3 size-[480px] rounded-full radial-gold blur-3xl opacity-30"
|
||||||
|
/>
|
||||||
|
<div className="mx-auto max-w-7xl px-6 md:px-10">
|
||||||
|
<div className="flex flex-col items-center gap-6 text-center">
|
||||||
|
<div className="flex items-center gap-3 text-[11px] font-medium uppercase tracking-[0.22em] text-mist">
|
||||||
|
<span className="h-px w-8 bg-gold/40" />
|
||||||
|
<span>{eyebrow}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="font-display text-4xl leading-[1.04] text-bone text-balance md:text-5xl">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{subtitle ? (
|
||||||
|
<p className="max-w-2xl text-pretty text-mist md:text-lg">{subtitle}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mt-12 md:mt-14">
|
||||||
|
{/* edge fades */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 z-10 w-16 bg-gradient-to-r from-obsidian to-transparent md:w-32"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-y-0 right-0 z-10 w-16 bg-gradient-to-l from-obsidian to-transparent md:w-32"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="flex overflow-hidden"
|
||||||
|
style={{
|
||||||
|
maskImage:
|
||||||
|
"linear-gradient(90deg, transparent, black 8%, black 92%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{reduce ? (
|
||||||
|
<ClientGrid clients={clients} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 gap-4 pr-4 md:gap-6 md:pr-6"
|
||||||
|
style={{
|
||||||
|
animation: "marquee 50s linear infinite",
|
||||||
|
animationPlayState: "running",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) =>
|
||||||
|
(e.currentTarget.style.animationPlayState = "paused")
|
||||||
|
}
|
||||||
|
onMouseLeave={(e) =>
|
||||||
|
(e.currentTarget.style.animationPlayState = "running")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loop.map((c, i) => (
|
||||||
|
<LogoTile key={`${c.src}-${i}`} client={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogoTile({ client }: { client: Client }) {
|
||||||
|
return (
|
||||||
|
<div className="group/tile relative grid h-24 w-44 shrink-0 place-items-center overflow-hidden rounded-2xl border border-bone/10 bg-graphite/40 px-5 backdrop-blur-sm transition-all duration-500 hover:border-gold/30 hover:bg-graphite/60 md:h-28 md:w-52">
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-700 group-hover/tile:opacity-100"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(60% 80% at 50% 50%, rgba(212,164,55,0.18), transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
src={client.src}
|
||||||
|
alt={client.alt}
|
||||||
|
width={200}
|
||||||
|
height={100}
|
||||||
|
className="relative max-h-14 w-auto object-contain opacity-70 brightness-[1.05] contrast-[0.9] grayscale transition-all duration-500 group-hover/tile:scale-105 group-hover/tile:opacity-100 group-hover/tile:grayscale-0 md:max-h-16"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientGrid({ clients }: { clients: Client[] }) {
|
||||||
|
return (
|
||||||
|
<div className="grid w-full grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{clients.map((c) => (
|
||||||
|
<LogoTile key={c.src} client={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/**
|
||||||
|
* Premium 4-column footer w/ no duplicated nav links.
|
||||||
|
*
|
||||||
|
* Expected lib/content.ts shape:
|
||||||
|
*
|
||||||
|
* export const brand = {
|
||||||
|
* name: "BRAND",
|
||||||
|
* legalName: "Brand Legal LLC",
|
||||||
|
* phone: "00 000 0000",
|
||||||
|
* email: "info@example.com",
|
||||||
|
* instagram: "@brand_handle",
|
||||||
|
* };
|
||||||
|
* export const socials = {
|
||||||
|
* instagram: "https://www.instagram.com/brand_handle/",
|
||||||
|
* whatsapp: "https://api.whatsapp.com/send/?phone=000000000000&text&type=phone_number&app_absent=0",
|
||||||
|
* whatsappDisplay: "+00 00 000 0000",
|
||||||
|
* };
|
||||||
|
* export const credit = {
|
||||||
|
* agency: "YS Lootah Tech",
|
||||||
|
* url: "https://yslootahtech.com/",
|
||||||
|
* };
|
||||||
|
*/
|
||||||
|
import { Mail, Phone } from "lucide-react";
|
||||||
|
import { brand, socials, credit } from "@/lib/content";
|
||||||
|
import { WhatsAppLink } from "./WhatsAppLink";
|
||||||
|
import { AppBadge } from "./AppBadges";
|
||||||
|
|
||||||
|
function InstagramIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="5" />
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<circle cx="17.5" cy="6.5" r="1.1" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Link = { label: string; href: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
navigationLinks: Link[];
|
||||||
|
serviceItems: Link[];
|
||||||
|
legalLinks: Link[];
|
||||||
|
/** Brand logo node (provide your own LogoMark) */
|
||||||
|
logo?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Footer({ navigationLinks, serviceItems, legalLinks, logo }: Props) {
|
||||||
|
return (
|
||||||
|
<footer className="relative overflow-hidden border-t border-bone/10 bg-obsidian pb-8 pt-11 md:pb-10 md:pt-14 lg:pb-12 lg:pt-[72px]">
|
||||||
|
<div className="relative z-10 mx-auto max-w-[1480px] px-6 md:px-10">
|
||||||
|
<div className="grid gap-10 lg:grid-cols-12 lg:gap-12">
|
||||||
|
{/* Col 1 Brand + contact */}
|
||||||
|
<div className="space-y-5 lg:col-span-4">
|
||||||
|
<a href="/" className="inline-flex items-center gap-4">
|
||||||
|
{logo}
|
||||||
|
<span className="hidden font-display text-2xl tracking-[0.22em] text-bone sm:inline">
|
||||||
|
{brand.name}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<p className="max-w-md text-sm text-mist text-pretty md:text-base">
|
||||||
|
{brand.legalName}.
|
||||||
|
</p>
|
||||||
|
<div className="metallic-divider" />
|
||||||
|
<div className="space-y-2 text-sm text-mist">
|
||||||
|
<a
|
||||||
|
href={`tel:${brand.phone.replace(/\s/g, "")}`}
|
||||||
|
className="flex items-center gap-3 hover:text-gold-light"
|
||||||
|
>
|
||||||
|
<Phone className="size-4" />
|
||||||
|
{brand.phone}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={`mailto:${brand.email}`}
|
||||||
|
className="flex items-center gap-3 hover:text-gold-light"
|
||||||
|
>
|
||||||
|
<Mail className="size-4" />
|
||||||
|
{brand.email}
|
||||||
|
</a>
|
||||||
|
<WhatsAppLink
|
||||||
|
href={socials.whatsapp}
|
||||||
|
display={socials.whatsappDisplay}
|
||||||
|
ariaLabel={`Chat with ${brand.name} on WhatsApp`}
|
||||||
|
inline
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={socials.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 hover:text-gold-light"
|
||||||
|
>
|
||||||
|
<InstagramIcon className="size-4" />
|
||||||
|
Instagram · {brand.instagram}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cols 2-4 */}
|
||||||
|
<div className="grid gap-8 sm:grid-cols-3 lg:col-span-8 lg:gap-10">
|
||||||
|
<FooterCol title="Navigation" links={navigationLinks} />
|
||||||
|
<FooterCol title="Services" links={serviceItems} />
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-medium uppercase tracking-[0.22em] text-gold-light">
|
||||||
|
Legal & App
|
||||||
|
</h4>
|
||||||
|
<ul className="mt-4 space-y-2.5">
|
||||||
|
{legalLinks.map((l) => (
|
||||||
|
<li key={l.label}>
|
||||||
|
<a
|
||||||
|
href={l.href}
|
||||||
|
className="text-sm text-mist transition-colors hover:text-bone"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-5 flex flex-col items-start gap-2">
|
||||||
|
<AppBadge store="appstore" small />
|
||||||
|
<AppBadge store="googleplay" small />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-9 flex flex-col items-start justify-between gap-3 border-t border-bone/10 pt-5 text-xs uppercase tracking-[0.22em] text-mist md:mt-10 md:flex-row md:items-center md:gap-6">
|
||||||
|
<span>
|
||||||
|
© {new Date().getFullYear()} {brand.legalName}. All rights reserved.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 text-[11px] tracking-[0.04em] text-mist/60 md:text-xs">
|
||||||
|
Website designed & developed by{" "}
|
||||||
|
<a
|
||||||
|
href={credit.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-mist/80 underline decoration-gold/30 decoration-1 underline-offset-4 transition-colors duration-500 hover:text-gold-light hover:decoration-gold/70"
|
||||||
|
>
|
||||||
|
{credit.agency}
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Watermark contained inside footer */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 z-0 flex select-none justify-center overflow-hidden"
|
||||||
|
>
|
||||||
|
<span className="translate-y-[18%] font-display text-[14vw] leading-none tracking-[-0.02em] text-bone/[0.025] md:text-[12vw]">
|
||||||
|
{brand.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FooterCol({ title, links }: { title: string; links: Link[] }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-[10px] font-medium uppercase tracking-[0.22em] text-gold-light">
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<ul className="mt-4 space-y-2.5">
|
||||||
|
{links.map((l) => (
|
||||||
|
<li key={l.label}>
|
||||||
|
<a
|
||||||
|
href={l.href}
|
||||||
|
className="text-sm text-mist transition-colors hover:text-bone"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
Icon?: LucideIcon;
|
||||||
|
title: ReactNode;
|
||||||
|
body?: ReactNode;
|
||||||
|
href?: string;
|
||||||
|
external?: boolean;
|
||||||
|
className?: string;
|
||||||
|
/** Append custom inner content below the body */
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function GlassCard({
|
||||||
|
Icon,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
href,
|
||||||
|
external,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: Props) {
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
{/* hover amber radial glow */}
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute -bottom-16 -right-16 size-48 rounded-full opacity-0 blur-3xl transition-opacity duration-700 group-hover:opacity-100"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle, rgba(212,164,55,0.28), transparent 70%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Icon ? (
|
||||||
|
<div className="grid size-11 place-items-center rounded-full glass-gold transition-all duration-500 group-hover:rotate-[8deg] group-hover:shadow-[0_0_22px_-6px_rgba(212,164,55,0.6)]">
|
||||||
|
<Icon className="size-[18px] text-gold-light" strokeWidth={1.4} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<h4 className="mt-5 font-display text-xl text-bone transition-colors duration-500 group-hover:text-gold-light md:text-2xl">
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
{body ? (
|
||||||
|
<p className="mt-2 text-sm text-mist text-pretty leading-relaxed">
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
{/* gold underline sweep */}
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute bottom-0 left-0 h-px w-0 bg-gradient-to-r from-gold via-gold-light to-transparent transition-all duration-700 group-hover:w-full"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const base = `group relative isolate h-full overflow-hidden rounded-2xl border border-bone/10 bg-graphite/40 p-6 backdrop-blur-sm transition-all duration-500 hover:-translate-y-1 hover:border-gold/30 hover:bg-graphite/60 md:p-7 ${className ?? ""}`;
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
{...(external
|
||||||
|
? { target: "_blank", rel: "noopener noreferrer" }
|
||||||
|
: {})}
|
||||||
|
className={base}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div className={base}>{inner}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
import type { ComponentPropsWithoutRef } from "react";
|
||||||
|
|
||||||
|
type Variant = "primary" | "ghost" | "outline";
|
||||||
|
|
||||||
|
type Props = ComponentPropsWithoutRef<"a"> & {
|
||||||
|
variant?: Variant;
|
||||||
|
arrow?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const base =
|
||||||
|
"relative inline-flex items-center gap-2 rounded-full px-7 py-3.5 text-sm font-medium tracking-wide transition-all duration-500";
|
||||||
|
|
||||||
|
const variants: Record<Variant, string> = {
|
||||||
|
primary:
|
||||||
|
"bg-gradient-to-r from-gold-light via-gold to-gold-deep text-obsidian shadow-[0_10px_40px_-12px_rgba(212,164,55,0.6)] hover:shadow-[0_18px_50px_-12px_rgba(242,194,91,0.8)]",
|
||||||
|
outline:
|
||||||
|
"border border-bone/20 text-bone hover:border-gold/60 hover:text-gold-light",
|
||||||
|
ghost: "text-bone hover:text-gold-light",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PremiumButton({
|
||||||
|
variant = "primary",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
arrow = true,
|
||||||
|
...rest
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<a className={cn(base, variants[variant], className)} {...rest}>
|
||||||
|
<span>{children}</span>
|
||||||
|
{arrow ? (
|
||||||
|
<ArrowUpRight className="size-4 transition-transform duration-500 group-hover:translate-x-0.5" />
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
eyebrow?: string;
|
||||||
|
index?: string;
|
||||||
|
title: ReactNode;
|
||||||
|
/** The gold-accent phrase rendered inside the title pass as JSX or separate prop. */
|
||||||
|
accent?: ReactNode;
|
||||||
|
subtitle?: ReactNode;
|
||||||
|
align?: "left" | "center";
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SectionHeading({
|
||||||
|
eyebrow,
|
||||||
|
index,
|
||||||
|
title,
|
||||||
|
accent,
|
||||||
|
subtitle,
|
||||||
|
align = "left",
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const reduce = useReducedMotion();
|
||||||
|
const v: Variants = {
|
||||||
|
hidden: { opacity: 0, y: reduce ? 0 : 24, filter: reduce ? "blur(0px)" : "blur(6px)" },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
transition: { duration: 0.9, ease: [0.16, 1, 0.3, 1] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6",
|
||||||
|
align === "center" ? "items-center text-center" : "items-start",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{eyebrow ? (
|
||||||
|
<div className="flex items-center gap-3 text-[11px] font-medium uppercase tracking-[0.22em] text-mist">
|
||||||
|
{index ? <span className="text-gold/85">{index}</span> : null}
|
||||||
|
<span className="h-px w-8 bg-gold/40" />
|
||||||
|
<span className="text-mist/85">{eyebrow}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<motion.h2
|
||||||
|
variants={v}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, amount: 0.3 }}
|
||||||
|
className="font-display text-[clamp(2.4rem,5vw,4.6rem)] leading-[1.02] tracking-[-0.02em] text-bone text-balance"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{accent ? <> <span className="gold-text">{accent}</span></> : null}
|
||||||
|
</motion.h2>
|
||||||
|
{subtitle ? (
|
||||||
|
<motion.p
|
||||||
|
variants={v}
|
||||||
|
initial="hidden"
|
||||||
|
whileInView="visible"
|
||||||
|
viewport={{ once: true, amount: 0.3 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
className="max-w-2xl text-pretty text-mist md:text-lg"
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</motion.p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Refined gold WhatsApp link.
|
||||||
|
*
|
||||||
|
* Use 3 surfaces:
|
||||||
|
* - <WhatsAppLink href={...} display="+CC XX XXX XXXX" ariaLabel="..." inline /> (footer row)
|
||||||
|
* - <WhatsAppIconButton href={...} ariaLabel="..." /> (nav)
|
||||||
|
* - <WhatsAppLink href={...} display={...} ariaLabel={...} /> (contact card)
|
||||||
|
*
|
||||||
|
* URL format expected: https://api.whatsapp.com/send/?phone=COUNTRYCODE+DIGITS&text&type=phone_number&app_absent=0
|
||||||
|
* Phone in URL = digits only (no + or spaces). Display string = formatted "+CC XX XXX XXXX".
|
||||||
|
*
|
||||||
|
* lucide-react has no WhatsApp glyph → use this inline SVG.
|
||||||
|
*/
|
||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export function WhatsAppIcon({ className, ...rest }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden
|
||||||
|
className={className}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
<path d="M19.11 4.86A9.94 9.94 0 0 0 12.04 2C6.58 2 2.13 6.45 2.13 11.91c0 1.75.46 3.45 1.33 4.95L2 22l5.27-1.38a9.93 9.93 0 0 0 4.76 1.21h.01c5.46 0 9.91-4.45 9.91-9.91 0-2.65-1.03-5.14-2.84-7.06ZM12.04 20.15c-1.5 0-2.96-.4-4.24-1.17l-.3-.18-3.13.82.83-3.05-.2-.31a8.21 8.21 0 0 1-1.26-4.35c0-4.53 3.69-8.22 8.22-8.22 2.2 0 4.27.86 5.82 2.41a8.16 8.16 0 0 1 2.41 5.81c0 4.53-3.69 8.24-8.15 8.24Zm4.72-6.16c-.26-.13-1.53-.75-1.76-.83-.24-.09-.41-.13-.58.13-.17.26-.66.83-.81 1-.15.17-.3.2-.55.07-.26-.13-1.1-.41-2.1-1.29-.78-.69-1.3-1.55-1.45-1.81-.15-.26-.02-.4.11-.53.11-.11.26-.3.39-.45.13-.15.17-.26.26-.43.09-.17.04-.32-.02-.45-.07-.13-.58-1.4-.79-1.92-.21-.5-.42-.43-.58-.44-.15-.01-.32-.01-.49-.01-.17 0-.45.06-.69.32-.24.26-.9.88-.9 2.15 0 1.27.92 2.5 1.05 2.67.13.17 1.82 2.78 4.42 3.9.62.27 1.1.43 1.48.55.62.2 1.18.17 1.63.1.5-.07 1.53-.62 1.74-1.23.21-.6.21-1.12.15-1.23-.06-.11-.23-.18-.49-.31Z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkProps = {
|
||||||
|
href: string;
|
||||||
|
display: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
inline?: boolean; // true = footer row; false = larger contact-card row
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WhatsAppLink({ href, display, ariaLabel, inline }: LinkProps) {
|
||||||
|
if (inline) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="flex items-center gap-3 text-sm text-mist hover:text-gold-light"
|
||||||
|
>
|
||||||
|
<WhatsAppIcon className="size-4" />
|
||||||
|
WhatsApp · {display}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="group flex items-center gap-3 rounded-xl border border-bone/8 bg-bone/[0.03] p-3.5 transition-colors duration-500 hover:border-gold/30"
|
||||||
|
>
|
||||||
|
<span className="grid size-9 shrink-0 place-items-center rounded-full border border-gold/30 bg-gold/[0.08] text-gold-light shadow-[0_0_14px_-4px_rgba(212,164,55,0.6)]">
|
||||||
|
<WhatsAppIcon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span className="text-bone group-hover:text-gold-light">
|
||||||
|
WhatsApp · {display}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonProps = {
|
||||||
|
href: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WhatsAppIconButton({ href, ariaLabel, className }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={`group grid size-10 place-items-center rounded-full border border-bone/15 text-bone transition-all duration-500 hover:border-gold/40 hover:text-gold-light hover:shadow-[0_0_22px_-6px_rgba(212,164,55,0.6)] hover:scale-[1.04] ${className ?? ""}`}
|
||||||
|
>
|
||||||
|
<WhatsAppIcon className="size-[18px]" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ node_modules
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
# Local env files — set these as environment variables in Coolify instead
|
# Local env files set these as environment variables in Coolify instead
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
# SQLite database files — mount /app/prisma as a persistent volume in Coolify
|
# SQLite database files mount /app/prisma as a persistent volume in Coolify
|
||||||
prisma/*.db
|
prisma/*.db
|
||||||
prisma/*.db-shm
|
prisma/*.db-shm
|
||||||
prisma/*.db-wal
|
prisma/*.db-wal
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Stripe — https://dashboard.stripe.com/apikeys
|
# Stripe https://dashboard.stripe.com/apikeys
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||||
STRIPE_SECRET_KEY=sk_live_...
|
STRIPE_SECRET_KEY=sk_live_...
|
||||||
@@ -11,7 +11,7 @@ STRIPE_WEBHOOK_SECRET=whsec_...
|
|||||||
ADMIN_JWT_SECRET=change_me_generate_with_openssl_rand_hex_32
|
ADMIN_JWT_SECRET=change_me_generate_with_openssl_rand_hex_32
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Database — SQLite via libsql
|
# Database SQLite via libsql
|
||||||
# In Coolify: set to file:/app/prisma/lootah.db
|
# In Coolify: set to file:/app/prisma/lootah.db
|
||||||
# and mount /app/prisma as a persistent volume
|
# and mount /app/prisma as a persistent volume
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Add password authentication to the admin dashboard, enable full CRUD for persona
|
|||||||
- Create `prisma/schema.prisma` with SQLite provider
|
- Create `prisma/schema.prisma` with SQLite provider
|
||||||
- Models: `AdminUser` (id, username, passwordHash, createdAt), `AppSettings` (key, value)
|
- Models: `AdminUser` (id, username, passwordHash, createdAt), `AppSettings` (key, value)
|
||||||
- Run `npx prisma db push` to create the database
|
- Run `npx prisma db push` to create the database
|
||||||
- Create `src/lib/prisma.ts` — singleton Prisma client
|
- Create `src/lib/prisma.ts` singleton Prisma client
|
||||||
|
|
||||||
2. **Create seed script** `prisma/seed.ts`
|
2. **Create seed script** `prisma/seed.ts`
|
||||||
- Creates default admin user with bcrypt-hashed password
|
- Creates default admin user with bcrypt-hashed password
|
||||||
@@ -19,10 +19,10 @@ Add password authentication to the admin dashboard, enable full CRUD for persona
|
|||||||
- Run with `npx prisma db seed`
|
- Run with `npx prisma db seed`
|
||||||
|
|
||||||
3. **Create admin auth API routes**
|
3. **Create admin auth API routes**
|
||||||
- `src/app/api/admin/login/route.ts` — POST, accepts `{ username, password }`, verifies bcrypt hash from DB, returns JWT in httpOnly cookie (JWT secret from DB)
|
- `src/app/api/admin/login/route.ts` POST, accepts `{ username, password }`, verifies bcrypt hash from DB, returns JWT in httpOnly cookie (JWT secret from DB)
|
||||||
- `src/app/api/admin/verify/route.ts` — GET, checks JWT cookie validity
|
- `src/app/api/admin/verify/route.ts` GET, checks JWT cookie validity
|
||||||
- `src/app/api/admin/logout/route.ts` — POST, clears auth cookie
|
- `src/app/api/admin/logout/route.ts` POST, clears auth cookie
|
||||||
- `src/app/api/admin/change-password/route.ts` — POST, accepts `{ currentPassword, newPassword }`, updates hash in DB
|
- `src/app/api/admin/change-password/route.ts` POST, accepts `{ currentPassword, newPassword }`, updates hash in DB
|
||||||
|
|
||||||
4. **Create admin middleware** `src/middleware.ts`
|
4. **Create admin middleware** `src/middleware.ts`
|
||||||
- Protects all `/admin/*` routes (except `/admin/login/`)
|
- Protects all `/admin/*` routes (except `/admin/login/`)
|
||||||
@@ -34,7 +34,7 @@ Add password authentication to the admin dashboard, enable full CRUD for persona
|
|||||||
- Calls login API, redirects to `/admin/` on success
|
- Calls login API, redirects to `/admin/` on success
|
||||||
- Shows error on wrong credentials
|
- Shows error on wrong credentials
|
||||||
|
|
||||||
6. **Add JWT_SECRET to .env.local** (only this one — password is in DB)
|
6. **Add JWT_SECRET to .env.local** (only this one password is in DB)
|
||||||
- Alternative: store JWT secret in DB and load at startup into a module-level cache
|
- Alternative: store JWT secret in DB and load at startup into a module-level cache
|
||||||
|
|
||||||
### Dependencies to install
|
### Dependencies to install
|
||||||
@@ -43,12 +43,12 @@ Add password authentication to the admin dashboard, enable full CRUD for persona
|
|||||||
## Phase 2: Full CRUD for Pricing Items + Personas
|
## Phase 2: Full CRUD for Pricing Items + Personas
|
||||||
|
|
||||||
### Steps (depends on Phase 1)
|
### Steps (depends on Phase 1)
|
||||||
6. **Add/Remove pricing items in admin** — Update `src/app/admin/page.tsx`
|
6. **Add/Remove pricing items in admin** Update `src/app/admin/page.tsx`
|
||||||
- "Add Item" button with fields: id (auto-slug from label), label, price
|
- "Add Item" button with fields: id (auto-slug from label), label, price
|
||||||
- Delete button per row (with confirmation)
|
- Delete button per row (with confirmation)
|
||||||
- Update `usePricingStore` to support `addItem()` and `removeItem()` actions
|
- Update `usePricingStore` to support `addItem()` and `removeItem()` actions
|
||||||
|
|
||||||
7. **Persona management section** — New section in admin page
|
7. **Persona management section** New section in admin page
|
||||||
- List current personas with edit capability (label, description, colors)
|
- List current personas with edit capability (label, description, colors)
|
||||||
- Add new persona form
|
- Add new persona form
|
||||||
- Delete persona button
|
- Delete persona button
|
||||||
@@ -58,7 +58,7 @@ Add password authentication to the admin dashboard, enable full CRUD for persona
|
|||||||
## Phase 3: Additional Admin Features
|
## Phase 3: Additional Admin Features
|
||||||
|
|
||||||
### Steps (parallel with Phase 2)
|
### Steps (parallel with Phase 2)
|
||||||
8. **Orders dashboard section** — New section in admin page
|
8. **Orders dashboard section** New section in admin page
|
||||||
- Show orders from Stripe API (list recent payments)
|
- Show orders from Stripe API (list recent payments)
|
||||||
- Display: order ID, customer email, amount, status, date
|
- Display: order ID, customer email, amount, status, date
|
||||||
- New API route `src/app/api/admin/orders/route.ts` to fetch from Stripe
|
- New API route `src/app/api/admin/orders/route.ts` to fetch from Stripe
|
||||||
@@ -77,18 +77,18 @@ Add password authentication to the admin dashboard, enable full CRUD for persona
|
|||||||
11. **Logout button** in admin header
|
11. **Logout button** in admin header
|
||||||
|
|
||||||
## Relevant Files
|
## Relevant Files
|
||||||
- `src/app/admin/page.tsx` — Main admin dashboard (add CRUD UI, analytics)
|
- `src/app/admin/page.tsx` Main admin dashboard (add CRUD UI, analytics)
|
||||||
- `src/app/admin/login/page.tsx` — New login page
|
- `src/app/admin/login/page.tsx` New login page
|
||||||
- `src/app/api/admin/login/route.ts` — New auth API
|
- `src/app/api/admin/login/route.ts` New auth API
|
||||||
- `src/app/api/admin/verify/route.ts` — New verify API
|
- `src/app/api/admin/verify/route.ts` New verify API
|
||||||
- `src/app/api/admin/orders/route.ts` — New orders API
|
- `src/app/api/admin/orders/route.ts` New orders API
|
||||||
- `src/middleware.ts` — New, protects admin routes
|
- `src/middleware.ts` New, protects admin routes
|
||||||
- `src/lib/prisma.ts` — New, Prisma client singleton
|
- `src/lib/prisma.ts` New, Prisma client singleton
|
||||||
- `prisma/schema.prisma` — New, database schema (AdminUser, AppSettings)
|
- `prisma/schema.prisma` New, database schema (AdminUser, AppSettings)
|
||||||
- `prisma/seed.ts` — New, seeds default admin user
|
- `prisma/seed.ts` New, seeds default admin user
|
||||||
- `src/store/usePricingStore.ts` — Add `addItem()`, `removeItem()` actions
|
- `src/store/usePricingStore.ts` Add `addItem()`, `removeItem()` actions
|
||||||
- `src/components/ConfigPanel.tsx` — Persona options currently hardcoded (PERSONA_OPTIONS const)
|
- `src/components/ConfigPanel.tsx` Persona options currently hardcoded (PERSONA_OPTIONS const)
|
||||||
- `.env.local` — Add ADMIN_PASSWORD, ADMIN_JWT_SECRET
|
- `.env.local` Add ADMIN_PASSWORD, ADMIN_JWT_SECRET
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
1. Visit `/admin/` without login → redirected to `/admin/login/`
|
1. Visit `/admin/` without login → redirected to `/admin/login/`
|
||||||
@@ -102,13 +102,13 @@ Add password authentication to the admin dashboard, enable full CRUD for persona
|
|||||||
9. Run `npx vitest run` → all existing tests pass
|
9. Run `npx vitest run` → all existing tests pass
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
- **Database: Prisma + SQLite** — file-based, no external server needed, real ORM
|
- **Database: Prisma + SQLite** file-based, no external server needed, real ORM
|
||||||
- Admin password stored as bcrypt hash in DB (not in env vars)
|
- Admin password stored as bcrypt hash in DB (not in env vars)
|
||||||
- JWT secret stored in AppSettings table in DB
|
- JWT secret stored in AppSettings table in DB
|
||||||
- JWT in httpOnly cookie — secure, no localStorage tokens
|
- JWT in httpOnly cookie secure, no localStorage tokens
|
||||||
- jose library for JWT — lightweight, edge-compatible (works in Next.js middleware)
|
- jose library for JWT lightweight, edge-compatible (works in Next.js middleware)
|
||||||
- Personas need to move from hardcoded array to store-driven — breaking change in ConfigPanel
|
- Personas need to move from hardcoded array to store-driven breaking change in ConfigPanel
|
||||||
- Stripe orders fetched via Stripe API directly — no local DB needed for orders
|
- Stripe orders fetched via Stripe API directly no local DB needed for orders
|
||||||
|
|
||||||
## Further Considerations
|
## Further Considerations
|
||||||
1. **Persona storage**: Currently hardcoded in ConfigPanel. Moving to a store means ConfigPanel reads from store dynamically. This is required for admin CRUD to work. **Recommended: create persona store with localStorage persistence (same pattern as pricing store)**
|
1. **Persona storage**: Currently hardcoded in ConfigPanel. Moving to a store means ConfigPanel reads from store dynamically. This is required for admin CRUD to work. **Recommended: create persona store with localStorage persistence (same pattern as pricing store)**
|
||||||
|
|||||||
@@ -37,3 +37,7 @@ next-env.d.ts
|
|||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# Local design references not part of project
|
||||||
|
/references
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Changelog — Lootah Robotics G1 Configurator
|
# Changelog Lootah Robotics G1 Configurator
|
||||||
> تاريخ التغييرات — 20 أبريل 2026
|
> تاريخ التغييرات 20 أبريل 2026
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2ff21c5 — perf: compress GLBs 75%, add Draco decoder, loading spinner for attire
|
## 2ff21c5 perf: compress GLBs 75%, add Draco decoder, loading spinner for attire
|
||||||
|
|
||||||
### المشكلة
|
### المشكلة
|
||||||
- الموبايلات القديمة كانت تعلّق لأوقات طويلة عند تحميل الأزياء الجديدة
|
- الموبايلات القديمة كانت تعلّق لأوقات طويلة عند تحميل الأزياء الجديدة
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## b2a484f — fix: dynamic attire buttons in ScrollOverlays + mobile touch support
|
## b2a484f fix: dynamic attire buttons in ScrollOverlays + mobile touch support
|
||||||
|
|
||||||
### المشكلة
|
### المشكلة
|
||||||
- أزرار الأزياء في صفحة الـ Landing (Kandura, Vest, Suit) كانت مكتوبة بشكل ثابت (hardcoded)
|
- أزرار الأزياء في صفحة الـ Landing (Kandura, Vest, Suit) كانت مكتوبة بشكل ثابت (hardcoded)
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 320b77b — fix: contacts API - use ADMIN_JWT_SECRET env var
|
## 320b77b fix: contacts API - use ADMIN_JWT_SECRET env var
|
||||||
|
|
||||||
### المشكلة
|
### المشكلة
|
||||||
- صفحة Contacts في الأدمن كانت ترجع خطأ 500
|
- صفحة Contacts في الأدمن كانت ترجع خطأ 500
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 25ffbf4 — feat: add favicon and app icons for PWA support
|
## 25ffbf4 feat: add favicon and app icons for PWA support
|
||||||
|
|
||||||
### التغييرات
|
### التغييرات
|
||||||
| الملف | التغيير |
|
| الملف | التغيير |
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## e686d41 — fix: use configStore.getState().setPersonaAttire in ScrollOverlays
|
## e686d41 fix: use configStore.getState().setPersonaAttire in ScrollOverlays
|
||||||
|
|
||||||
### المشكلة
|
### المشكلة
|
||||||
- بناء Docker كان يفشل مع خطأ TypeScript:
|
- بناء Docker كان يفشل مع خطأ TypeScript:
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## e159965 — feat: add GET endpoint to retrieve contact requests with admin authentication
|
## e159965 feat: add GET endpoint to retrieve contact requests with admin authentication
|
||||||
|
|
||||||
### التغييرات
|
### التغييرات
|
||||||
| الملف | التغيير |
|
| الملف | التغيير |
|
||||||
@@ -114,10 +114,10 @@ STRIPE_SECRET_KEY= # للمدفوعات
|
|||||||
### هيكل Stores
|
### هيكل Stores
|
||||||
| Store | الوصف |
|
| Store | الوصف |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `configStore` (vanilla Zustand) | الألوان والزي النشط — يدعم `.getState()` |
|
| `configStore` (vanilla Zustand) | الألوان والزي النشط يدعم `.getState()` |
|
||||||
| `useConfigStore` (React hook) | wrapper لـ `configStore` للاستخدام داخل components |
|
| `useConfigStore` (React hook) | wrapper لـ `configStore` للاستخدام داخل components |
|
||||||
| `personaStore` (vanilla Zustand) | قائمة الأزياء — تُحمَّل من API عند التهيئة |
|
| `personaStore` (vanilla Zustand) | قائمة الأزياء تُحمَّل من API عند التهيئة |
|
||||||
| `pricingStore` | أسعار العناصر — تُزامَن مع قاعدة البيانات |
|
| `pricingStore` | أسعار العناصر تُزامَن مع قاعدة البيانات |
|
||||||
|
|
||||||
### تدفق الأزياء المرفوعة
|
### تدفق الأزياء المرفوعة
|
||||||
1. الأدمن يرفع `.glb` من لوحة التحكم
|
1. الأدمن يرفع `.glb` من لوحة التحكم
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
# Generate Prisma client (reads prisma/schema.prisma + prisma.config.ts)
|
# Generate Prisma client (reads prisma/schema.prisma + prisma.config.ts)
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
|
|
||||||
# Build Next.js — produces .next/standalone (set in next.config.mjs)
|
# Build Next.js produces .next/standalone (set in next.config.mjs)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Stage 3: production runner ─────────────────────────────────────────────────
|
# ── Stage 3: production runner ─────────────────────────────────────────────────
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
@@ -9,7 +9,7 @@ echo "→ Syncing database schema..."
|
|||||||
# db push creates the SQLite file and syncs tables to match schema.prisma
|
# db push creates the SQLite file and syncs tables to match schema.prisma
|
||||||
/app/node_modules/.bin/prisma db push
|
/app/node_modules/.bin/prisma db push
|
||||||
|
|
||||||
echo "→ Seeding database (idempotent — skips existing records)..."
|
echo "→ Seeding database (idempotent pricing rows are upserted every deploy)..."
|
||||||
/app/node_modules/.bin/tsx /app/prisma/seed.ts
|
/app/node_modules/.bin/tsx /app/prisma/seed.ts
|
||||||
|
|
||||||
echo "→ Starting Next.js on port ${PORT:-3000}..."
|
echo "→ Starting Next.js on port ${PORT:-3000}..."
|
||||||
|
|||||||
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 232 KiB |
@@ -1,11 +1,18 @@
|
|||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
outputFileTracingRoot: __dirname,
|
||||||
images: {
|
images: {
|
||||||
unoptimized: true,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
allowedDevOrigins: ['127.0.0.1', 'localhost', '10.255.254.66'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -18,22 +18,27 @@
|
|||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@react-three/postprocessing": "^2.16.0",
|
"@react-three/postprocessing": "^2.16.0",
|
||||||
|
"@splinetool/react-spline": "^4.1.0",
|
||||||
|
"@splinetool/runtime": "^1.12.94",
|
||||||
"@stripe/react-stripe-js": "^6.1.0",
|
"@stripe/react-stripe-js": "^6.1.0",
|
||||||
"@stripe/stripe-js": "^9.1.0",
|
"@stripe/stripe-js": "^9.1.0",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"draco3dgltf": "^1.5.7",
|
"draco3dgltf": "^1.5.7",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-country-phone-input": "^1.0.2",
|
"react-country-phone-input": "^1.0.2",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"stripe": "^22.0.1",
|
"stripe": "^22.0.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
"three": "^0.170.0",
|
"three": "^0.170.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
@@ -2713,6 +2718,37 @@
|
|||||||
"text-hex": "1.0.x"
|
"text-hex": "1.0.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@splinetool/react-spline": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@splinetool/react-spline/-/react-spline-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Y379gm17gw+1nxT/YXTCJnVIWuu7tsUH1tp/YxsYb0pZnc9Gljk7Om4Kpq7WPq0bZ4zidVCxf6xn6jgDcbHifQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"blurhash": "2.0.5",
|
||||||
|
"lodash.debounce": "4.0.8",
|
||||||
|
"react-merge-refs": "2.1.1",
|
||||||
|
"thumbhash": "0.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@splinetool/runtime": "*",
|
||||||
|
"next": ">=14.2.0",
|
||||||
|
"react": "*",
|
||||||
|
"react-dom": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"next": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@splinetool/runtime": {
|
||||||
|
"version": "1.12.94",
|
||||||
|
"resolved": "https://registry.npmjs.org/@splinetool/runtime/-/runtime-1.12.94.tgz",
|
||||||
|
"integrity": "sha512-Y49qppQkzVHhbeePpmdXBOeVOtgAcn8qObBujB8ePYnGYBxSAA2NCHo91pY1Sv1DCMXrowuTSAJexVeXf+t4Pw==",
|
||||||
|
"dependencies": {
|
||||||
|
"on-change": "4.0.0",
|
||||||
|
"semver-compare": "1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -3714,6 +3750,12 @@
|
|||||||
"require-from-string": "^2.0.2"
|
"require-from-string": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/blurhash": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||||
@@ -4022,6 +4064,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "5.0.3",
|
"version": "5.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||||
@@ -6043,6 +6094,15 @@
|
|||||||
"url": "https://github.com/sponsors/wellwelwel"
|
"url": "https://github.com/sponsors/wellwelwel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lz-string": {
|
"node_modules/lz-string": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
||||||
@@ -6515,6 +6575,18 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/on-change": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-change/-/on-change-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-PTu7C9Jsz4b+sNMDpH0eZFTr7uxdOtoDWRnhaVNK50bgrrnW5nvbWI0jm5DG9qOoTnIhBzE9xoKVFPD9xgtbdg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sindresorhus/on-change?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/one-time": {
|
"node_modules/one-time": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
||||||
@@ -6947,6 +7019,16 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/react-merge-refs": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-jLQXJ/URln51zskhgppGJ2ub7b2WFKGq3cl3NYKtlHoTG+dN2q7EzWrn3hN3EgPsTMvpR9tpq5ijdp7YwFZkag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/gregberge"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-use-measure": {
|
"node_modules/react-use-measure": {
|
||||||
"version": "2.1.7",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
|
||||||
@@ -7170,6 +7252,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/semver-compare": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/seq-queue": {
|
"node_modules/seq-queue": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||||
@@ -7713,6 +7801,16 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
@@ -7779,6 +7877,12 @@
|
|||||||
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
|
"integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/thumbhash": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
@@ -21,22 +21,27 @@
|
|||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@react-three/postprocessing": "^2.16.0",
|
"@react-three/postprocessing": "^2.16.0",
|
||||||
|
"@splinetool/react-spline": "^4.1.0",
|
||||||
|
"@splinetool/runtime": "^1.12.94",
|
||||||
"@stripe/react-stripe-js": "^6.1.0",
|
"@stripe/react-stripe-js": "^6.1.0",
|
||||||
"@stripe/stripe-js": "^9.1.0",
|
"@stripe/stripe-js": "^9.1.0",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "^0.183.1",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"draco3dgltf": "^1.5.7",
|
"draco3dgltf": "^1.5.7",
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"jose": "^6.2.2",
|
"jose": "^6.2.2",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-country-phone-input": "^1.0.2",
|
"react-country-phone-input": "^1.0.2",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"stripe": "^22.0.1",
|
"stripe": "^22.0.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
"three": "^0.170.0",
|
"three": "^0.170.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
|
|||||||
@@ -47,25 +47,30 @@ async function main() {
|
|||||||
console.log('✓ JWT secret already exists, skipping.');
|
console.log('✓ JWT secret already exists, skipping.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed default pricing items (idempotent — only if table is empty)
|
// Pricing items upserted on every deploy so the code is the source of truth.
|
||||||
const pricingCount = await prisma.pricingItem.count();
|
// Prices in AED. USD → AED at 3.6725 (CBUAE peg).
|
||||||
if (pricingCount === 0) {
|
// Basic = Unitree G1 retail ($16k) + $5k markup = $21k ≈ 77,125 AED.
|
||||||
const defaultItems = [
|
// EDU = $40k flat ≈ 146,900 AED.
|
||||||
{ id: 'base', label: 'G1 Robot Base', price: 250000, sortOrder: 0 },
|
// All persona attire = 5,000 AED each.
|
||||||
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 15000, sortOrder: 1 },
|
// Note: any prior admin-UI edits to these item prices/labels will be reset on each deploy.
|
||||||
{ id: 'industrial-vest', label: 'Industrial Vest', price: 8500, sortOrder: 2 },
|
const defaultItems = [
|
||||||
{ id: 'business-suit', label: 'Business Suit', price: 12000, sortOrder: 3 },
|
{ id: 'base', label: 'G1 Robot Basic', price: 77125, sortOrder: 0 },
|
||||||
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 4 },
|
{ id: 'edu', label: 'G1 Robot EDU', price: 146900, sortOrder: 1 },
|
||||||
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 5 },
|
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000, sortOrder: 2 },
|
||||||
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 6 },
|
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000, sortOrder: 3 },
|
||||||
];
|
{ id: 'business-suit', label: 'Business Suit', price: 5000, sortOrder: 4 },
|
||||||
for (const item of defaultItems) {
|
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 5 },
|
||||||
await prisma.pricingItem.create({ data: item });
|
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 6 },
|
||||||
}
|
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 7 },
|
||||||
console.log(`✓ Seeded ${defaultItems.length} default pricing items.`);
|
];
|
||||||
} else {
|
for (const item of defaultItems) {
|
||||||
console.log(`✓ ${pricingCount} pricing items already exist, skipping.`);
|
await prisma.pricingItem.upsert({
|
||||||
|
where: { id: item.id },
|
||||||
|
create: item,
|
||||||
|
update: { label: item.label, price: item.price, sortOrder: item.sortOrder },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
console.log(`✓ Upserted ${defaultItems.length} pricing items from code defaults.`);
|
||||||
|
|
||||||
console.log('Seeding complete!');
|
console.log('Seeding complete!');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// One-off price refresh. Run with: npx tsx prisma/update-prices.ts
|
||||||
|
// Idempotent: upserts every row, safe to re-run.
|
||||||
|
import { PrismaClient } from '../src/generated/prisma/client.js';
|
||||||
|
import { PrismaLibSql } from '@prisma/adapter-libsql';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const dbPath = path.resolve(process.cwd(), 'prisma', 'lootah.db');
|
||||||
|
const adapter = new PrismaLibSql({ url: `file:${dbPath}` });
|
||||||
|
const prisma = new PrismaClient({ adapter } as ConstructorParameters<typeof PrismaClient>[0]);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ id: 'base', label: 'G1 Robot Basic', price: 77125, sortOrder: 0 },
|
||||||
|
{ id: 'edu', label: 'G1 Robot EDU', price: 146900, sortOrder: 1 },
|
||||||
|
{ id: 'emarati-kandura', label: 'Emarati Kandura', price: 5000, sortOrder: 2 },
|
||||||
|
{ id: 'industrial-vest', label: 'Industrial Vest', price: 5000, sortOrder: 3 },
|
||||||
|
{ id: 'business-suit', label: 'Business Suit', price: 5000, sortOrder: 4 },
|
||||||
|
{ id: 'custom-color', label: 'Custom Color', price: 3500, sortOrder: 5 },
|
||||||
|
{ id: 'robot-doctor', label: 'Robot Doctor', price: 5000, sortOrder: 6 },
|
||||||
|
{ id: 'security-guard', label: 'Security Guard', price: 5000, sortOrder: 7 },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const item of items) {
|
||||||
|
await prisma.pricingItem.upsert({
|
||||||
|
where: { id: item.id },
|
||||||
|
create: item,
|
||||||
|
update: { label: item.label, price: item.price, sortOrder: item.sortOrder },
|
||||||
|
});
|
||||||
|
console.log(`✓ ${item.id} → ${item.label} @ ${item.price} AED`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
<svg width="98" height="26" viewBox="0 0 98 26" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<g clip-path="url(#clip0_7615_2164)">
|
||||||
|
<path d="M90.9858 21.7754C93.4836 21.7754 98.0003 20.6516 98.0003 14.5274V4.22375H94.6635V14.3776C94.6635 17.7951 92.1419 18.4216 90.7494 18.4216H90.1531C88.7591 18.4216 86.2391 17.7951 86.2391 14.3776V4.22375H82.9023V14.5274C82.9023 20.6532 87.419 21.7754 89.9168 21.7754H90.9858Z" fill="#FFFFFF"/>
|
||||||
|
<path d="M43.2211 10.9441C43.2211 11.985 42.758 13.8659 40.2317 13.8659H34.7951V7.57755H40.2317C42.758 7.57755 43.2211 9.46007 43.2211 10.4994V10.9441ZM46.5579 10.2619C46.5579 8.11154 45.6 4.22375 40.3823 4.22375H31.46V21.6367H34.7967V17.2197H40.3823C45.6016 17.2197 46.5579 13.3319 46.5579 11.1816V10.2619Z" fill="#FFFFFF"/>
|
||||||
|
<path d="M80.9788 13.5455C80.9788 16.4259 79.6958 21.6351 72.705 21.6351H72.3181H72.7463H65.8809V4.22375H72.7463H72.3181H72.705C79.6958 4.22375 80.9788 9.43298 80.9788 12.3133V13.5455ZM77.642 12.5509C77.642 10.7799 76.8538 7.57755 72.556 7.57755H69.2176V18.2813H72.556C76.8538 18.2813 77.642 15.079 77.642 13.308V12.5509Z" fill="#FFFFFF"/>
|
||||||
|
<path d="M56.4887 21.7754C58.9865 21.7754 63.5032 20.6516 63.5032 14.5274V4.22375H60.1664V14.3776C60.1664 17.7951 57.6448 18.4216 56.2524 18.4216H55.6561C54.2621 18.4216 51.742 17.7951 51.742 14.3776V4.22375H48.4053V14.5274C48.4053 20.6532 52.922 21.7754 55.4198 21.7754H56.4887Z" fill="#FFFFFF"/>
|
||||||
|
<path d="M0.114186 25.9884L6.14225 22.4912C6.18982 22.4641 6.21837 22.4131 6.21837 22.3589V0.0762157C6.21837 0.0172373 6.15493 -0.0194248 6.10418 0.0108613L0.0761239 3.50812C0.0285464 3.53521 0 3.58622 0 3.64042V25.9231C0 25.982 0.0634366 26.0187 0.114186 25.9884Z" fill="#FFFFFF"/>
|
||||||
|
<path d="M16.5444 25.9884L22.5724 22.4912C22.62 22.4641 22.6485 22.4131 22.6485 22.3589V0.0762157C22.6485 0.0172373 22.5851 -0.0194248 22.5344 0.0108613L16.5063 3.50812C16.4587 3.53521 16.4302 3.58622 16.4302 3.64042V25.9231C16.4302 25.982 16.4936 26.0187 16.5444 25.9884Z" fill="#FFFFFF"/>
|
||||||
|
<path d="M14.4332 3.64042V9.94791C14.4332 10.0021 14.4047 10.0531 14.3571 10.0802L8.32903 13.5775C8.27828 13.6062 8.21484 13.5695 8.21484 13.5121V0.0762157C8.21484 0.0172373 8.27828 -0.0194248 8.32903 0.0108613L14.3571 3.50812C14.4047 3.53521 14.4332 3.58622 14.4332 3.64042Z" fill="#FFFFFF"/>
|
||||||
|
<path d="M8.21484 16.0514V22.3605C8.21484 22.4147 8.24339 22.4657 8.29097 22.4928L14.319 25.9901C14.3698 26.0188 14.4332 25.9821 14.4332 25.9247V12.4872C14.4332 12.4282 14.3698 12.3916 14.319 12.4219L8.29097 15.9191C8.24339 15.9462 8.21484 15.9972 8.21484 16.0514Z" fill="#FFFFFF"/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7615_2164">
|
||||||
|
<rect width="98" height="26" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 870.93 196.18" style="enable-background:new 0 0 870.93 196.18;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#040000;}
|
||||||
|
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#040000;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M336.28,15.85l-28.22,113.64c-1.5,6.05-2.68,8.25-4.43,10.48c-1.72,2.2-3.89,3.93-6.4,5.13
|
||||||
|
c-2.51,1.18-4.86,1.81-10.92,1.81h-19.2l32.55-131.06C299.66,15.85,336.28,15.85,336.28,15.85z"/>
|
||||||
|
<path class="st0" d="M144.8,50.55l-18.94,76.29c-1.73,6.98-3.07,9.5-5.1,12.07c-2.01,2.56-4.48,4.55-7.39,5.94
|
||||||
|
c-2.9,1.35-5.61,2.08-12.58,2.08H29.16c-6.98,0-9.32-0.73-11.55-2.08c-2.22-1.39-3.71-3.37-4.44-5.94
|
||||||
|
c-0.75-2.56-0.84-5.09,0.9-12.07l18.94-76.29h36.62l-18.11,72.91c-0.69,2.79-0.65,3.8-0.35,4.84c0.29,1.02,0.88,1.81,1.78,2.37
|
||||||
|
c0.87,0.54,1.82,0.83,4.61,0.83h22.47c2.79,0,3.89-0.29,5.02-0.83c1.18-0.56,2.17-1.35,2.96-2.37c0.82-1.04,1.36-2.04,2.05-4.84
|
||||||
|
l18.11-72.91C108.17,50.55,144.8,50.55,144.8,50.55z"/>
|
||||||
|
<path class="st0" d="M587.04,147.17h-40.19l-29.7-57.44h30.11c2.79,0,3.87-0.29,5.02-0.83c0.48-0.23,0.93-0.5,1.34-0.81
|
||||||
|
c0.61-0.44,1.15-0.96,1.62-1.56c0.79-1.02,1.34-2.04,2.03-4.84l1.79-7.21c0.69-2.79,0.65-3.8,0.37-4.82
|
||||||
|
c-0.18-0.6-0.45-1.14-0.85-1.56c-0.25-0.33-0.56-0.6-0.94-0.81c-0.89-0.54-1.82-0.83-4.61-0.83h-30.11l-19.98,80.45h-36.62
|
||||||
|
l23.93-96.37h94.23c6.07,0,8.1,0.62,10.02,1.79c1.92,1.19,3.22,2.93,3.85,5.15c0.11,0.35,0.19,0.71,0.25,1.08
|
||||||
|
c0.39,1.93,0.24,4.3-1.02,9.39l-4.99,20.08c-1.26,5.09-2.3,7.46-3.64,9.39c-0.25,0.37-0.51,0.73-0.79,1.08
|
||||||
|
c-1.73,2.22-3.89,3.95-6.4,5.15c-2.51,1.18-4.84,1.79-10.91,1.79h-7.79l0,0L587.04,147.17z"/>
|
||||||
|
<path class="st0" d="M647.58,87.17h75.17l-4.31,17.35h-67.13c-2.79,0-3.87,0.29-5.02,0.83c-1.16,0.56-2.17,1.35-2.96,2.37
|
||||||
|
c-0.81,1.02-1.36,2.04-2.05,4.84l-4.23,17.02h75.17l-4.31,17.35h-91.69c-7,0-9.34-0.73-11.55-2.08c-2.22-1.39-3.73-3.37-4.46-5.94
|
||||||
|
c-0.73-2.56-0.84-5.09,0.9-12.07l14.62-58.88c1.5-6.05,2.68-8.25,4.43-10.47c1.73-2.22,3.89-3.95,6.4-5.15
|
||||||
|
c2.51-1.18,4.86-1.79,10.91-1.79h94.36l-4.31,17.35H660.4c-2.79,0-3.87,0.29-5.02,0.83c-1.16,0.56-2.17,1.35-2.96,2.37
|
||||||
|
c-0.81,1.02-1.36,2.04-2.05,4.84L647.58,87.17z"/>
|
||||||
|
<path class="st0" d="M777.75,87.17h75.17l-4.31,17.35h-67.13c-2.79,0-3.89,0.29-5.04,0.83c-1.16,0.56-2.15,1.35-2.96,2.37
|
||||||
|
c-0.79,1.02-1.34,2.04-2.03,4.84l-4.23,17.02h75.17l-4.31,17.35h-91.71c-7,0-9.34-0.73-11.55-2.08c-2.22-1.39-3.73-3.37-4.44-5.94
|
||||||
|
c-0.75-2.56-0.84-5.09,0.9-12.07l14.62-58.88c1.5-6.05,2.67-8.25,4.41-10.47c1.73-2.22,3.89-3.95,6.42-5.15
|
||||||
|
c2.49-1.18,4.84-1.79,10.89-1.79H862l-4.31,17.35h-67.13c-2.79,0-3.89,0.29-5.04,0.83c-1.16,0.56-2.15,1.35-2.96,2.37
|
||||||
|
c-0.79,1.02-1.34,2.04-2.03,4.84L777.75,87.17z"/>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M274.41,50.55l-23.93,96.37h-25.33c-6.07,0-8.09-0.64-10.02-1.81c-0.25-0.15-0.48-0.31-0.7-0.5
|
||||||
|
c-1.3-0.96-2.27-2.25-2.88-3.78c0.01-0.04,0-0.06-0.02-0.08l-20.92-55.47l-15.31,61.64h-36.62l23.93-96.37h25.33
|
||||||
|
c6.05,0,8.1,0.62,10.02,1.79c0.25,0.15,0.48,0.31,0.7,0.5c1.32,0.98,2.27,2.25,2.87,3.82c0.01,0.02,0.01,0.02,0.03,0.04
|
||||||
|
l20.92,55.47l15.3-61.62H274.41z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<polygon class="st0" points="473.07,50.55 468.52,68.86 422.27,68.86 394.27,181.61 357.65,181.61 385.64,68.86 339.39,68.86
|
||||||
|
343.93,50.55 "/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 26.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="图层_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 870.93 196.18" style="enable-background:new 0 0 870.93 196.18;" xml:space="preserve">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill:#FFFFFF;}
|
||||||
|
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<g>
|
||||||
|
<path class="st0" d="M337,15.2l-27.95,112.55c-1.49,5.99-2.66,8.17-4.39,10.38c-1.7,2.18-3.85,3.89-6.34,5.08
|
||||||
|
c-2.48,1.16-4.82,1.79-10.81,1.79h-19.01L300.73,15.2H337z"/>
|
||||||
|
<path class="st0" d="M147.34,49.56l-18.76,75.56c-1.72,6.91-3.04,9.41-5.05,11.95c-1.99,2.54-4.44,4.51-7.32,5.88
|
||||||
|
c-2.87,1.34-5.55,2.06-12.46,2.06H32.81c-6.91,0-9.23-0.73-11.44-2.06c-2.2-1.37-3.68-3.34-4.4-5.88
|
||||||
|
c-0.74-2.54-0.83-5.04,0.89-11.95l18.76-75.56h36.27l-17.93,72.22c-0.69,2.77-0.65,3.76-0.35,4.79c0.28,1.01,0.87,1.79,1.77,2.35
|
||||||
|
c0.86,0.53,1.8,0.82,4.57,0.82H83.2c2.77,0,3.85-0.29,4.98-0.82c1.17-0.55,2.15-1.34,2.93-2.35c0.81-1.03,1.34-2.02,2.03-4.79
|
||||||
|
l17.93-72.22H147.34z"/>
|
||||||
|
<path class="st0" d="M585.36,145.26h-39.8l-29.42-56.89h29.82c2.77,0,3.83-0.29,4.98-0.82c0.48-0.23,0.93-0.5,1.33-0.8
|
||||||
|
c0.61-0.44,1.13-0.95,1.61-1.55c0.79-1.01,1.32-2.02,2.01-4.79l1.77-7.14c0.69-2.77,0.65-3.76,0.36-4.77
|
||||||
|
c-0.18-0.59-0.45-1.13-0.84-1.55c-0.24-0.32-0.56-0.59-0.93-0.8c-0.88-0.53-1.8-0.82-4.57-0.82h-29.82l-19.79,79.68h-36.27
|
||||||
|
l23.7-95.45h93.33c6.01,0,8.02,0.61,9.92,1.78c1.9,1.18,3.19,2.9,3.81,5.1c0.11,0.34,0.19,0.71,0.25,1.07
|
||||||
|
c0.39,1.91,0.24,4.26-1.01,9.3l-4.94,19.89c-1.25,5.04-2.27,7.39-3.61,9.3c-0.24,0.36-0.5,0.73-0.78,1.07
|
||||||
|
c-1.71,2.2-3.85,3.91-6.34,5.1c-2.48,1.16-4.79,1.78-10.81,1.78h-7.71l0,0L585.36,145.26z"/>
|
||||||
|
<path class="st0" d="M645.32,85.83h74.45l-4.27,17.18h-66.49c-2.77,0-3.83,0.29-4.98,0.82c-1.15,0.55-2.15,1.34-2.93,2.35
|
||||||
|
c-0.8,1.01-1.34,2.02-2.03,4.79l-4.19,16.86h74.45l-4.27,17.18h-90.81c-6.93,0-9.25-0.73-11.44-2.06c-2.2-1.37-3.69-3.34-4.42-5.88
|
||||||
|
c-0.72-2.54-0.83-5.04,0.89-11.95l14.48-58.32c1.49-5.99,2.66-8.17,4.39-10.37c1.71-2.2,3.85-3.91,6.34-5.1
|
||||||
|
c2.48-1.16,4.81-1.78,10.81-1.78h93.46l-4.27,17.18h-66.49c-2.77,0-3.83,0.29-4.98,0.82c-1.15,0.55-2.15,1.34-2.93,2.35
|
||||||
|
c-0.8,1.01-1.34,2.02-2.03,4.79L645.32,85.83z"/>
|
||||||
|
<path class="st0" d="M774.26,85.83h74.45l-4.27,17.18h-66.49c-2.77,0-3.85,0.29-5,0.82c-1.15,0.55-2.13,1.34-2.93,2.35
|
||||||
|
c-0.79,1.01-1.32,2.02-2.01,4.79l-4.19,16.86h74.45l-4.27,17.18h-90.83c-6.93,0-9.25-0.73-11.44-2.06c-2.2-1.37-3.69-3.34-4.4-5.88
|
||||||
|
c-0.74-2.54-0.83-5.04,0.89-11.95l14.48-58.32c1.49-5.99,2.64-8.17,4.37-10.37c1.71-2.2,3.85-3.91,6.36-5.1
|
||||||
|
c2.47-1.16,4.79-1.78,10.79-1.78h93.48l-4.27,17.18h-66.49c-2.77,0-3.85,0.29-5,0.82c-1.15,0.55-2.13,1.34-2.93,2.35
|
||||||
|
c-0.79,1.01-1.32,2.02-2.01,4.79L774.26,85.83z"/>
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M275.72,49.56l-23.7,95.45h-25.08c-6.01,0-8.01-0.63-9.92-1.79c-0.25-0.15-0.48-0.31-0.7-0.5
|
||||||
|
c-1.29-0.95-2.25-2.23-2.85-3.74c0.01-0.04,0-0.06-0.02-0.08l-20.72-54.94l-15.16,61.05H141.3L165,49.56h25.08
|
||||||
|
c5.99,0,8.02,0.61,9.92,1.78c0.25,0.15,0.48,0.31,0.7,0.5c1.3,0.97,2.25,2.23,2.84,3.78c0.01,0.02,0.01,0.02,0.03,0.04
|
||||||
|
l20.72,54.94l15.16-61.03H275.72z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<polygon class="st0" points="472.48,49.56 467.98,67.7 422.16,67.7 394.43,179.37 358.16,179.37 385.89,67.7 340.08,67.7
|
||||||
|
344.58,49.56 "/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
Drop the approved Bu Sunaidah photo here as portrait.jpg (or .webp).
|
||||||
|
After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 283 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 27 KiB |