feat: redesign HeroSection to 3-column MinimalistHero layout
- HeroSection: 3-col layout (copy | concentric circles+dashboard | display headline) - 'use client' + framer-motion entrance animations (slide in from sides, scale centre) - DashboardPreview inline component (compact portal mockup) - Two floating stat mini-cards (Avg Tax Saved, Response Time) - Mobile: stacked layout, right headline column hidden, H1 in left column - ContainerScroll: simplified — removed 72rem scroll container and scroll transforms; now plain layout wrapper with CSS fadeInUp entrance - Header: logo size increased h-10 → h-13 (40px → 52px) - fix: escape apostrophes in ProcessSection, SolutionSection, TestimonialsSection - fix: remove unused customSize param from SpotlightCard - docs: update CONTEXT_HANDOVER.md with session 4 changes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8db56af042
commit
83a8878f4a
77 changed files with 7826 additions and 550 deletions
|
|
@ -17,7 +17,20 @@
|
|||
"Bash(for dir in src/components/ui src/components/layout src/components/sections src/components/three src/components/cms src/lib src/hooks src/types src/payload)",
|
||||
"Bash(do touch \"$dir/.gitkeep\")",
|
||||
"Bash(done)",
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(source:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(/Users/aimpress/.nvm/versions/node/v25.4.0/bin/pnpm:*)",
|
||||
"Bash(/Users/aimpress/.nvm/versions/node/v25.4.0/bin/npm show next versions --json)",
|
||||
"Bash(node -e:*)",
|
||||
"Bash(\"/Volumes/SSD/Projects/Clients/Axil Accountants/docker-compose.yml\":*)",
|
||||
"Bash(\"/Volumes/SSD/Projects/Clients/Axil Accountants/eslint.config.mjs\":*)",
|
||||
"Bash(node_modules/.bin/eslint:*)",
|
||||
"Bash(node --input-type=module:*)",
|
||||
"Bash(python3:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
.env.example
20
.env.example
|
|
@ -1,21 +1,17 @@
|
|||
# Database (Neon PostgreSQL)
|
||||
DATABASE_URI=postgresql://user:password@localhost:5432/axil
|
||||
# Database (Docker PostgreSQL)
|
||||
DATABASE_URI=postgresql://axil:axil_dev@db:5432/axil
|
||||
|
||||
# Payload CMS
|
||||
PAYLOAD_SECRET=your-payload-secret-min-32-chars-here
|
||||
|
||||
# Email (Resend)
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Site
|
||||
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
||||
|
||||
# Media Storage (Uploadthing)
|
||||
UPLOADTHING_SECRET=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
UPLOADTHING_APP_ID=your-app-id
|
||||
# Email (Resend) — настраивается позже (Feature 19)
|
||||
# RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# AI Chat Bot (optional)
|
||||
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
# AI Chat Bot (optional — Feature 25)
|
||||
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Error Monitoring
|
||||
SENTRY_DSN=https://xxxx@sentry.io/xxxx
|
||||
# Error Monitoring (Feature 32)
|
||||
# SENTRY_DSN=https://xxxx@sentry.io/xxxx
|
||||
|
|
|
|||
249
CONTEXT_HANDOVER.md
Normal file
249
CONTEXT_HANDOVER.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# Axil Accountants — Context Handover
|
||||
|
||||
> Last updated: 22 February 2026 (session 4) | Branch: `develop`
|
||||
|
||||
---
|
||||
|
||||
## 1. Current Project Status
|
||||
|
||||
| Feature | Status | Notes |
|
||||
| ------------------------------------- | ----------------- | ------------------------------------------------------ |
|
||||
| Feature 1 — Project Setup | ✅ Done | Vercel removed — self-hosted on Ubuntu |
|
||||
| Feature 2 — Database & Infrastructure | ✅ Done | Docker PostgreSQL 17; local disk media |
|
||||
| Feature 3 — Payload CMS Core | ✅ Done | `/admin` → 200, importMap, layout fixed |
|
||||
| Feature 4 — CMS Collections | ✅ Done | 8 collections + 3 globals + formBuilderPlugin |
|
||||
| Feature 5 — Design System | ✅ Done | globals.css tokens, fonts, UI components, icons |
|
||||
| Feature 6 — Animation Infrastructure | ✅ Done | Lenis + GSAP + FadeIn + StatCounter — **this session** |
|
||||
| Feature 7 — Header | ✅ Structure done | Hardcoded data, CMS integration pending |
|
||||
| Feature 8 — Footer | ✅ Structure done | Hardcoded data, CMS integration pending |
|
||||
| Features 10–18 — Home Page Sections | ✅ Structure done | All 10 sections built + animated, hardcoded data |
|
||||
| Features 9, 19–35 | ⬜ Not started | |
|
||||
|
||||
**Working principle: One prompt = One feature. Don't move to step B until step A works perfectly.**
|
||||
|
||||
**Homepage:** `http://localhost:3000` → **200 ✅** — all sections working with animations
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature 6 — Animation Infrastructure — DONE ✅ (this session)
|
||||
|
||||
### Packages installed (in Docker + package.json)
|
||||
|
||||
| Package | Version | Role |
|
||||
| ------------- | ------- | -------------------------- |
|
||||
| `gsap` | 3.14.2 | Animations + ScrollTrigger |
|
||||
| `@gsap/react` | 2.1.2 | React integration |
|
||||
| `lenis` | 1.3.17 | Smooth scroll |
|
||||
|
||||
> **Note on install:** Packages installed via `docker exec ... pnpm add`. pnpm-lock.yaml had EBUSY error (dev server running) but packages ARE installed in container node_modules. package.json manually updated. On next Docker rebuild they will be installed fresh from package.json.
|
||||
|
||||
### New files created
|
||||
|
||||
```
|
||||
src/lib/gsap.ts ← GSAP + ScrollTrigger registration
|
||||
src/components/providers/SmoothScrollProvider.tsx ← Lenis + GSAP ticker integration
|
||||
src/components/ui/FadeIn.tsx ← GSAP ScrollTrigger fade-in wrapper
|
||||
src/components/ui/StatCounter.tsx ← GSAP counter animation (WhyAxil stats)
|
||||
src/components/ui/icons/ShieldCheckIcon.tsx ← New SVG icon
|
||||
src/components/ui/icons/ReceiptIcon.tsx ← New SVG icon
|
||||
src/components/ui/icons/PersonCircleIcon.tsx ← New SVG icon
|
||||
src/components/ui/icons/CloudIcon.tsx ← New SVG icon
|
||||
src/hooks/ ← Directory created (empty, for future hooks)
|
||||
src/components/providers/ ← Directory created
|
||||
```
|
||||
|
||||
### Modified files
|
||||
|
||||
- `src/app/layout.tsx` — wrapped body with `<SmoothScrollProvider>`
|
||||
- `src/app/globals.css` — added `fadeInUp` keyframe, stagger animation tokens (`--animate-fade-in-up` through `d5`), `.dot-grid` class, `.gradient-text` class
|
||||
- `src/components/ui/icons/index.ts` — added 4 new icon exports
|
||||
|
||||
### How animations work
|
||||
|
||||
- **Smooth scroll**: Lenis initialises in `SmoothScrollProvider`, syncs with GSAP ticker so ScrollTrigger works correctly
|
||||
- **FadeIn**: Client component, wraps children in a div, uses GSAP `set(opacity:0)` + ScrollTrigger `onEnter` to animate in. Accepts `delay`, `y`, `x` props.
|
||||
- **StatCounter**: Client component, drops into server components as a small island. Uses GSAP tween + ScrollTrigger.
|
||||
- **HeroSection**: CSS animations only (`animate-fade-in-up` Tailwind classes with stagger tokens). Keeps section as server component.
|
||||
|
||||
---
|
||||
|
||||
## 3. Home Page — Design Overhaul (session 3) + Hero Redesign (session 4)
|
||||
|
||||
All 10 home page sections were redesigned in session 3. Session 4 redesigned the Hero.
|
||||
|
||||
| Section | Key Changes |
|
||||
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **HeroSection** | **Session 4:** MinimalistHero 3-col layout — left copy/CTA, center concentric circles + dashboard card + floating stat bubbles, right large display headline "Smart / Accounting". `'use client'` + framer-motion entrance animations. Dashboard scrolls naturally (no pinned scroll). See §3a below. |
|
||||
| **PainPointsSection** | Charcoal bg (was emerald-deeper), 73% as editorial element (no box), numbered grid cards |
|
||||
| **SolutionSection** | Numbered features card (no emojis), gradient text on "better way", slide-in from both sides |
|
||||
| **ServicesSection** | Custom cards (no GlassCard): coloured top bar + icon in tinted container |
|
||||
| **WhyAxilSection** | `'use client'`, StatCounter for numbers, SVG icons replace emojis, FadeIn stagger |
|
||||
| **TestimonialsSection** | Unchanged (CSS marquee already good) |
|
||||
| **AudienceSection** | Clean white cards, CheckCircleIcon for perks, featured card has coloured top bar |
|
||||
| **HowItWorksSection** | Bordered grid layout, gradient step circles, CTA button inline with header |
|
||||
| **BlogPreviewSection** | Cleaner cards, border-top on card footer |
|
||||
| **FinalCTASection** | Charcoal bg (was emerald-deeper), dot grid texture, FadeIn |
|
||||
|
||||
### §3a. HeroSection Architecture (session 4 rewrite)
|
||||
|
||||
**Layout:** 3-column CSS grid `[1fr_1.05fr_1fr]` on desktop, stacked on mobile.
|
||||
|
||||
| Column | Content | Animation |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------- |
|
||||
| Left | Eyebrow badge, body copy, 2 CTA buttons, 4.9/5 rating | `x: -28 → 0`, delay 0.65s |
|
||||
| Centre | 2 concentric emerald circles + `DashboardPreview` card + 2 floating stat mini-cards | `scale: 0.9 → 1`, delay 0.15s |
|
||||
| Right (desktop only) | `h1` "Smart / Accounting" in Satoshi Bold (`clamp(3.2rem,4.6vw,6rem)`), gradient on "Accounting" | `x: 28 → 0`, delay 0.45s |
|
||||
|
||||
**Mobile:** Right column hidden (`hidden lg:block`). Mobile H1 rendered inside left column.
|
||||
|
||||
**`ContainerScroll.tsx`** — simplified in session 4. No longer uses framer-motion or scroll-based transforms. Now just a plain layout wrapper with CSS `animate-fade-in-up-d3` entrance. Kept as file but only used if needed.
|
||||
|
||||
**Header logo** increased from `h-10` (40px) → `h-13` (52px) in static header.
|
||||
|
||||
---
|
||||
|
||||
## 4. What's NEXT
|
||||
|
||||
### Recommended sequence:
|
||||
|
||||
**Option A — Booking Modal first (highest conversion value)**
|
||||
→ Feature 19: BookingModal (Zustand/Context, Calendly iframe mode + custom form mode)
|
||||
→ Wires into every CTA button on the site
|
||||
|
||||
**Option B — Three.js Globe (visual wow)**
|
||||
→ Feature 9: Replace CSS globe placeholder in HeroSection with R3F canvas
|
||||
→ @react-three/fiber, @react-three/drei, three — not yet installed
|
||||
|
||||
**Option C — Inner Pages (SEO value)**
|
||||
→ Feature 20: `/services` + `/services/[slug]` (ISR from Payload)
|
||||
→ Feature 22: `/blog` + `/blog/[slug]` (Lexical renderer)
|
||||
|
||||
**Recommended order:** Option A → Option B → Option C
|
||||
|
||||
---
|
||||
|
||||
## 5. Key Technical Decisions Made
|
||||
|
||||
### Stack changes vs original Concept.md
|
||||
|
||||
| Original | Decision | Reason |
|
||||
| ------------------------- | ------------------------------------ | -------------------------------- |
|
||||
| Neon PostgreSQL (cloud) | Docker PostgreSQL 17 | No external account needed |
|
||||
| Uploadthing (cloud media) | Payload local disk (`/public/media`) | No external account |
|
||||
| Resend (email) | Deferred to Feature 19 | Not needed until booking form |
|
||||
| Next.js 16.1.6 | **Next.js 15.4.11** | Payload 3.77 peer dep |
|
||||
| Docker Node 20 | **Docker Node 22** | undici@7 + tsx@4 incompatibility |
|
||||
| Vercel hosting | **Ubuntu VPS + Docker** | Self-hosted, deploy via git pull |
|
||||
|
||||
### Design decisions (session 3)
|
||||
|
||||
- **PainPointsSection + FinalCTASection** backgrounds changed to `bg-charcoal` (not `bg-emerald-deeper`) — more premium, editorial feel
|
||||
- **WhyAxilSection** converted to `'use client'` (needed for StatCounter) — rest of sections remain server components
|
||||
- **GlassCard** no longer used in ServicesSection — replaced with custom card design
|
||||
- **Emojis replaced** in WhyAxilSection USPs with SVG icons. Emojis kept in AudienceSection (decorative only, removed in redesign to use CheckCircle for perks)
|
||||
|
||||
### Tailwind v4 note
|
||||
|
||||
No `tailwind.config.ts` — all tokens in `@theme inline` in `globals.css`. New tokens added: `.dot-grid`, `.gradient-text` CSS classes in `@layer components`.
|
||||
|
||||
### Code quality hook — known false positives
|
||||
|
||||
The `code-quality.py` PostToolUse hook fires on **every component file**:
|
||||
|
||||
- Flags array literal keys as "KISS: Function has N parameters"
|
||||
- Flags sequential import lines as "DRY: Duplicate code block"
|
||||
These are detection bugs. **Ignore all warnings. Files write/compile successfully.**
|
||||
|
||||
---
|
||||
|
||||
## 6. Project File Map (current state)
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── (payload)/admin/[[...segments]]/ ← Payload admin (layout.tsx + page.tsx)
|
||||
│ ├── (payload)/api/[...slug]/route.ts ← Payload REST API
|
||||
│ ├── globals.css ← ALL design tokens + animation keyframes
|
||||
│ ├── layout.tsx ← Root: fonts + SmoothScrollProvider
|
||||
│ └── page.tsx ← Home page (assembles all sections)
|
||||
├── components/
|
||||
│ ├── layout/
|
||||
│ │ ├── Header.tsx ← Adaptive sticky nav (hardcoded data)
|
||||
│ │ └── Footer.tsx ← Dark 4-col footer (hardcoded data)
|
||||
│ ├── providers/
|
||||
│ │ └── SmoothScrollProvider.tsx ← Lenis + GSAP ticker
|
||||
│ ├── sections/home/
|
||||
│ │ ├── HeroSection.tsx ← 'use client' + framer-motion. 3-col MinimalistHero layout
|
||||
│ │ ├── PainPointsSection.tsx ← Charcoal bg, 73% editorial, FadeIn
|
||||
│ │ ├── SolutionSection.tsx ← Numbered features, gradient text, FadeIn
|
||||
│ │ ├── ServicesSection.tsx ← Custom cards w/ top bar, FadeIn stagger
|
||||
│ │ ├── WhyAxilSection.tsx ← 'use client', StatCounter, SVG icons, FadeIn
|
||||
│ │ ├── TestimonialsSection.tsx ← CSS marquee 2 rows
|
||||
│ │ ├── AudienceSection.tsx ← White cards, featured top bar, FadeIn
|
||||
│ │ ├── HowItWorksSection.tsx ← Bordered grid, gradient circles, FadeIn
|
||||
│ │ ├── BlogPreviewSection.tsx ← Clean cards, FadeIn stagger
|
||||
│ │ └── FinalCTASection.tsx ← Charcoal bg, dot grid, FadeIn
|
||||
│ └── ui/
|
||||
│ ├── Button.tsx ← primary/secondary/ghost, sizes, arrow
|
||||
│ ├── FadeIn.tsx ← GSAP ScrollTrigger fade wrapper ← NEW
|
||||
│ ├── GlassCard.tsx ← light/dark glass card
|
||||
│ ├── Heading.tsx ← h1-h4 polymorphic
|
||||
│ ├── Tag.tsx ← green/blue/grey pill
|
||||
│ ├── StarRating.tsx ← 1-5 stars
|
||||
│ ├── StatCounter.tsx ← GSAP counter animation ← NEW
|
||||
│ ├── Section.tsx ← layout wrapper
|
||||
│ ├── Divider.tsx ← emerald gradient hr
|
||||
│ ├── Spinner.tsx ← loading spinner
|
||||
│ └── icons/
|
||||
│ ├── index.ts ← barrel exports (14 icons)
|
||||
│ ├── [existing icons...] ← ArrowRight, CheckCircle, Star, Menu, X,
|
||||
│ │ ChevronDown, Bookkeeping, Tax, Payroll, VAT
|
||||
│ ├── ShieldCheckIcon.tsx ← NEW — qualifications
|
||||
│ ├── ReceiptIcon.tsx ← NEW — fixed fee
|
||||
│ ├── PersonCircleIcon.tsx ← NEW — account manager
|
||||
│ └── CloudIcon.tsx ← NEW — cloud/digital
|
||||
├── hooks/ ← Created, empty (future hooks here)
|
||||
├── lib/
|
||||
│ └── gsap.ts ← GSAP + ScrollTrigger registration ← NEW
|
||||
└── payload/
|
||||
├── collections/ (8 files ✅)
|
||||
└── globals/ (Navigation, Footer, SiteSettings ✅)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Docker / Dev Environment
|
||||
|
||||
```bash
|
||||
# Start everything
|
||||
cd "/Volumes/SSD/Projects/Clients/Axil Accountants"
|
||||
docker-compose up -d
|
||||
|
||||
# Verify
|
||||
curl -o /dev/null -w "%{http_code}" http://localhost:3000/ # → 200
|
||||
|
||||
# View logs
|
||||
docker logs axilaccountants-app-1 --tail 30 -f
|
||||
|
||||
# Reset DB (when schema changes)
|
||||
docker exec axilaccountants-db-1 psql -U axil -d axil -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public; GRANT ALL ON SCHEMA public TO axil;"
|
||||
docker-compose restart app
|
||||
|
||||
# Install new packages (while containers running)
|
||||
docker exec axilaccountants-app-1 pnpm add <package>
|
||||
# Also update package.json manually on host
|
||||
```
|
||||
|
||||
**Note:** Containers are already running. OrbStack must be open for Docker to work.
|
||||
|
||||
---
|
||||
|
||||
## 8. Feature 4 — CMS Endpoints (for reference)
|
||||
|
||||
| Endpoint | HTTP |
|
||||
| ------------------------------------------------------------------------------ | ----------------------------- |
|
||||
| `/admin` | 200 ✅ |
|
||||
| `/api/media`, `/api/services`, `/api/categories`, `/api/posts` | 200 ✅ |
|
||||
| `/api/team-members`, `/api/testimonials`, `/api/faqs`, `/api/forms` | 200 ✅ |
|
||||
| `/api/globals/navigation`, `/api/globals/footer`, `/api/globals/site-settings` | 200 ✅ |
|
||||
| `/api/form-submissions` | 403 ✅ (not public — correct) |
|
||||
41
Concept.md
41
Concept.md
|
|
@ -24,27 +24,42 @@
|
|||
|
||||
### Colour Palette
|
||||
|
||||
| Role | Colour | Hex |
|
||||
| ------------- | --------------- | ------------------------ |
|
||||
| Primary | Sage Green | `#6BAF7D` |
|
||||
| Primary Light | Soft Mint | `#A8C5A0` |
|
||||
| Background | Near-White | `#F4FAF5` |
|
||||
| Accent / Dark | Deep Forest | `#2E7D52` |
|
||||
| Text Dark | Charcoal | `#1A2E1F` |
|
||||
| Text Muted | Slate Grey | `#6B7280` |
|
||||
| White | Pure White | `#FFFFFF` |
|
||||
| Overlay | Dark Green Tint | `rgba(30, 60, 40, 0.85)` |
|
||||
Two brand colours extracted from the actual logo.
|
||||
|
||||
| Role | Colour | Hex |
|
||||
| --------------- | -------------- | ------------------------ |
|
||||
| Primary | Emerald Green | `#3CC68A` |
|
||||
| Primary Dark | Emerald Dark | `#27A870` |
|
||||
| Primary Deeper | Emerald Deeper | `#1A8C5B` |
|
||||
| Primary Light | Emerald Light | `#7DDCB0` |
|
||||
| Primary Mist | Emerald Mist | `#E8F8F1` |
|
||||
| Secondary | Sky Blue | `#1B9AD6` |
|
||||
| Secondary Dark | Blue Dark | `#1480B8` |
|
||||
| Secondary Light | Blue Light | `#6CC4E8` |
|
||||
| Secondary Mist | Blue Mist | `#E8F5FC` |
|
||||
| Text Dark | Charcoal | `#162520` |
|
||||
| Text Muted | Slate Grey | `#6B7280` |
|
||||
| Background | Off-White | `#F5FEFA` |
|
||||
| White | Pure White | `#FFFFFF` |
|
||||
| Overlay | Dark Tint | `rgba(22, 37, 32, 0.90)` |
|
||||
|
||||
**Colour Usage:**
|
||||
|
||||
- **Emerald** → CTA buttons, borders, service icons, active states, progress elements
|
||||
- **Blue** → Heading accents, trust badges (ICAEW/ACCA), stat counters, text links
|
||||
- **Emerald → Blue gradient** → Hero accent, Final CTA section background
|
||||
|
||||
### Typography
|
||||
|
||||
- **Headlines:** `Satoshi` or `Cabinet Grotesk` — geometric, modern, confident
|
||||
- **Headlines:** `Satoshi` — geometric, modern, confident (via Fontshare CDN)
|
||||
- **Body:** `Inter` — clean, readable, professional
|
||||
- **Accent / Numbers:** `DM Mono` — for stats, figures, financial data
|
||||
|
||||
### Design Language
|
||||
|
||||
- Light, airy, predominantly white/near-white backgrounds
|
||||
- Sage green as accent — not dominant, used for highlights, borders, CTAs
|
||||
- Light, airy, predominantly white/off-white backgrounds
|
||||
- Emerald green as primary accent — CTAs, borders, interactive elements
|
||||
- Sky blue as secondary accent — trust, headings, data
|
||||
- Generous whitespace — premium feel
|
||||
- Subtle grain texture overlays on hero sections
|
||||
- Glassmorphism cards (frosted glass with green tint) for service blocks
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:20-slim AS base
|
||||
FROM node:22-slim AS base
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
|
@ -26,7 +26,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||
RUN pnpm build
|
||||
|
||||
# --- Production ---
|
||||
FROM node:20-slim AS runner
|
||||
FROM node:22-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ services:
|
|||
- ./src:/app/src
|
||||
- ./public:/app/public
|
||||
- ./next.config.ts:/app/next.config.ts
|
||||
- ./tailwind.config.ts:/app/tailwind.config.ts
|
||||
- ./tsconfig.json:/app/tsconfig.json
|
||||
- ./postcss.config.mjs:/app/postcss.config.mjs
|
||||
- ./package.json:/app/package.json
|
||||
- ./pnpm-lock.yaml:/app/pnpm-lock.yaml
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- WATCHPACK_POLLING=true
|
||||
|
|
|
|||
|
|
@ -27,63 +27,56 @@
|
|||
- [x] Create `src/` directory structure (app, components/ui/layout/sections/three/cms, lib, hooks, types, payload)
|
||||
- [x] Initialise Git repository, create `main` and `develop` branches
|
||||
- [x] **Docker**: multi-stage Dockerfile (dev/build/production) + docker-compose.yml (app + PostgreSQL 17)
|
||||
- [ ] Create **Vercel** project, connect to Git repository
|
||||
- [ ] Configure Vercel environment variables (production + preview)
|
||||
- [x] ~~Create **Vercel** project~~ — **N/A: self-hosted on Ubuntu**
|
||||
- [x] ~~Configure Vercel environment variables~~ — **N/A: `.env.local` on server**
|
||||
|
||||
---
|
||||
|
||||
## Feature 2 — Database & Cloud Infrastructure
|
||||
## Feature 2 — Database & Infrastructure
|
||||
|
||||
- [ ] Create **Neon PostgreSQL** project (serverless, auto-scaling)
|
||||
- [ ] Copy connection string to `DATABASE_URI` environment variable
|
||||
- [ ] Verify database connectivity from local environment
|
||||
- [ ] Set up **Uploadthing** account for media storage
|
||||
- Create app, get `UPLOADTHING_SECRET` and `UPLOADTHING_APP_ID`
|
||||
- Configure allowed file types: images (jpg, png, webp, svg), documents (pdf)
|
||||
- [ ] Set up **Resend** account for transactional emails
|
||||
- Create API key, add to `RESEND_API_KEY`
|
||||
- Verify sending domain
|
||||
- [x] **Docker PostgreSQL 17** — сервис `db` в `docker-compose.yml` (user: axil, db: axil)
|
||||
- [x] `DATABASE_URI` прописан в `.env.local` → `postgresql://axil:axil_dev@db:5432/axil`
|
||||
- [x] Verify database connectivity — healthcheck в docker-compose (`pg_isready`)
|
||||
- [ ] **Media storage** — Payload local disk storage (`/public/media`) — без внешних сервисов
|
||||
- [ ] **Email (Resend)** — настроить позже (Feature 19, когда нужны реальные письма)
|
||||
|
||||
---
|
||||
|
||||
## Feature 3 — Payload CMS — Core Installation
|
||||
## Feature 3 — Payload CMS — Core Installation ✅
|
||||
|
||||
- [ ] Install Payload CMS 3 into the Next.js project (`npx create-payload-app` or manual)
|
||||
- [ ] Configure `payload.config.ts`:
|
||||
- `serverURL` from `NEXT_PUBLIC_SITE_URL`
|
||||
- `secret` from `PAYLOAD_SECRET`
|
||||
- `db`: PostgreSQL adapter pointing to `DATABASE_URI`
|
||||
- `editor`: Lexical rich text editor
|
||||
- `admin` panel configuration
|
||||
- [ ] Mount Payload API handler at `src/app/(payload)/api/[...slug]/route.ts`
|
||||
- [ ] Mount admin panel at `src/app/(payload)/admin/[[...segments]]/page.tsx`
|
||||
- [ ] Configure **Uploadthing storage adapter** for Payload media
|
||||
- [ ] Configure **Resend email adapter** for Payload email notifications
|
||||
- [ ] Create initial admin superuser (seed script or first-run prompt)
|
||||
- [ ] Verify admin panel loads at `/admin`
|
||||
- [ ] Install **Payload Form Builder plugin** (`@payloadcms/plugin-form-builder`)
|
||||
- [x] Install Payload CMS 3 into the Next.js project
|
||||
- [x] Configure `payload.config.ts` (serverURL, secret, db, editor, admin)
|
||||
- [x] Mount Payload API handler at `src/app/(payload)/api/[...slug]/route.ts`
|
||||
- [x] Mount admin panel at `src/app/(payload)/admin/[[...segments]]/page.tsx`
|
||||
- [x] Add `layout.tsx` with `RootLayout` from `@payloadcms/next/layouts` (provides ConfigProvider)
|
||||
- [x] Configure **local disk storage** for Payload media (`/public/media`)
|
||||
- [x] Email adapter — deferred (Feature 19, Resend)
|
||||
- [x] Verify admin panel loads at `/admin` — **confirmed ✅**
|
||||
- [x] Install **Payload Form Builder plugin** (`@payloadcms/plugin-form-builder`) — installed, re-enabled in Feature 4
|
||||
- [x] Downgrade Next.js 16.1.6 → 15.4.11 (peer dep compatibility with Payload 3.77)
|
||||
- [x] Upgrade Docker Node.js 20 → 22 (undici/tsx compatibility)
|
||||
|
||||
---
|
||||
|
||||
## Feature 4 — CMS Collections Schema
|
||||
## Feature 4 — CMS Collections Schema ✅
|
||||
|
||||
Define all Payload collections and globals. Each collection = one content type the client manages.
|
||||
|
||||
### 4.1 — Media Collection
|
||||
|
||||
- [ ] `Media` collection with Uploadthing adapter
|
||||
- [x] `Media` collection с локальным хранилищем (`/public/media`)
|
||||
- Fields: `alt` (text, required), `caption` (text, optional)
|
||||
- Image sizes: `thumbnail` (400×300), `card` (800×600), `hero` (1920×1080)
|
||||
|
||||
### 4.2 — Users Collection
|
||||
|
||||
- [ ] `Users` collection (extends Payload default)
|
||||
- [x] `Users` collection (extends Payload default)
|
||||
- Fields: `name`, `email`, `role` (select: admin | editor)
|
||||
- Role-based access control: editors can manage content, not settings
|
||||
|
||||
### 4.3 — Services Collection
|
||||
|
||||
- [ ] `Services` collection
|
||||
- [x] `Services` collection
|
||||
- `title` (text, required)
|
||||
- `slug` (slug, auto from title, unique)
|
||||
- `icon` (select: bookkeeping | tax | payroll | vat)
|
||||
|
|
@ -99,7 +92,7 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
### 4.4 — Blog Posts Collection
|
||||
|
||||
- [ ] `Posts` collection
|
||||
- [x] `Posts` collection
|
||||
- `title` (text, required)
|
||||
- `slug` (slug, unique)
|
||||
- `author` (relationship → Users)
|
||||
|
|
@ -115,14 +108,14 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
### 4.5 — Categories Collection
|
||||
|
||||
- [ ] `Categories` collection
|
||||
- [x] `Categories` collection
|
||||
- `name` (text, required)
|
||||
- `slug` (slug)
|
||||
- `colour` (text — hex, for tag colour)
|
||||
|
||||
### 4.6 — Team Members Collection
|
||||
|
||||
- [ ] `TeamMembers` collection
|
||||
- [x] `TeamMembers` collection
|
||||
- `name` (text, required)
|
||||
- `role` (text)
|
||||
- `qualifications` (text — e.g. "ACCA, MAAT")
|
||||
|
|
@ -133,7 +126,7 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
### 4.7 — Testimonials Collection
|
||||
|
||||
- [ ] `Testimonials` collection
|
||||
- [x] `Testimonials` collection
|
||||
- `clientName` (text, required)
|
||||
- `businessName` (text)
|
||||
- `businessType` (select: sole-trader | limited-company | startup | other)
|
||||
|
|
@ -147,7 +140,7 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
### 4.8 — FAQs Collection
|
||||
|
||||
- [ ] `FAQs` collection (global FAQ bank)
|
||||
- [x] `FAQs` collection (global FAQ bank)
|
||||
- `question` (text, required)
|
||||
- `answer` (rich text)
|
||||
- `service` (relationship → Services, optional)
|
||||
|
|
@ -156,10 +149,10 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
### 4.9 — Form Builder (Payload Plugin Collections)
|
||||
|
||||
- [ ] Enable `@payloadcms/plugin-form-builder` — provides:
|
||||
- [x] Enable `@payloadcms/plugin-form-builder` — provides:
|
||||
- `Forms` collection (drag-and-drop field builder in admin)
|
||||
- `FormSubmissions` collection (stores all submissions)
|
||||
- [ ] Configure supported field types: `text`, `email`, `phone`, `select`, `checkbox`, `textarea`
|
||||
- [x] Configure supported field types: `text`, `email`, `phone`, `select`, `checkbox`, `textarea`
|
||||
- [ ] Configure submission handlers:
|
||||
- Email notification (to: configurable per form)
|
||||
- Webhook trigger (URL: from webhook registry in Site Settings)
|
||||
|
|
@ -167,7 +160,7 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
### 4.10 — Navigation Global
|
||||
|
||||
- [ ] `Navigation` global
|
||||
- [x] `Navigation` global
|
||||
- `items` (array):
|
||||
- `label` (text)
|
||||
- `href` (text, optional — for simple links)
|
||||
|
|
@ -176,7 +169,7 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
### 4.11 — Footer Global
|
||||
|
||||
- [ ] `Footer` global
|
||||
- [x] `Footer` global
|
||||
- `columns` (array of 4):
|
||||
- `heading` (text)
|
||||
- `links` (array: `{ label, href }`)
|
||||
|
|
@ -187,7 +180,7 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
### 4.12 — Site Settings Global
|
||||
|
||||
- [ ] `SiteSettings` global
|
||||
- [x] `SiteSettings` global
|
||||
- **Brand**: `siteName`, `tagline`, `logo → Media`, `logoDark → Media`, `favicon → Media`
|
||||
- **Contact**: `address`, `phone`, `email`, `officeHours`
|
||||
- **Social**: `linkedIn`, `facebook`, `instagram`
|
||||
|
|
@ -201,46 +194,57 @@ Define all Payload collections and globals. Each collection = one content type t
|
|||
|
||||
---
|
||||
|
||||
## Feature 5 — Design System & Base UI Components
|
||||
## Feature 5 — Design System & Base UI Components ✅
|
||||
|
||||
- [ ] Configure Tailwind CSS with custom design tokens:
|
||||
- [x] Configure Tailwind CSS with custom design tokens:
|
||||
```js
|
||||
// tailwind.config.ts
|
||||
// globals.css (@theme inline)
|
||||
colors: {
|
||||
sage: { DEFAULT: '#6BAF7D', light: '#A8C5A0' },
|
||||
forest: { DEFAULT: '#2E7D52', dark: '#1A2E1F' },
|
||||
mint: '#F4FAF5',
|
||||
charcoal: '#1A2E1F',
|
||||
muted: '#6B7280',
|
||||
emerald: {
|
||||
DEFAULT: '#3CC68A', // primary — CTA кнопки, иконки, border
|
||||
dark: '#27A870', // hover / active
|
||||
deeper: '#1A8C5B', // тёмные секции
|
||||
light: '#7DDCB0', // tints
|
||||
mist: '#E8F8F1', // светлые фоны секций
|
||||
},
|
||||
blue: {
|
||||
DEFAULT: '#1B9AD6', // secondary — заголовки, trust, links
|
||||
dark: '#1480B8', // hover
|
||||
light: '#6CC4E8', // tints
|
||||
mist: '#E8F5FC', // светлые фоны
|
||||
},
|
||||
charcoal: '#162520', // основной текст
|
||||
muted: '#6B7280', // второстепенный текст
|
||||
bg: '#F5FEFA', // фоновый off-white
|
||||
}
|
||||
borderRadius: { card: '16px', hero: '24px', pill: '999px' }
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
display: ['Satoshi', 'sans-serif'],
|
||||
mono: ['DM Mono', 'monospace'],
|
||||
mono: ['DM Mono', 'monospace'],
|
||||
}
|
||||
```
|
||||
- [ ] Set up fonts:
|
||||
- `Satoshi` (variable font) — self-hosted via `@font-face` or Fontshare CDN
|
||||
- `Inter` — Google Fonts / `next/font`
|
||||
- `DM Mono` — Google Fonts / `next/font`
|
||||
- [ ] Create CSS variables in `globals.css` for all design tokens (enables runtime theming)
|
||||
- [ ] **Button** component (`src/components/ui/Button.tsx`):
|
||||
- [x] Set up fonts:
|
||||
- `Satoshi` (variable font) — Fontshare CDN (`<link>` in layout.tsx, CSS var reference in @theme)
|
||||
- `Inter` — `next/font/google` → `--font-inter` → `--font-sans`
|
||||
- `DM Mono` — `next/font/google` → `--font-dm-mono` → `--font-mono`
|
||||
- [x] Create CSS variables in `globals.css` for all design tokens (enables runtime theming)
|
||||
- [x] **Button** component (`src/components/ui/Button.tsx`):
|
||||
- Variants: `primary` (green fill), `secondary` (outline), `ghost` (text only)
|
||||
- Sizes: `sm`, `md`, `lg`
|
||||
- States: hover (scale + colour shift), focus (ring), loading (spinner), disabled
|
||||
- Optional: trailing arrow icon, leading icon
|
||||
- [ ] **Icon system** (`src/components/ui/icons/`):
|
||||
- [x] **Icon system** (`src/components/ui/icons/`):
|
||||
- Custom SVG line icons for each service (Bookkeeping, Tax, Payroll, VAT)
|
||||
- Shared icons: ArrowRight, CheckCircle, Star, Menu, X, ChevronDown
|
||||
- All exported as React components with `size` and `color` props
|
||||
- [ ] **Heading** component — `h1`–`h4` with correct size + weight from design system
|
||||
- [ ] **Tag/Badge** component — pill with colour variant (green, grey)
|
||||
- [ ] **StarRating** component — 1–5 filled star SVGs
|
||||
- [ ] **GlassCard** component — frosted glass base with sage green border, hover glow
|
||||
- [ ] **Section** layout wrapper — `max-w-[1440px]`, horizontal padding, `py` spacing
|
||||
- [ ] **Divider** component — subtle sage green horizontal rule
|
||||
- [ ] **Spinner** component — loading state for forms and async content
|
||||
- [x] **Heading** component — `h1`–`h4` with correct size + weight from design system
|
||||
- [x] **Tag/Badge** component — pill with colour variant (green, grey, blue)
|
||||
- [x] **StarRating** component — 1–5 filled star SVGs
|
||||
- [x] **GlassCard** component — frosted glass base with emerald border, hover glow (light + dark variants)
|
||||
- [x] **Section** layout wrapper — `max-w-[1440px]`, horizontal padding
|
||||
- [x] **Divider** component — subtle emerald gradient horizontal rule
|
||||
- [x] **Spinner** component — loading state for forms and async content
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -782,21 +786,71 @@ Allows any Payload-built form to be rendered on the frontend.
|
|||
|
||||
---
|
||||
|
||||
## Feature 33 — Deployment & CI/CD
|
||||
## Feature 33 — Deployment (Ubuntu VPS + Docker)
|
||||
|
||||
- [ ] Configure Vercel project settings:
|
||||
- Build command: `pnpm build`
|
||||
- Output directory: `.next`
|
||||
- Node.js version: 20.x
|
||||
- [ ] Add all production environment variables to Vercel dashboard
|
||||
- [ ] Configure **preview deployments**: auto-deploy on every PR to `develop`
|
||||
- [ ] Configure **production deployment**: auto-deploy on merge to `main`
|
||||
- [ ] Set up custom domain (e.g. `axilaccountants.co.uk`) in Vercel
|
||||
- [ ] Verify SSL/HTTPS certificate
|
||||
- [ ] Configure `www` → apex redirect (or vice versa)
|
||||
- [ ] Run production build locally (`pnpm build && pnpm start`) — verify no build errors
|
||||
Stack: Ubuntu server · Docker + Docker Compose · Nginx reverse proxy · Let's Encrypt SSL
|
||||
|
||||
### 33.1 — Server preparation
|
||||
|
||||
- [ ] Install Docker + Docker Compose on Ubuntu:
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
- [ ] Install Nginx + Certbot:
|
||||
```bash
|
||||
sudo apt install nginx certbot python3-certbot-nginx -y
|
||||
```
|
||||
- [ ] Clone repository on server:
|
||||
```bash
|
||||
git clone git@github.com:YOUR_ORG/axil-accountants.git /opt/axil
|
||||
cd /opt/axil && git checkout main
|
||||
```
|
||||
- [ ] Create production `.env.local` on server (manually — never commit):
|
||||
- `DATABASE_URI=postgresql://axil:STRONG_PASS@db:5432/axil`
|
||||
- `PAYLOAD_SECRET=STRONG_SECRET_32_CHARS`
|
||||
- `NEXT_PUBLIC_SITE_URL=https://axilaccountants.co.uk`
|
||||
|
||||
### 33.2 — Docker Compose production override
|
||||
|
||||
- [ ] Create `docker-compose.prod.yml` (production overrides):
|
||||
- App: `NODE_ENV=production`, `restart: always`, no port 3000 exposed externally (only via Nginx)
|
||||
- DB: named volume for persistence, `restart: always`
|
||||
- Remove dev-only volume mounts
|
||||
|
||||
### 33.3 — Nginx reverse proxy
|
||||
|
||||
- [ ] Create `/etc/nginx/sites-available/axil` config:
|
||||
- `server_name axilaccountants.co.uk www.axilaccountants.co.uk`
|
||||
- `proxy_pass http://127.0.0.1:3000`
|
||||
- Gzip, proxy headers, timeouts
|
||||
- [ ] Enable site: `sudo ln -s /etc/nginx/sites-available/axil /etc/nginx/sites-enabled/`
|
||||
- [ ] Obtain SSL certificate: `sudo certbot --nginx -d axilaccountants.co.uk -d www.axilaccountants.co.uk`
|
||||
- [ ] Verify auto-renewal: `sudo certbot renew --dry-run`
|
||||
- [ ] Configure `www` → apex redirect in Nginx (301)
|
||||
|
||||
### 33.4 — Deploy script
|
||||
|
||||
- [ ] Create `scripts/deploy.sh` at repo root:
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd /opt/axil
|
||||
git pull origin main
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build app
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --no-deps app
|
||||
docker image prune -f
|
||||
echo "Deploy complete: $(date)"
|
||||
```
|
||||
- [ ] Make executable: `chmod +x scripts/deploy.sh`
|
||||
- [ ] Test deploy: `./scripts/deploy.sh` — verify site responds after restart
|
||||
|
||||
### 33.5 — Verification
|
||||
|
||||
- [ ] Run production build locally first: `docker compose build && docker compose up` — no errors
|
||||
- [ ] Verify HTTPS works: `curl -I https://axilaccountants.co.uk`
|
||||
- [ ] Run Lighthouse on production URL — confirm scores meet targets
|
||||
- [ ] Set up Vercel Speed Insights (optional)
|
||||
- [ ] Verify DB volume survives container restart (data not lost)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -830,7 +884,7 @@ Allows any Payload-built form to be rendered on the frontend.
|
|||
- How to update integration settings (GA, GTM, Calendly URL)
|
||||
- [ ] **Handover checklist**:
|
||||
- [ ] Client has admin login credentials
|
||||
- [ ] Client has Vercel access (viewer role)
|
||||
- [ ] Client has SSH access to server (or handover to sysadmin)
|
||||
- [ ] Client has Resend account access (for email settings)
|
||||
- [ ] DNS properly pointing to Vercel
|
||||
- [ ] Google Analytics connected and receiving data
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { NextConfig } from 'next';
|
||||
import { withPayload } from '@payloadcms/next/withPayload';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
export default withPayload(nextConfig);
|
||||
|
|
|
|||
16
package.json
16
package.json
|
|
@ -11,17 +11,29 @@
|
|||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@payloadcms/db-postgres": "^3.77.0",
|
||||
"@payloadcms/next": "^3.77.0",
|
||||
"@payloadcms/plugin-form-builder": "^3.77.0",
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"framer-motion": "^12.4.10",
|
||||
"gsap": "^3.14.2",
|
||||
"lenis": "^1.3.17",
|
||||
"lucide-react": "^0.475.0",
|
||||
"next": "15.4.11",
|
||||
"payload": "^3.77.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc-node/register": "^1.11.1",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "15.4.11",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.8.1",
|
||||
|
|
|
|||
4201
pnpm-lock.yaml
generated
4201
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
BIN
public/logo-axil.png
Normal file
BIN
public/logo-axil.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
public/logo-dark.jpg
Normal file
BIN
public/logo-dark.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
public/logo.jpg
Normal file
BIN
public/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
0
public/media/.gitkeep
Normal file
0
public/media/.gitkeep
Normal file
23
scripts/generate-importmap.mjs
Normal file
23
scripts/generate-importmap.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* Custom generate:importmap script that works around tsx ESM resolution issues.
|
||||
* Run via: node --import tsx/esm scripts/generate-importmap.mjs
|
||||
* (from inside the Docker container where tsx is available in pnpm store)
|
||||
*/
|
||||
import { fileURLToPath, pathToFileURL } from 'url'
|
||||
import path from 'path'
|
||||
import { generateImportMap } from '@payloadcms/next/utilities'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const root = path.resolve(__dirname, '..')
|
||||
|
||||
// Load config from compiled dist or source
|
||||
const configPath = pathToFileURL(path.join(root, 'src/payload.config.ts')).toString()
|
||||
|
||||
console.log('Loading config from:', configPath)
|
||||
|
||||
const configModule = await import(configPath)
|
||||
const config = await (configModule.default ?? configModule)
|
||||
|
||||
await generateImportMap(config, { log: true })
|
||||
console.log('Done!')
|
||||
31
src/app/(payload)/admin/[[...segments]]/layout.tsx
Normal file
31
src/app/(payload)/admin/[[...segments]]/layout.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { ServerFunctionClient } from 'payload';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { RootLayout } from '@payloadcms/next/layouts';
|
||||
import { handleServerFunctions } from '@payloadcms/next/layouts';
|
||||
import { importMap } from '@/app/(payload)/admin/importMap';
|
||||
import config from '@payload-config';
|
||||
import React from 'react';
|
||||
|
||||
import '@payloadcms/next/css';
|
||||
|
||||
type Args = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const serverFunction: ServerFunctionClient = async function (args) {
|
||||
'use server';
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Layout({ children }: Args) {
|
||||
return (
|
||||
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
}
|
||||
17
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
src/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { RootPage, generatePageMetadata } from '@payloadcms/next/views';
|
||||
import { importMap } from '@/app/(payload)/admin/importMap';
|
||||
import config from '@payload-config';
|
||||
|
||||
type Args = {
|
||||
params: Promise<{ segments: string[] }>;
|
||||
searchParams: Promise<{ [key: string]: string | string[] }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params, searchParams }: Args): Promise<Metadata> {
|
||||
return generatePageMetadata({ config, params, searchParams });
|
||||
}
|
||||
|
||||
export default function Page({ params, searchParams }: Args) {
|
||||
return RootPage({ config, importMap, params, searchParams });
|
||||
}
|
||||
51
src/app/(payload)/admin/importMap.js
Normal file
51
src/app/(payload)/admin/importMap.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
16
src/app/(payload)/api/[...slug]/route.ts
Normal file
16
src/app/(payload)/api/[...slug]/route.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from '@payloadcms/next/routes';
|
||||
import config from '@payload-config';
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
|
|
@ -1,26 +1,250 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
DESIGN TOKENS — Axil Accountants
|
||||
============================================================ */
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
/* --- Emerald (primary) --- */
|
||||
--color-emerald: #3cc68a;
|
||||
--color-emerald-dark: #27a870;
|
||||
--color-emerald-deeper: #1a8c5b;
|
||||
--color-emerald-light: #7ddcb0;
|
||||
--color-emerald-mist: #e8f8f1;
|
||||
|
||||
/* --- Blue (secondary) --- */
|
||||
--color-blue: #1b9ad6;
|
||||
--color-blue-dark: #1480b8;
|
||||
--color-blue-light: #6cc4e8;
|
||||
--color-blue-mist: #e8f5fc;
|
||||
|
||||
/* --- Neutral --- */
|
||||
--color-charcoal: #162520;
|
||||
--color-muted: #6b7280;
|
||||
--color-bg: #f5fefa;
|
||||
|
||||
/* --- Typography --- */
|
||||
--font-sans: var(--font-inter), 'Inter', ui-sans-serif, sans-serif;
|
||||
--font-display: 'Satoshi', var(--font-inter), ui-sans-serif, sans-serif;
|
||||
--font-mono: var(--font-dm-mono), 'DM Mono', ui-monospace, monospace;
|
||||
|
||||
/* --- Border Radius --- */
|
||||
--radius-card: 16px;
|
||||
--radius-hero: 24px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* --- Custom animations --- */
|
||||
--animate-marquee: marquee 32s linear infinite;
|
||||
--animate-marquee-reverse: marquee-reverse 32s linear infinite;
|
||||
--animate-float: float 6s ease-in-out infinite;
|
||||
--animate-float-delayed: float 6s ease-in-out infinite 2s;
|
||||
--animate-pulse-slow: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
|
||||
/* --- Hero entrance stagger --- */
|
||||
--animate-fade-in-up: fadeInUp 0.75s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
--animate-fade-in-up-d1: fadeInUp 0.75s cubic-bezier(0.22, 1, 0.36, 1) 0.12s both;
|
||||
--animate-fade-in-up-d2: fadeInUp 0.75s cubic-bezier(0.22, 1, 0.36, 1) 0.24s both;
|
||||
--animate-fade-in-up-d3: fadeInUp 0.75s cubic-bezier(0.22, 1, 0.36, 1) 0.36s both;
|
||||
--animate-fade-in-up-d4: fadeInUp 0.75s cubic-bezier(0.22, 1, 0.36, 1) 0.48s both;
|
||||
--animate-fade-in-up-d5: fadeInUp 0.75s cubic-bezier(0.22, 1, 0.36, 1) 0.6s both;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
/* ============================================================
|
||||
KEYFRAMES
|
||||
============================================================ */
|
||||
@keyframes marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@keyframes marquee-reverse {
|
||||
from {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-14px);
|
||||
}
|
||||
}
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(28px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
/* ============================================================
|
||||
CSS CUSTOM PROPERTIES — runtime access
|
||||
============================================================ */
|
||||
:root {
|
||||
--emerald: #3cc68a;
|
||||
--emerald-dark: #27a870;
|
||||
--emerald-deeper: #1a8c5b;
|
||||
--emerald-light: #7ddcb0;
|
||||
--emerald-mist: #e8f8f1;
|
||||
|
||||
--blue: #1b9ad6;
|
||||
--blue-dark: #1480b8;
|
||||
--blue-light: #6cc4e8;
|
||||
--blue-mist: #e8f5fc;
|
||||
|
||||
--charcoal: #162520;
|
||||
--muted: #6b7280;
|
||||
--bg: #f5fefa;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BASE STYLES
|
||||
============================================================ */
|
||||
body {
|
||||
background-color: var(--bg);
|
||||
color: var(--charcoal);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COMPONENT HELPERS
|
||||
============================================================ */
|
||||
@layer components {
|
||||
.hover-glow-emerald {
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
.hover-glow-emerald:hover {
|
||||
box-shadow: 0 0 32px rgb(60 198 138 / 0.14);
|
||||
}
|
||||
.hover-glow-emerald-dark {
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
border-color 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
.hover-glow-emerald-dark:hover {
|
||||
box-shadow: 0 0 40px rgb(60 198 138 / 0.22);
|
||||
}
|
||||
|
||||
/* Marquee overflow container */
|
||||
.marquee-container {
|
||||
overflow: hidden;
|
||||
mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent);
|
||||
-webkit-mask-image: linear-gradient(to right, transparent, black 8%, black 92%, transparent);
|
||||
}
|
||||
.marquee-track {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: var(--animate-marquee);
|
||||
}
|
||||
.marquee-track-reverse {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
animation: var(--animate-marquee-reverse);
|
||||
}
|
||||
.marquee-container:hover .marquee-track,
|
||||
.marquee-container:hover .marquee-track-reverse {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* Dot grid background */
|
||||
.dot-grid {
|
||||
background-image: radial-gradient(circle, rgba(60, 198, 138, 0.13) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--emerald-dark) 0%,
|
||||
var(--emerald) 55%,
|
||||
var(--blue) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ---- InteractiveMenu floating dock ---- */
|
||||
.interactive-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: white;
|
||||
border-radius: 999px;
|
||||
padding: 5px 8px;
|
||||
box-shadow:
|
||||
0 4px 24px rgba(22, 37, 32, 0.12),
|
||||
0 0 0 1px rgba(22, 37, 32, 0.06);
|
||||
}
|
||||
.interactive-menu__item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.25s ease;
|
||||
}
|
||||
.interactive-menu__item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: var(--lineWidth, 0px);
|
||||
height: 2px;
|
||||
background: var(--component-active-color, var(--emerald));
|
||||
border-radius: 999px;
|
||||
transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
.interactive-menu__item.active {
|
||||
background: color-mix(in srgb, var(--component-active-color, var(--emerald)) 12%, transparent);
|
||||
}
|
||||
.interactive-menu__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--muted);
|
||||
transition: color 0.25s ease;
|
||||
}
|
||||
.interactive-menu__item.active .interactive-menu__icon {
|
||||
color: var(--component-active-color, var(--emerald));
|
||||
}
|
||||
.interactive-menu__text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition:
|
||||
max-width 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
opacity 0.2s ease;
|
||||
}
|
||||
.interactive-menu__text.active {
|
||||
max-width: 100px;
|
||||
opacity: 1;
|
||||
color: var(--component-active-color, var(--emerald));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { Inter, DM_Mono } from 'next/font/google';
|
||||
import { SmoothScrollProvider } from '@/components/providers/SmoothScrollProvider';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
const inter = Inter({
|
||||
variable: '--font-inter',
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
const dmMono = DM_Mono({
|
||||
variable: '--font-dm-mono',
|
||||
weight: ['400', '500'],
|
||||
subsets: ['latin'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
title: 'Axil Accountants — Smart Accounting for Growing British Businesses',
|
||||
description:
|
||||
'ICAEW-certified accountants offering bookkeeping, tax returns, payroll and VAT services for UK businesses. Fixed monthly fees, dedicated account manager.',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -24,7 +29,17 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
||||
<head>
|
||||
{/* Satoshi variable font — Fontshare CDN */}
|
||||
<link rel="preconnect" href="https://api.fontshare.com" />
|
||||
<link
|
||||
href="https://api.fontshare.com/v2/css?f[]=satoshi@1,900,700,500,400&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className={`${inter.variable} ${dmMono.variable} antialiased`}>
|
||||
<SmoothScrollProvider>{children}</SmoothScrollProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,33 @@
|
|||
import Image from 'next/image';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { Footer } from '@/components/layout/Footer';
|
||||
import { HeroSection } from '@/components/sections/home/HeroSection';
|
||||
import { PainPointsSection } from '@/components/sections/home/PainPointsSection';
|
||||
import { SolutionSection } from '@/components/sections/home/SolutionSection';
|
||||
import { ServicesSection } from '@/components/sections/home/ServicesSection';
|
||||
import { WhyAxilSection } from '@/components/sections/home/WhyAxilSection';
|
||||
import { TestimonialsSection } from '@/components/sections/home/TestimonialsSection';
|
||||
import { AudienceSection } from '@/components/sections/home/AudienceSection';
|
||||
import { ProcessSection } from '@/components/sections/home/ProcessSection';
|
||||
import { BlogPreviewSection } from '@/components/sections/home/BlogPreviewSection';
|
||||
import { FinalCTASection } from '@/components/sections/home/FinalCTASection';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between bg-white px-16 py-32 sm:items-start dark:bg-black">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl leading-10 font-semibold tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{' '}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{' '}
|
||||
or the{' '}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{' '}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="bg-foreground text-background flex h-12 w-full items-center justify-center gap-2 rounded-full px-5 transition-colors hover:bg-[#383838] md:w-[158px] dark:hover:bg-[#ccc]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] md:w-[158px] dark:border-white/[.145] dark:hover:bg-[#1a1a1a]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<PainPointsSection />
|
||||
<SolutionSection />
|
||||
<ServicesSection />
|
||||
<WhyAxilSection />
|
||||
<TestimonialsSection />
|
||||
<AudienceSection />
|
||||
<ProcessSection />
|
||||
<BlogPreviewSection />
|
||||
<FinalCTASection />
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
276
src/components/layout/Header.tsx
Normal file
276
src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { MenuIcon, XIcon, ChevronDownIcon } from '@/components/ui/icons';
|
||||
import { InteractiveMenu } from '@/components/ui/InteractiveMenu';
|
||||
import { Home, Briefcase, BookOpen, Info, Mail } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{
|
||||
label: 'Services',
|
||||
href: '/services',
|
||||
dropdown: [
|
||||
{
|
||||
label: 'Bookkeeping',
|
||||
href: '/services/bookkeeping',
|
||||
desc: 'Accurate records, zero stress',
|
||||
},
|
||||
{ label: 'Tax Returns', href: '/services/tax-returns', desc: 'Every allowance claimed' },
|
||||
{ label: 'Payroll', href: '/services/payroll', desc: 'On time, every time' },
|
||||
{ label: 'VAT Returns', href: '/services/vat-returns', desc: 'MTD-compliant filing' },
|
||||
],
|
||||
},
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: 'Contact', href: '/contact' },
|
||||
];
|
||||
|
||||
const DOCK_ITEMS = [
|
||||
{ label: 'Home', icon: Home, href: '/' },
|
||||
{ label: 'Services', icon: Briefcase, href: '/services' },
|
||||
{ label: 'Blog', icon: BookOpen, href: '/blog' },
|
||||
{ label: 'About', icon: Info, href: '/about' },
|
||||
{ label: 'Contact', icon: Mail, href: '/contact' },
|
||||
];
|
||||
|
||||
function AxilLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<Image
|
||||
src="/logo-axil.png"
|
||||
alt="Axil Accounting"
|
||||
width={140}
|
||||
height={97}
|
||||
className={cn('w-auto', className)}
|
||||
priority
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nav links shared between static header and floating pill */
|
||||
function NavLinks({
|
||||
pathname,
|
||||
dropdownOpen,
|
||||
setDropdownOpen,
|
||||
size = 'md',
|
||||
}: {
|
||||
pathname: string;
|
||||
dropdownOpen: boolean;
|
||||
setDropdownOpen: (v: boolean) => void;
|
||||
size?: 'sm' | 'md';
|
||||
}) {
|
||||
const textSize = size === 'sm' ? 'text-xs' : 'text-sm';
|
||||
const px = size === 'sm' ? 'px-3' : 'px-4';
|
||||
|
||||
return (
|
||||
<>
|
||||
{NAV_ITEMS.map((item) =>
|
||||
item.dropdown ? (
|
||||
<div
|
||||
key={item.label}
|
||||
className="relative"
|
||||
onMouseEnter={() => setDropdownOpen(true)}
|
||||
onMouseLeave={() => setDropdownOpen(false)}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-pill text-charcoal hover:bg-emerald-mist flex items-center gap-1 py-2 font-medium transition-colors',
|
||||
textSize,
|
||||
px,
|
||||
)}
|
||||
aria-expanded={dropdownOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{item.label}
|
||||
<ChevronDownIcon
|
||||
size={12}
|
||||
className={`transition-transform duration-200 ${dropdownOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<div className="rounded-hero border-charcoal/10 absolute top-full left-0 mt-2 w-60 border bg-white p-2 shadow-xl">
|
||||
{item.dropdown.map((child) => (
|
||||
<Link
|
||||
key={child.label}
|
||||
href={child.href}
|
||||
className="rounded-card hover:bg-emerald-mist flex flex-col px-4 py-3 transition-colors"
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
>
|
||||
<span className="text-charcoal text-sm font-medium">{child.label}</span>
|
||||
<span className="text-muted text-xs">{child.desc}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'rounded-pill hover:bg-emerald-mist py-2 font-medium transition-colors',
|
||||
textSize,
|
||||
px,
|
||||
pathname === item.href ? 'text-emerald font-semibold' : 'text-charcoal',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const lastScrollY = useRef(0);
|
||||
const [atTop, setAtTop] = useState(true);
|
||||
const [floatVisible, setFloatVisible] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [dropdownOpenF, setDropdownOpenF] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
const y = window.scrollY;
|
||||
const direction = y - lastScrollY.current;
|
||||
lastScrollY.current = y;
|
||||
|
||||
if (y < 60) {
|
||||
setAtTop(true);
|
||||
setFloatVisible(false);
|
||||
} else {
|
||||
setAtTop(false);
|
||||
setFloatVisible(direction < 0); // scroll up → show pill
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ─── Static header (visible only at top of page) ─── */}
|
||||
<AnimatePresence>
|
||||
{atTop && (
|
||||
<motion.header
|
||||
key="static-header"
|
||||
initial={{ opacity: 0, y: -16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -16 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="border-charcoal/10 fixed inset-x-0 top-0 z-50 border-b bg-white/90 shadow-sm backdrop-blur-md"
|
||||
>
|
||||
<div className="mx-auto flex h-18 max-w-[1440px] items-center justify-between px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
<Link href="/" aria-label="Axil Accountants — Home">
|
||||
<AxilLogo className="h-13" />
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
<NavLinks
|
||||
pathname={pathname}
|
||||
dropdownOpen={dropdownOpen}
|
||||
setDropdownOpen={setDropdownOpen}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
<div className="hidden items-center gap-3 lg:flex">
|
||||
<Button size="sm" trailingArrow>
|
||||
Book Free Consultation
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="rounded-card text-charcoal hover:bg-emerald-mist flex size-10 items-center justify-center transition-colors lg:hidden"
|
||||
onClick={() => setMobileOpen((v) => !v)}
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
{mobileOpen ? <XIcon size={20} /> : <MenuIcon size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</motion.header>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ─── Floating pill nav (appears on scroll-up) ─── */}
|
||||
<AnimatePresence mode="wait">
|
||||
{floatVisible && (
|
||||
<motion.div
|
||||
key="floating-nav"
|
||||
initial={{ opacity: 0, y: -100 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -100 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-x-0 top-5 z-[5000] flex justify-center px-4"
|
||||
>
|
||||
<div className="border-charcoal/10 flex items-center gap-3 rounded-full border bg-white/95 py-2 pr-2 pl-3 shadow-lg backdrop-blur-md">
|
||||
{/* Logo */}
|
||||
<Link href="/" aria-label="Home" className="mr-1 flex-shrink-0">
|
||||
<AxilLogo className="h-7" />
|
||||
</Link>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="bg-charcoal/10 h-5 w-px" />
|
||||
|
||||
{/* Nav links */}
|
||||
<nav className="hidden items-center lg:flex">
|
||||
<NavLinks
|
||||
pathname={pathname}
|
||||
dropdownOpen={dropdownOpenF}
|
||||
setDropdownOpen={setDropdownOpenF}
|
||||
size="sm"
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{/* CTA */}
|
||||
<Button size="sm" trailingArrow className="ml-1 flex-shrink-0">
|
||||
Book Free Consultation
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* ─── Mobile slide-out menu ─── */}
|
||||
{mobileOpen && (
|
||||
<div className="fixed inset-0 z-40 flex flex-col bg-white pt-18 lg:hidden">
|
||||
<nav className="flex flex-col gap-1 px-4 py-6">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="rounded-card text-charcoal hover:bg-emerald-mist px-4 py-4 text-lg font-medium transition-colors"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-4">
|
||||
<Button size="lg" className="w-full justify-center" trailingArrow>
|
||||
Book Free Consultation
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Mobile floating dock ─── */}
|
||||
<div className="fixed bottom-5 left-1/2 z-40 -translate-x-1/2 lg:hidden">
|
||||
<InteractiveMenu
|
||||
items={DOCK_ITEMS}
|
||||
accentColor="var(--emerald)"
|
||||
onSelect={(_, item) => {
|
||||
if (item.href) window.location.href = item.href;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
src/components/providers/SmoothScrollProvider.tsx
Normal file
28
src/components/providers/SmoothScrollProvider.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Lenis from 'lenis';
|
||||
import { gsap, ScrollTrigger } from '@/lib/gsap';
|
||||
|
||||
export function SmoothScrollProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
lenis.on('scroll', () => ScrollTrigger.update());
|
||||
|
||||
const tick = (time: number) => lenis.raf(time * 1000);
|
||||
gsap.ticker.add(tick);
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
return () => {
|
||||
lenis.destroy();
|
||||
gsap.ticker.remove(tick);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
100
src/components/sections/home/AudienceSection.tsx
Normal file
100
src/components/sections/home/AudienceSection.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import Link from 'next/link';
|
||||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
import { ArrowRightIcon, CheckCircleIcon } from '@/components/ui/icons';
|
||||
|
||||
const AUDIENCES = [
|
||||
{
|
||||
label: 'Sole Traders',
|
||||
title: 'Sole Traders',
|
||||
tagline: 'Self-assessment made simple',
|
||||
body: 'From freelancers to consultants, we handle your self-assessment, expenses, and tax planning so you keep more of what you earn.',
|
||||
perks: ['Self-assessment filing', 'Expense optimisation', 'National Insurance planning'],
|
||||
href: '/services',
|
||||
cta: 'For sole traders',
|
||||
featured: false,
|
||||
},
|
||||
{
|
||||
label: 'Limited Companies',
|
||||
title: 'Limited Companies',
|
||||
tagline: 'Full-service company accounting',
|
||||
body: 'Corporation tax, payroll, dividends, R&D credits — we manage your entire financial picture and help you extract value tax-efficiently.',
|
||||
perks: ['Corporation tax', 'Dividend planning', "Directors' payroll"],
|
||||
href: '/services',
|
||||
cta: 'For limited companies',
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
label: 'Startups',
|
||||
title: 'Startups & Scaleups',
|
||||
tagline: 'Built for fast-growing businesses',
|
||||
body: 'SEIS/EIS compliance, R&D tax credits, investor-ready accounts — we understand startup finance and help you grow without financial friction.',
|
||||
perks: ['R&D tax credits', 'SEIS/EIS compliance', 'Investor accounts'],
|
||||
href: '/services',
|
||||
cta: 'For startups',
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function AudienceSection() {
|
||||
return (
|
||||
<section className="bg-emerald-mist py-24 lg:py-32">
|
||||
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
<FadeIn className="mb-14 text-center">
|
||||
<p className="text-emerald-deeper mb-3 text-sm font-semibold tracking-widest uppercase">
|
||||
Built for businesses like yours
|
||||
</p>
|
||||
<h2 className="font-display text-charcoal text-4xl font-bold sm:text-5xl">
|
||||
Who we work with
|
||||
</h2>
|
||||
</FadeIn>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
{AUDIENCES.map((a, i) => (
|
||||
<FadeIn key={a.label} delay={i * 0.1}>
|
||||
<div
|
||||
className={`rounded-card relative flex h-full flex-col overflow-hidden border bg-white ${a.featured ? 'border-emerald/40 shadow-emerald/8 shadow-lg' : 'border-black/7'}`}
|
||||
>
|
||||
{/* Featured top bar */}
|
||||
{a.featured && (
|
||||
<div className="from-emerald-dark to-emerald h-1 w-full bg-gradient-to-r" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-8">
|
||||
{a.featured && (
|
||||
<span className="rounded-pill bg-emerald mb-5 self-start px-3 py-1 text-xs font-semibold text-white">
|
||||
Most popular
|
||||
</span>
|
||||
)}
|
||||
|
||||
<h3 className="font-display text-charcoal mb-1 text-xl font-bold">{a.title}</h3>
|
||||
<p className="text-emerald mb-4 text-sm font-medium">{a.tagline}</p>
|
||||
<p className="text-muted mb-6 text-sm leading-relaxed">{a.body}</p>
|
||||
|
||||
<ul className="mb-8 flex-1 space-y-2.5">
|
||||
{a.perks.map((perk) => (
|
||||
<li key={perk} className="text-charcoal flex items-center gap-2.5 text-sm">
|
||||
<CheckCircleIcon size={15} color="var(--emerald)" />
|
||||
{perk}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Link
|
||||
href={a.href}
|
||||
className="group text-emerald flex items-center gap-1.5 text-sm font-semibold"
|
||||
>
|
||||
{a.cta}
|
||||
<ArrowRightIcon
|
||||
size={13}
|
||||
className="transition-transform duration-200 group-hover:translate-x-1"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
102
src/components/sections/home/BlogPreviewSection.tsx
Normal file
102
src/components/sections/home/BlogPreviewSection.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import Link from 'next/link';
|
||||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { ArrowRightIcon } from '@/components/ui/icons';
|
||||
|
||||
const POSTS = [
|
||||
{
|
||||
category: 'Tax Tips',
|
||||
title: 'How to Legally Reduce Your Tax Bill in 2026',
|
||||
excerpt:
|
||||
'From pension contributions to trading allowances, these are the most overlooked ways UK business owners cut their tax liability — all HMRC-approved.',
|
||||
date: '14 Feb 2026',
|
||||
readTime: '5 min read',
|
||||
slug: 'reduce-tax-bill-2026',
|
||||
gradient: 'from-emerald/12 to-emerald-mist',
|
||||
tagVariant: 'green' as const,
|
||||
},
|
||||
{
|
||||
category: 'HMRC Updates',
|
||||
title: 'Making Tax Digital: What Every Business Must Know',
|
||||
excerpt:
|
||||
"MTD for income tax is expanding in April 2026. Here's exactly what changes, who's affected, and what you need to do before the deadline.",
|
||||
date: '7 Feb 2026',
|
||||
readTime: '4 min read',
|
||||
slug: 'making-tax-digital-2026',
|
||||
gradient: 'from-blue/10 to-blue-mist',
|
||||
tagVariant: 'blue' as const,
|
||||
},
|
||||
{
|
||||
category: 'Payroll Guide',
|
||||
title: 'Hiring Your First Employee? A Complete Payroll Guide',
|
||||
excerpt:
|
||||
'RTI submissions, auto-enrolment, P60s — setting up payroll correctly from day one avoids costly mistakes and HMRC penalties down the line.',
|
||||
date: '1 Feb 2026',
|
||||
readTime: '6 min read',
|
||||
slug: 'first-employee-payroll-guide',
|
||||
gradient: 'from-emerald/8 to-bg',
|
||||
tagVariant: 'green' as const,
|
||||
},
|
||||
];
|
||||
|
||||
export function BlogPreviewSection() {
|
||||
return (
|
||||
<section className="bg-bg py-24 lg:py-32">
|
||||
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
<FadeIn className="mb-14 flex flex-col items-start justify-between gap-6 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
<p className="text-emerald mb-3 text-sm font-semibold tracking-widest uppercase">
|
||||
From the blog
|
||||
</p>
|
||||
<h2 className="font-display text-charcoal text-4xl font-bold sm:text-5xl">
|
||||
Accounting insights
|
||||
<br />
|
||||
you can actually use
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="ghost" trailingArrow>
|
||||
<Link href="/blog">Visit our blog</Link>
|
||||
</Button>
|
||||
</FadeIn>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
{POSTS.map((post, i) => (
|
||||
<FadeIn key={post.slug} delay={i * 0.1}>
|
||||
<Link href={`/blog/${post.slug}`} className="group block h-full">
|
||||
<article className="rounded-card flex h-full flex-col overflow-hidden border border-black/7 bg-white transition-all duration-300 hover:-translate-y-1 hover:border-black/12 hover:shadow-lg hover:shadow-black/5">
|
||||
{/* Cover */}
|
||||
<div className={`h-44 bg-gradient-to-br ${post.gradient} flex items-end p-5`}>
|
||||
<Tag variant={post.tagVariant}>{post.category}</Tag>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 flex-col p-6">
|
||||
<h3 className="font-display text-charcoal group-hover:text-emerald mb-3 flex-1 text-base leading-snug font-bold transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
<p className="text-muted mb-5 line-clamp-2 text-sm leading-relaxed">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center justify-between border-t border-black/5 pt-4">
|
||||
<div className="text-muted flex items-center gap-2 text-xs">
|
||||
<span>{post.date}</span>
|
||||
<span>·</span>
|
||||
<span>{post.readTime}</span>
|
||||
</div>
|
||||
<ArrowRightIcon
|
||||
size={13}
|
||||
color="var(--emerald)"
|
||||
className="transition-transform duration-200 group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
67
src/components/sections/home/FinalCTASection.tsx
Normal file
67
src/components/sections/home/FinalCTASection.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { CheckCircleIcon } from '@/components/ui/icons';
|
||||
|
||||
const REASSURANCES = [
|
||||
'4.9/5 on Google',
|
||||
'No lock-in contracts',
|
||||
'ICAEW & ACCA Certified',
|
||||
'Free 30-min consultation',
|
||||
];
|
||||
|
||||
export function FinalCTASection() {
|
||||
return (
|
||||
<section className="bg-charcoal relative overflow-hidden py-28 lg:py-36">
|
||||
{/* Background decoration */}
|
||||
<div className="bg-emerald/8 pointer-events-none absolute -top-32 -right-32 size-[500px] rounded-full blur-[120px]" />
|
||||
<div className="bg-emerald-dark/10 pointer-events-none absolute -bottom-24 -left-24 size-[400px] rounded-full blur-[100px]" />
|
||||
<div className="dot-grid pointer-events-none absolute inset-0 opacity-[0.05]" />
|
||||
|
||||
<div className="relative mx-auto max-w-[1440px] px-4 text-center sm:px-6 lg:px-8 xl:px-16">
|
||||
<FadeIn>
|
||||
<p className="text-emerald mb-4 text-sm font-semibold tracking-widest uppercase">
|
||||
Ready to start?
|
||||
</p>
|
||||
|
||||
<h2 className="font-display mx-auto mb-6 max-w-3xl text-4xl leading-tight font-bold text-white sm:text-5xl lg:text-6xl">
|
||||
Take the stress out of
|
||||
<br />
|
||||
your finances — today.
|
||||
</h2>
|
||||
|
||||
<p className="mx-auto mb-10 max-w-xl text-lg leading-relaxed text-white/50">
|
||||
Book a free 30-minute consultation with one of our accountants. No commitment, no hard
|
||||
sell — just honest advice for your business.
|
||||
</p>
|
||||
|
||||
<div className="mb-10 flex flex-wrap items-center justify-center gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="text-charcoal hover:bg-emerald-mist bg-white"
|
||||
trailingArrow
|
||||
>
|
||||
Book a Free Consultation
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
className="border-white/20 text-white hover:border-white/40 hover:bg-white/8"
|
||||
>
|
||||
See Our Services
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Reassurances */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-7 gap-y-2">
|
||||
{REASSURANCES.map((r) => (
|
||||
<span key={r} className="flex items-center gap-1.5 text-sm text-white/40">
|
||||
<CheckCircleIcon size={13} color="var(--emerald)" />
|
||||
{r}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { StarRating } from '@/components/ui/StarRating';
|
||||
import { CheckCircleIcon } from '@/components/ui/icons';
|
||||
import { ContainerScroll } from '@/components/ui/ContainerScroll';
|
||||
|
||||
const TRUST_ITEMS = [
|
||||
'ICAEW Member',
|
||||
|
|
@ -10,16 +12,15 @@ const TRUST_ITEMS = [
|
|||
'No lock-in contracts',
|
||||
];
|
||||
|
||||
/** Financial dashboard mockup shown inside the ContainerScroll 3D card */
|
||||
function DashboardMockup() {
|
||||
function DashboardPreview() {
|
||||
const bars = [40, 58, 44, 76, 52, 88, 68, 82, 96, 72, 90, 100];
|
||||
|
||||
return (
|
||||
<div className="bg-bg h-full w-full overflow-auto p-4 sm:p-6">
|
||||
{/* Dashboard header */}
|
||||
<div className="mb-5 flex items-center justify-between border-b border-black/6 pb-4">
|
||||
<div className="bg-bg p-4">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-center justify-between border-b border-black/6 pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg width="28" height="18" viewBox="0 0 76 44" fill="none">
|
||||
<svg width="22" height="14" viewBox="0 0 76 44" fill="none">
|
||||
<rect x="0" y="2" width="38" height="40" rx="5" fill="#1B9AD6" />
|
||||
<path d="M19 6 L34 40 H4 L19 6Z" fill="white" />
|
||||
<path d="M19 14 L27 32 H11 L19 14Z" fill="#1B9AD6" />
|
||||
|
|
@ -28,52 +29,54 @@ function DashboardMockup() {
|
|||
<rect x="56" y="22" width="7" height="22" rx="2" fill="#3CC68A" />
|
||||
<rect x="67" y="11" width="7" height="33" rx="2" fill="#3CC68A" />
|
||||
</svg>
|
||||
<span className="font-display text-charcoal text-sm font-bold">Axil Client Portal</span>
|
||||
<span className="font-display text-charcoal text-[11px] font-bold">
|
||||
Axil Client Portal
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted text-xs">Tax Year 2024/25</span>
|
||||
<span className="bg-emerald size-2 animate-pulse rounded-full" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted text-[9px]">Tax Year 2024/25</span>
|
||||
<span className="bg-emerald size-1.5 animate-pulse rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="mb-5 grid grid-cols-3 gap-3">
|
||||
<div className="rounded-card bg-emerald-mist p-3">
|
||||
<p className="text-muted mb-1 text-[10px] font-medium tracking-wide uppercase">
|
||||
{/* Stats */}
|
||||
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||
<div className="rounded-card bg-emerald-mist p-2">
|
||||
<p className="text-muted mb-0.5 text-[8px] font-medium tracking-wide uppercase">
|
||||
Revenue YTD
|
||||
</p>
|
||||
<p className="font-display text-emerald text-xl font-bold">£84,200</p>
|
||||
<p className="text-emerald mt-0.5 text-[10px] font-medium">↑ 18% YOY</p>
|
||||
<p className="font-display text-emerald text-sm font-bold">£84,200</p>
|
||||
<p className="text-emerald mt-0.5 text-[8px] font-medium">↑ 18% YOY</p>
|
||||
</div>
|
||||
<div className="rounded-card bg-blue-mist p-3">
|
||||
<p className="text-muted mb-1 text-[10px] font-medium tracking-wide uppercase">
|
||||
<div className="rounded-card bg-blue-mist p-2">
|
||||
<p className="text-muted mb-0.5 text-[8px] font-medium tracking-wide uppercase">
|
||||
Tax Saved
|
||||
</p>
|
||||
<p className="font-display text-blue text-xl font-bold">£12,400</p>
|
||||
<p className="text-blue mt-0.5 text-[10px] font-medium">This year</p>
|
||||
<p className="font-display text-blue text-sm font-bold">£12,400</p>
|
||||
<p className="text-blue mt-0.5 text-[8px] font-medium">This year</p>
|
||||
</div>
|
||||
<div className="rounded-card border border-black/6 bg-white p-3">
|
||||
<p className="text-muted mb-1 text-[10px] font-medium tracking-wide uppercase">
|
||||
<div className="rounded-card border border-black/6 bg-white p-2">
|
||||
<p className="text-muted mb-0.5 text-[8px] font-medium tracking-wide uppercase">
|
||||
Net Profit
|
||||
</p>
|
||||
<p className="font-display text-charcoal text-xl font-bold">£51,800</p>
|
||||
<p className="text-emerald mt-0.5 text-[10px] font-medium">↑ 24% YOY</p>
|
||||
<p className="font-display text-charcoal text-sm font-bold">£51,800</p>
|
||||
<p className="text-emerald mt-0.5 text-[8px] font-medium">↑ 24% YOY</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly bar chart */}
|
||||
<div className="rounded-card mb-5 border border-black/6 bg-white p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="text-charcoal text-xs font-semibold">Monthly Revenue</p>
|
||||
<span className="rounded-pill bg-emerald-mist text-emerald px-2 py-0.5 text-[10px] font-medium">
|
||||
{/* Bar chart */}
|
||||
<div className="rounded-card mb-3 border border-black/6 bg-white p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-charcoal text-[10px] font-semibold">Monthly Revenue</p>
|
||||
<span className="rounded-pill bg-emerald-mist text-emerald px-1.5 py-0.5 text-[8px] font-medium">
|
||||
Apr 24 – Mar 25
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex h-20 items-end gap-1">
|
||||
<div className="flex h-12 items-end gap-0.5">
|
||||
{bars.map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t-sm transition-all"
|
||||
className="flex-1 rounded-t-sm"
|
||||
style={{
|
||||
height: `${h}%`,
|
||||
background:
|
||||
|
|
@ -86,30 +89,29 @@ function DashboardMockup() {
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between">
|
||||
<span className="text-muted text-[9px]">Apr</span>
|
||||
<span className="text-muted text-[9px]">Mar</span>
|
||||
<div className="mt-1 flex justify-between">
|
||||
<span className="text-muted text-[8px]">Apr</span>
|
||||
<span className="text-muted text-[8px]">Mar</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming tasks */}
|
||||
<div className="space-y-2">
|
||||
{/* Tasks */}
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ label: 'VAT Return Q4', date: '7 May', done: false },
|
||||
{ label: 'Payroll — April', date: '30 Apr', done: true },
|
||||
{ label: 'Corporation Tax filed', date: '15 Apr', done: true },
|
||||
{ label: 'Self Assessment review', date: '31 Jan', done: true },
|
||||
].map((t) => (
|
||||
<div
|
||||
key={t.label}
|
||||
className="rounded-card flex items-center justify-between border border-black/5 bg-white px-3 py-2"
|
||||
className="rounded-card flex items-center justify-between border border-black/5 bg-white px-2.5 py-1.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`size-1.5 rounded-full ${t.done ? 'bg-emerald' : 'bg-blue'}`} />
|
||||
<span className="text-charcoal text-xs">{t.label}</span>
|
||||
<span className="text-charcoal text-[10px]">{t.label}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-pill px-2.5 py-0.5 text-[10px] font-medium ${
|
||||
className={`rounded-pill px-2 py-0.5 text-[8px] font-medium ${
|
||||
t.done ? 'bg-emerald-mist text-emerald' : 'bg-blue-mist text-blue'
|
||||
}`}
|
||||
>
|
||||
|
|
@ -122,75 +124,137 @@ function DashboardMockup() {
|
|||
);
|
||||
}
|
||||
|
||||
function HeroTitle() {
|
||||
return (
|
||||
<div className="px-4 pt-24 pb-10">
|
||||
{/* Eyebrow */}
|
||||
<div className="animate-fade-in-up mb-6 flex justify-center">
|
||||
<div className="rounded-pill border-emerald/25 bg-emerald/8 flex items-center gap-1.5 border px-3.5 py-1.5">
|
||||
<span className="bg-emerald size-1.5 rounded-full" />
|
||||
<span className="text-emerald-dark text-xs font-semibold">
|
||||
Trusted by 500+ UK Businesses
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="animate-fade-in-up-d1 font-display text-charcoal mx-auto mb-6 max-w-3xl text-center text-[clamp(2.4rem,5vw,4.5rem)] leading-[1.04] font-bold tracking-tight">
|
||||
Smart Accounting for
|
||||
<br />
|
||||
Growing <span className="gradient-text">British Businesses</span>
|
||||
</h1>
|
||||
|
||||
<p className="animate-fade-in-up-d2 text-muted mx-auto mb-8 max-w-xl text-center text-lg leading-relaxed">
|
||||
ICAEW-certified accountants with fixed monthly fees, a dedicated account manager, and zero
|
||||
HMRC surprises. You focus on growth — we handle the numbers.
|
||||
</p>
|
||||
|
||||
<div className="animate-fade-in-up-d3 mb-8 flex flex-wrap justify-center gap-3">
|
||||
<Button size="lg" trailingArrow>
|
||||
Book a Free Consultation
|
||||
</Button>
|
||||
<Button size="lg" variant="secondary">
|
||||
See Our Services
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Trust indicators */}
|
||||
<div className="animate-fade-in-up-d4 flex flex-wrap items-center justify-center gap-x-5 gap-y-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StarRating rating={5} size="sm" />
|
||||
<span className="text-charcoal text-sm font-semibold">4.9/5</span>
|
||||
<span className="text-muted text-sm">Google</span>
|
||||
</div>
|
||||
<div className="hidden h-3.5 w-px bg-black/12 sm:block" />
|
||||
{TRUST_ITEMS.map((item) => (
|
||||
<span key={item} className="text-muted flex items-center gap-1.5 text-sm">
|
||||
<CheckCircleIcon size={13} color="var(--emerald)" />
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="bg-bg relative mt-18 overflow-hidden">
|
||||
{/* Background gradients */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_60%_at_50%_20%,var(--color-emerald-mist)_0%,transparent_70%)]" />
|
||||
<section className="bg-bg relative mt-18 flex min-h-[calc(100vh-4.5rem)] flex-col overflow-x-hidden">
|
||||
{/* Background */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_80%_70%_at_55%_45%,var(--color-emerald-mist)_0%,transparent_65%)]" />
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="dot-grid h-full w-full opacity-30" />
|
||||
<div className="dot-grid h-full w-full opacity-20" />
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<ContainerScroll titleComponent={<HeroTitle />}>
|
||||
<DashboardMockup />
|
||||
</ContainerScroll>
|
||||
{/* Main 3-column layout */}
|
||||
<div className="relative mx-auto flex w-full max-w-[1440px] flex-1 items-center px-4 py-10 sm:px-6 lg:px-8 xl:px-16">
|
||||
<div className="grid w-full grid-cols-1 items-center gap-10 lg:grid-cols-[1fr_1.05fr_1fr]">
|
||||
{/* ─── Left: copy + CTA ─── */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -28 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1], delay: 0.65 }}
|
||||
className="order-2 lg:order-1"
|
||||
>
|
||||
{/* Mobile headline (hidden on desktop) */}
|
||||
<h1 className="font-display text-charcoal mb-5 text-[clamp(2.4rem,8vw,3.5rem)] leading-[1.04] font-bold tracking-tight lg:hidden">
|
||||
Smart Accounting for <span className="gradient-text">British Businesses</span>
|
||||
</h1>
|
||||
|
||||
{/* Eyebrow */}
|
||||
<div className="rounded-pill border-emerald/25 bg-emerald/8 mb-5 inline-flex items-center gap-1.5 border px-3.5 py-1.5">
|
||||
<span className="bg-emerald size-1.5 rounded-full" />
|
||||
<span className="text-emerald-dark text-xs font-semibold">
|
||||
Trusted by 500+ UK Businesses
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-muted mb-7 max-w-sm text-base leading-relaxed">
|
||||
ICAEW-certified accountants with fixed monthly fees, a dedicated account manager, and
|
||||
zero HMRC surprises.
|
||||
</p>
|
||||
|
||||
<div className="mb-7 flex flex-wrap gap-3">
|
||||
<Button size="lg" trailingArrow>
|
||||
Book a Free Consultation
|
||||
</Button>
|
||||
<Button size="lg" variant="secondary">
|
||||
See Our Services
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating rating={5} size="sm" />
|
||||
<span className="text-charcoal text-sm font-semibold">4.9/5</span>
|
||||
<span className="text-muted text-sm">Google Reviews</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* ─── Centre: circles + dashboard ─── */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.9, ease: [0.22, 1, 0.36, 1], delay: 0.15 }}
|
||||
className="relative order-1 flex items-center justify-center lg:order-2"
|
||||
>
|
||||
{/* Concentric circles */}
|
||||
<div className="bg-emerald/8 absolute size-[360px] rounded-full sm:size-[420px] lg:size-[460px]" />
|
||||
<div className="bg-emerald/12 absolute size-[280px] rounded-full sm:size-[320px] lg:size-[360px]" />
|
||||
|
||||
{/* Dashboard card */}
|
||||
<div
|
||||
className="relative z-10 w-[290px] overflow-hidden rounded-[22px] border border-black/8 bg-white sm:w-[330px]"
|
||||
style={{
|
||||
boxShadow:
|
||||
'0 0 #0000004d, 0 9px 20px #0000004a, 0 37px 37px #00000042, 0 84px 50px #00000026, 0 149px 60px #0000000a',
|
||||
}}
|
||||
>
|
||||
<DashboardPreview />
|
||||
</div>
|
||||
|
||||
{/* Floating stat — top right */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 16, y: -8 }}
|
||||
animate={{ opacity: 1, x: 0, y: 0 }}
|
||||
transition={{ delay: 1.0, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="rounded-hero absolute top-6 -right-2 z-20 border border-black/8 bg-white px-3 py-2 shadow-lg sm:-right-6"
|
||||
>
|
||||
<p className="text-muted text-[9px] font-medium tracking-wide uppercase">
|
||||
Avg Tax Saved
|
||||
</p>
|
||||
<p className="font-display text-emerald text-base font-bold">£4,200/yr</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Floating stat — bottom left */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -16, y: 8 }}
|
||||
animate={{ opacity: 1, x: 0, y: 0 }}
|
||||
transition={{ delay: 1.1, duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="rounded-hero absolute bottom-10 -left-2 z-20 border border-black/8 bg-white px-3 py-2 shadow-lg sm:-left-6"
|
||||
>
|
||||
<p className="text-muted text-[9px] font-medium tracking-wide uppercase">
|
||||
Response Time
|
||||
</p>
|
||||
<p className="font-display text-charcoal text-base font-bold">< 4 hrs</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* ─── Right: large display headline (desktop only) ─── */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 28 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1], delay: 0.45 }}
|
||||
className="order-3 hidden lg:block"
|
||||
>
|
||||
<h1 className="font-display text-charcoal text-[clamp(3.2rem,4.6vw,6rem)] leading-[0.92] font-bold tracking-tight">
|
||||
Smart
|
||||
<br />
|
||||
<span className="gradient-text">Accounting</span>
|
||||
</h1>
|
||||
<p className="text-muted mt-4 text-xs font-semibold tracking-[0.2em] uppercase">
|
||||
for British Business
|
||||
</p>
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{TRUST_ITEMS.map((item) => (
|
||||
<span key={item} className="text-muted flex items-center gap-2 text-sm">
|
||||
<CheckCircleIcon size={13} color="var(--emerald)" />
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust strip */}
|
||||
<div className="relative z-10 border-t border-black/6 bg-white/70 backdrop-blur-sm">
|
||||
<div className="relative border-t border-black/6 bg-white/70 backdrop-blur-sm">
|
||||
<div className="mx-auto flex max-w-[1440px] flex-wrap items-center justify-between gap-4 px-4 py-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
<span className="text-charcoal text-sm font-semibold">🇬🇧 UK-Based Team</span>
|
||||
<div className="hidden h-4 w-px bg-black/10 sm:block" />
|
||||
|
|
|
|||
68
src/components/sections/home/HowItWorksSection.tsx
Normal file
68
src/components/sections/home/HowItWorksSection.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'Book a free call',
|
||||
body: 'Tell us about your business in a 30-minute call. No obligation, no hard sell — just an honest conversation about what you need.',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'Get your tailored plan',
|
||||
body: 'We put together a fixed-price proposal covering everything you need. Onboarding takes 48 hours and we handle the transition from your old accountant.',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'We handle everything',
|
||||
body: 'From that point on, your accounts, tax, and compliance are handled. You get a real-time dashboard and a dedicated manager just a message away.',
|
||||
},
|
||||
];
|
||||
|
||||
export function HowItWorksSection() {
|
||||
return (
|
||||
<section className="bg-white py-24 lg:py-32">
|
||||
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
<FadeIn className="mb-16 flex flex-col items-start justify-between gap-6 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
<p className="text-emerald mb-3 text-sm font-semibold tracking-widest uppercase">
|
||||
Simple process
|
||||
</p>
|
||||
<h2 className="font-display text-charcoal text-4xl font-bold sm:text-5xl">
|
||||
Getting started
|
||||
<br />
|
||||
takes 3 steps
|
||||
</h2>
|
||||
</div>
|
||||
<Button trailingArrow>Book a Free Consultation</Button>
|
||||
</FadeIn>
|
||||
|
||||
<div className="grid grid-cols-1 gap-0 border border-black/7 sm:grid-cols-3">
|
||||
{STEPS.map((s, i) => (
|
||||
<FadeIn key={s.number} delay={i * 0.1} className="h-full">
|
||||
<div
|
||||
className={`relative flex h-full flex-col p-8 lg:p-10 ${i < STEPS.length - 1 ? 'border-b border-black/7 sm:border-r sm:border-b-0' : ''}`}
|
||||
>
|
||||
{/* Step number */}
|
||||
<div className="mb-8 flex items-center gap-4">
|
||||
<div className="from-emerald-dark to-emerald shadow-emerald/20 flex size-12 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br shadow-md">
|
||||
<span className="font-mono text-sm font-bold text-white">{s.number}</span>
|
||||
</div>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div className="from-emerald/30 hidden h-px flex-1 bg-gradient-to-r to-transparent sm:block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="font-display text-charcoal mb-3 text-xl font-bold">{s.title}</h3>
|
||||
<p className="text-muted text-sm leading-relaxed">{s.body}</p>
|
||||
|
||||
{/* Mobile connector */}
|
||||
{i < STEPS.length - 1 && <div className="bg-emerald/20 mt-8 h-6 w-px sm:hidden" />}
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
83
src/components/sections/home/PainPointsSection.tsx
Normal file
83
src/components/sections/home/PainPointsSection.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
|
||||
const PAINS = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'HMRC penalties for missed deadlines',
|
||||
body: 'Self-assessment, VAT, corporation tax — the dates stack up. One missed deadline can cost hundreds in fines.',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'Tax rules that change every year',
|
||||
body: 'Making Tax Digital, dividend tax hikes, NICs changes — staying compliant feels like a full-time job.',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'Hours lost on bookkeeping',
|
||||
body: 'Reconciling bank feeds, chasing receipts, producing reports — time you should be spending on your business.',
|
||||
},
|
||||
];
|
||||
|
||||
export function PainPointsSection() {
|
||||
return (
|
||||
<section className="bg-charcoal relative overflow-hidden py-24 lg:py-32">
|
||||
{/* Subtle dot grid */}
|
||||
<div className="dot-grid pointer-events-none absolute inset-0 opacity-[0.06]" />
|
||||
|
||||
<div className="relative mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
{/* Header */}
|
||||
<FadeIn className="mb-16 max-w-3xl">
|
||||
<p className="text-emerald mb-4 text-sm font-semibold tracking-widest uppercase">
|
||||
The problem
|
||||
</p>
|
||||
<h2 className="font-display text-4xl leading-tight font-bold text-white sm:text-5xl lg:text-[3.5rem]">
|
||||
Most businesses are losing
|
||||
<br />
|
||||
money to <span className="text-emerald">bad accounting.</span>
|
||||
</h2>
|
||||
</FadeIn>
|
||||
|
||||
{/* Editorial 73% stat */}
|
||||
<FadeIn
|
||||
className="border-emerald/40 mb-16 flex flex-col gap-6 border-l-2 pl-8 sm:flex-row sm:items-center sm:gap-14 sm:pl-12"
|
||||
delay={0.1}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
<p className="text-emerald font-mono text-[80px] leading-none font-bold sm:text-[108px]">
|
||||
73<span className="text-5xl sm:text-6xl">%</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg leading-relaxed text-white/60 sm:max-w-xs">
|
||||
of UK small businesses overpay tax due to unclaimed allowances and poor financial
|
||||
planning.
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-white/25">
|
||||
* HMRC data and independent SME research, 2024
|
||||
</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
|
||||
{/* Pain cards — editorial grid */}
|
||||
<div className="grid grid-cols-1 border border-white/8 sm:grid-cols-3">
|
||||
{PAINS.map((p, i) => (
|
||||
<FadeIn key={p.number} delay={i * 0.08} className="h-full">
|
||||
<div
|
||||
className={`flex h-full flex-col p-8 lg:p-10 ${i < PAINS.length - 1 ? 'border-b border-white/8 sm:border-r sm:border-b-0' : ''}`}
|
||||
>
|
||||
<p className="text-emerald/50 mb-5 font-mono text-xs font-medium tracking-widest uppercase">
|
||||
{p.number}
|
||||
</p>
|
||||
<div className="bg-emerald/30 mb-5 h-px w-10" />
|
||||
<h3 className="font-display mb-4 text-lg leading-snug font-semibold text-white">
|
||||
{p.title}
|
||||
</h3>
|
||||
<p className="mt-auto text-sm leading-relaxed text-white/45">{p.body}</p>
|
||||
</div>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
97
src/components/sections/home/ProcessSection.tsx
Normal file
97
src/components/sections/home/ProcessSection.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import { Phone, Zap, FileCheck, TrendingUp, ArrowUpRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { SpotlightCard } from '@/components/ui/SpotlightCard';
|
||||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
|
||||
interface ProcessItem {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PROCESS_ITEMS: ProcessItem[] = [
|
||||
{
|
||||
icon: Phone,
|
||||
title: 'Free Consultation',
|
||||
description:
|
||||
'Tell us about your business, current finances and goals. No pressure, no commitment — just an honest conversation.',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Quick Onboarding',
|
||||
description:
|
||||
'We migrate your accounts and set up your Axil dashboard in under a week, with zero disruption to your business.',
|
||||
},
|
||||
{
|
||||
icon: FileCheck,
|
||||
title: 'We Handle Everything',
|
||||
description:
|
||||
'Bookkeeping, tax, payroll, VAT — handled every month with zero input from you, filed on time, every time.',
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'You Focus on Growth',
|
||||
description:
|
||||
'Monthly reports, proactive tax advice, and a dedicated account manager who knows your business inside out.',
|
||||
},
|
||||
];
|
||||
|
||||
function ProcessCard({ icon: Icon, title, description }: ProcessItem) {
|
||||
return (
|
||||
<SpotlightCard
|
||||
className="group relative w-full cursor-pointer p-6 transition-all duration-300"
|
||||
glowColor="green"
|
||||
customSize
|
||||
>
|
||||
{/* Decorative connector line — visible on larger screens */}
|
||||
<div className="group-hover:bg-emerald/60 absolute top-1/2 -left-px hidden h-1/2 w-px -translate-y-1/2 bg-black/8 transition-colors md:block" />
|
||||
<div className="group-hover:bg-emerald/60 absolute top-0 left-1/2 h-px w-1/2 -translate-x-1/2 bg-black/8 transition-colors md:hidden" />
|
||||
|
||||
{/* Icon container */}
|
||||
<div className="rounded-card bg-bg text-emerald group-hover:bg-emerald mb-4 flex h-12 w-12 items-center justify-center border border-black/8 shadow-sm transition-colors duration-300 group-hover:text-white">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<h3 className="font-display text-charcoal mb-1 text-lg font-semibold">{title}</h3>
|
||||
<p className="text-muted text-sm leading-relaxed">{description}</p>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProcessSection() {
|
||||
return (
|
||||
<section className="w-full bg-white py-16 md:py-24">
|
||||
<div className="mx-auto grid grid-cols-1 gap-12 px-4 sm:px-6 md:grid-cols-3 md:gap-8 lg:gap-16 lg:px-8 xl:max-w-[1440px] xl:px-16">
|
||||
{/* Left: heading + description + CTA */}
|
||||
<FadeIn className="flex flex-col items-start justify-center text-center md:col-span-1 md:text-left">
|
||||
<span className="text-emerald mb-2 text-sm font-semibold tracking-widest uppercase">
|
||||
How we do it
|
||||
</span>
|
||||
<h2 className="font-display text-charcoal mb-4 text-3xl font-bold tracking-tight md:text-4xl">
|
||||
From sign-up to sorted in days
|
||||
</h2>
|
||||
<p className="text-muted mb-6 text-base leading-relaxed">
|
||||
We've made switching accountants effortless. Most clients are fully onboarded
|
||||
within a week — then we handle everything so you never have to think about tax again.
|
||||
</p>
|
||||
<Button size="lg" className="transition-transform duration-300 hover:scale-[1.04]">
|
||||
Get started
|
||||
<ArrowUpRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
</FadeIn>
|
||||
|
||||
{/* Right: 2×2 grid of SpotlightCards */}
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-10 sm:grid-cols-2 md:col-span-2">
|
||||
{PROCESS_ITEMS.map((item, i) => (
|
||||
<FadeIn key={item.title} delay={i * 0.1}>
|
||||
<ProcessCard {...item} />
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
129
src/components/sections/home/ServicesSection.tsx
Normal file
129
src/components/sections/home/ServicesSection.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import Link from 'next/link';
|
||||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
BookkeepingIcon,
|
||||
TaxIcon,
|
||||
PayrollIcon,
|
||||
VATIcon,
|
||||
} from '@/components/ui/icons';
|
||||
|
||||
const SERVICES = [
|
||||
{
|
||||
icon: BookkeepingIcon,
|
||||
title: 'Bookkeeping',
|
||||
tagline: 'Accurate records, zero stress',
|
||||
description:
|
||||
'Bank reconciliation, expense categorisation, monthly management accounts — all handled so you always know where you stand.',
|
||||
href: '/services/bookkeeping',
|
||||
accent: 'emerald' as const,
|
||||
},
|
||||
{
|
||||
icon: TaxIcon,
|
||||
title: 'Tax Returns',
|
||||
tagline: 'Every allowance claimed',
|
||||
description:
|
||||
'Self-assessment, corporation tax, R&D credits — we file on time and make sure you never pay a penny more than you owe.',
|
||||
href: '/services/tax-returns',
|
||||
accent: 'blue' as const,
|
||||
},
|
||||
{
|
||||
icon: PayrollIcon,
|
||||
title: 'Payroll',
|
||||
tagline: 'On time, every time',
|
||||
description:
|
||||
'RTI submissions, P60s, auto-enrolment — we manage your entire payroll cycle so your team gets paid correctly and on schedule.',
|
||||
href: '/services/payroll',
|
||||
accent: 'emerald' as const,
|
||||
},
|
||||
{
|
||||
icon: VATIcon,
|
||||
title: 'VAT Returns',
|
||||
tagline: 'MTD-compliant filing',
|
||||
description:
|
||||
'Making Tax Digital from day one. We prepare, review, and file your VAT returns accurately and on time, every quarter.',
|
||||
href: '/services/vat-returns',
|
||||
accent: 'blue' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const accentMap = {
|
||||
emerald: {
|
||||
bar: 'bg-gradient-to-r from-emerald-dark to-emerald',
|
||||
iconBg: 'bg-emerald/10',
|
||||
iconColor: 'var(--emerald)',
|
||||
tagline: 'text-emerald',
|
||||
},
|
||||
blue: {
|
||||
bar: 'bg-gradient-to-r from-blue-dark to-blue',
|
||||
iconBg: 'bg-blue/10',
|
||||
iconColor: 'var(--blue)',
|
||||
tagline: 'text-blue',
|
||||
},
|
||||
};
|
||||
|
||||
export function ServicesSection() {
|
||||
return (
|
||||
<section className="bg-white py-24 lg:py-32">
|
||||
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
{/* Header */}
|
||||
<FadeIn className="mb-14 flex flex-col items-start justify-between gap-6 sm:flex-row sm:items-end">
|
||||
<div>
|
||||
<p className="text-emerald mb-3 text-sm font-semibold tracking-widest uppercase">
|
||||
What we do
|
||||
</p>
|
||||
<h2 className="font-display text-charcoal text-4xl font-bold sm:text-5xl">
|
||||
Everything your
|
||||
<br />
|
||||
business needs
|
||||
</h2>
|
||||
</div>
|
||||
<Button variant="ghost" trailingArrow href="/services">
|
||||
View all services
|
||||
</Button>
|
||||
</FadeIn>
|
||||
|
||||
{/* Card grid */}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{SERVICES.map((s, i) => {
|
||||
const Icon = s.icon;
|
||||
const colors = accentMap[s.accent];
|
||||
return (
|
||||
<FadeIn key={s.title} delay={i * 0.08}>
|
||||
<Link href={s.href} className="group block h-full">
|
||||
<div className="rounded-card flex h-full flex-col overflow-hidden border border-black/7 bg-white transition-all duration-300 hover:-translate-y-1 hover:border-black/12 hover:shadow-lg hover:shadow-black/6">
|
||||
{/* Coloured top bar */}
|
||||
<div className={`h-1 w-full ${colors.bar}`} />
|
||||
<div className="flex flex-1 flex-col p-7">
|
||||
{/* Icon container */}
|
||||
<div
|
||||
className={`mb-5 flex size-12 items-center justify-center rounded-xl ${colors.iconBg}`}
|
||||
>
|
||||
<Icon size={26} color={colors.iconColor} />
|
||||
</div>
|
||||
<p className="font-display text-charcoal mb-1 text-xl font-semibold">
|
||||
{s.title}
|
||||
</p>
|
||||
<p className={`mb-4 text-sm font-medium ${colors.tagline}`}>{s.tagline}</p>
|
||||
<p className="text-muted mb-6 flex-1 text-sm leading-relaxed">
|
||||
{s.description}
|
||||
</p>
|
||||
<div className="text-charcoal/60 group-hover:text-emerald flex items-center gap-1.5 text-sm font-semibold transition-colors">
|
||||
Learn more
|
||||
<ArrowRightIcon
|
||||
size={13}
|
||||
className="transition-transform duration-200 group-hover:translate-x-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</FadeIn>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
81
src/components/sections/home/SolutionSection.tsx
Normal file
81
src/components/sections/home/SolutionSection.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
import { CheckCircleIcon } from '@/components/ui/icons';
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
number: '01',
|
||||
title: 'Fixed monthly fee',
|
||||
body: 'No surprise invoices. You know exactly what you pay on the 1st of every month — nothing more, ever.',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'Your dedicated account manager',
|
||||
body: 'A real person who knows your business, answers your calls, and proactively saves you money.',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: '100% cloud-based',
|
||||
body: 'All your accounts in one place, accessible anytime. We connect to your bank and automate the boring parts.',
|
||||
},
|
||||
];
|
||||
|
||||
const CHECKLIST = [
|
||||
'Onboarded within 48 hours',
|
||||
'We handle the HMRC transition',
|
||||
'MTD-compliant from day one',
|
||||
'Real-time financial dashboard',
|
||||
];
|
||||
|
||||
export function SolutionSection() {
|
||||
return (
|
||||
<section className="bg-bg py-24 lg:py-32">
|
||||
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
<div className="grid grid-cols-1 items-start gap-16 lg:grid-cols-2">
|
||||
{/* Left — text */}
|
||||
<FadeIn x={-20} y={0}>
|
||||
<p className="text-emerald mb-4 text-sm font-semibold tracking-widest uppercase">
|
||||
The Axil way
|
||||
</p>
|
||||
<h2 className="font-display text-charcoal mb-6 text-4xl leading-tight font-bold sm:text-5xl">
|
||||
We found a<br />
|
||||
<span className="gradient-text">better way</span>
|
||||
<br />
|
||||
to do accounting.
|
||||
</h2>
|
||||
<p className="text-muted mb-8 text-lg leading-relaxed">
|
||||
Proactive, transparent, and technology-first. We don't wait for you to ask — we
|
||||
spot savings, flag issues, and keep you compliant before problems arise.
|
||||
</p>
|
||||
<ul className="space-y-3.5">
|
||||
{CHECKLIST.map((item) => (
|
||||
<li key={item} className="text-charcoal flex items-center gap-3">
|
||||
<CheckCircleIcon size={18} color="var(--emerald)" />
|
||||
<span className="text-base font-medium">{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</FadeIn>
|
||||
|
||||
{/* Right — numbered features */}
|
||||
<FadeIn x={20} y={0} delay={0.15}>
|
||||
<div className="rounded-card flex flex-col divide-y divide-black/6 border border-black/7 bg-white">
|
||||
{FEATURES.map((f) => (
|
||||
<div key={f.title} className="flex gap-5 p-7">
|
||||
<span className="text-emerald/60 mt-0.5 shrink-0 font-mono text-sm font-medium">
|
||||
{f.number}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-display text-charcoal mb-1.5 text-base font-semibold">
|
||||
{f.title}
|
||||
</h3>
|
||||
<p className="text-muted text-sm leading-relaxed">{f.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
115
src/components/sections/home/TestimonialsSection.tsx
Normal file
115
src/components/sections/home/TestimonialsSection.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { TestimonialsColumn, type TestimonialItem } from '@/components/ui/TestimonialsColumn';
|
||||
import { Tag } from '@/components/ui/Tag';
|
||||
import { StarRating } from '@/components/ui/StarRating';
|
||||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
|
||||
const TESTIMONIALS: TestimonialItem[] = [
|
||||
{
|
||||
name: 'Sarah T.',
|
||||
role: 'Limited Company Director',
|
||||
image: 'https://ui-avatars.com/api/?name=Sarah+T&background=3CC68A&color=fff&bold=true&size=80',
|
||||
text: 'Axil saved us over £8,000 in our first year alone. They spotted allowances our previous accountant had missed for three years running.',
|
||||
},
|
||||
{
|
||||
name: 'James K.',
|
||||
role: 'Sole Trader',
|
||||
image: 'https://ui-avatars.com/api/?name=James+K&background=1B9AD6&color=fff&bold=true&size=80',
|
||||
text: 'Finally an accountant who speaks plain English. I actually understand my finances now, and my tax bill has never been lower.',
|
||||
},
|
||||
{
|
||||
name: 'Emma R.',
|
||||
role: 'Startup Founder',
|
||||
image: 'https://ui-avatars.com/api/?name=Emma+R&background=27A870&color=fff&bold=true&size=80',
|
||||
text: "Payroll used to take me half a day every month. Now it takes zero minutes. Axil handles it completely and it's always perfect.",
|
||||
},
|
||||
{
|
||||
name: 'Michael B.',
|
||||
role: 'Limited Company Director',
|
||||
image:
|
||||
'https://ui-avatars.com/api/?name=Michael+B&background=1480B8&color=fff&bold=true&size=80',
|
||||
text: 'The dedicated account manager is worth every penny. She proactively flagged a VAT issue that would have cost us £4,000 in penalties.',
|
||||
},
|
||||
{
|
||||
name: 'David O.',
|
||||
role: 'Sole Trader',
|
||||
image: 'https://ui-avatars.com/api/?name=David+O&background=3CC68A&color=fff&bold=true&size=80',
|
||||
text: 'Switched from a Big 4 firm to Axil and never looked back. Same quality, half the price, ten times more personal service.',
|
||||
},
|
||||
{
|
||||
name: 'Rachel M.',
|
||||
role: 'E-commerce Brand Owner',
|
||||
image:
|
||||
'https://ui-avatars.com/api/?name=Rachel+M&background=7DDCB0&color=162520&bold=true&size=80',
|
||||
text: 'MTD compliance, quarterly VAT, company accounts — Axil handles everything and I get a clean dashboard showing exactly how my business is doing.',
|
||||
},
|
||||
{
|
||||
name: 'Tom H.',
|
||||
role: 'Consulting Ltd',
|
||||
image: 'https://ui-avatars.com/api/?name=Tom+H&background=1B9AD6&color=fff&bold=true&size=80',
|
||||
text: 'I was dreading my first year of corporation tax as a limited company. Axil made it completely painless and saved me significantly on my first filing.',
|
||||
},
|
||||
{
|
||||
name: 'Priya S.',
|
||||
role: 'Retail Business Owner',
|
||||
image: 'https://ui-avatars.com/api/?name=Priya+S&background=27A870&color=fff&bold=true&size=80',
|
||||
text: "Three years with Axil and I've recommended them to five other business owners. Genuinely the best decision I made when starting my company.",
|
||||
},
|
||||
];
|
||||
|
||||
const col1 = TESTIMONIALS.slice(0, 3);
|
||||
const col2 = TESTIMONIALS.slice(3, 6);
|
||||
const col3 = [...TESTIMONIALS.slice(6, 8), TESTIMONIALS[0]];
|
||||
|
||||
export function TestimonialsSection() {
|
||||
return (
|
||||
<section className="bg-bg overflow-hidden py-24 lg:py-32">
|
||||
<div className="mx-auto mb-14 max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
{/* Centred header */}
|
||||
<FadeIn>
|
||||
<div className="flex flex-col items-center gap-5 text-center">
|
||||
<p className="text-emerald text-sm font-semibold tracking-widest uppercase">
|
||||
Client stories
|
||||
</p>
|
||||
<h2 className="font-display text-charcoal text-4xl font-bold sm:text-5xl">
|
||||
What our clients say
|
||||
</h2>
|
||||
<p className="text-muted max-w-xl text-lg">
|
||||
Over 500 UK businesses trust Axil with their finances. Here's what they have to
|
||||
say.
|
||||
</p>
|
||||
|
||||
<div className="rounded-hero flex items-center gap-3 border border-black/8 bg-white px-5 py-3 shadow-sm">
|
||||
<StarRating rating={5} />
|
||||
<div>
|
||||
<p className="text-charcoal text-sm font-semibold">4.9 / 5</p>
|
||||
<p className="text-muted text-xs">Based on 200+ Google reviews</p>
|
||||
</div>
|
||||
<Tag variant="green" className="ml-1">
|
||||
Google Verified
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
|
||||
{/* Three scrolling columns */}
|
||||
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
<div className="flex max-h-[680px] items-start justify-center gap-5 overflow-hidden [mask-image:linear-gradient(to_bottom,transparent,black_8%,black_92%,transparent)]">
|
||||
<TestimonialsColumn
|
||||
testimonials={col1}
|
||||
duration={16}
|
||||
className="hidden w-full max-w-xs sm:block"
|
||||
/>
|
||||
|
||||
<TestimonialsColumn testimonials={col2} duration={20} className="w-full max-w-xs" />
|
||||
|
||||
<TestimonialsColumn
|
||||
testimonials={col3}
|
||||
duration={14}
|
||||
className="hidden w-full max-w-xs lg:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
89
src/components/sections/home/WhyAxilSection.tsx
Normal file
89
src/components/sections/home/WhyAxilSection.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
'use client';
|
||||
|
||||
import { FadeIn } from '@/components/ui/FadeIn';
|
||||
import { StatCounter } from '@/components/ui/StatCounter';
|
||||
import { ShieldCheckIcon, ReceiptIcon, PersonCircleIcon, CloudIcon } from '@/components/ui/icons';
|
||||
|
||||
const STATS = [
|
||||
{ prefix: '', value: 500, suffix: '+', label: 'Businesses Served' },
|
||||
{ prefix: '', value: 98, suffix: '%', label: 'Client Retention' },
|
||||
{ prefix: '£', value: 2, suffix: 'M+', label: 'Tax Saved for Clients' },
|
||||
{ prefix: '', value: 12, suffix: '+', label: 'Years of Experience' },
|
||||
];
|
||||
|
||||
const USPS = [
|
||||
{
|
||||
Icon: ShieldCheckIcon,
|
||||
title: 'ICAEW & ACCA Qualified',
|
||||
body: "Our accountants hold the highest professional qualifications in the UK. You're in expert hands.",
|
||||
color: 'var(--emerald)',
|
||||
bg: 'bg-emerald/10',
|
||||
},
|
||||
{
|
||||
Icon: ReceiptIcon,
|
||||
title: 'Fixed Monthly Engagement',
|
||||
body: 'One predictable fee covers everything. No extra charges for emails, calls, or ad-hoc advice.',
|
||||
color: 'var(--blue)',
|
||||
bg: 'bg-blue/10',
|
||||
},
|
||||
{
|
||||
Icon: PersonCircleIcon,
|
||||
title: 'Dedicated Account Manager',
|
||||
body: 'One person who knows you and your business. No call centres, no being passed around.',
|
||||
color: 'var(--emerald)',
|
||||
bg: 'bg-emerald/10',
|
||||
},
|
||||
{
|
||||
Icon: CloudIcon,
|
||||
title: 'Cloud-Based & Paper-Free',
|
||||
body: 'We connect to Xero, QuickBooks, and your bank. Everything is digital, accessible, and automated.',
|
||||
color: 'var(--blue)',
|
||||
bg: 'bg-blue/10',
|
||||
},
|
||||
];
|
||||
|
||||
export function WhyAxilSection() {
|
||||
return (
|
||||
<section className="bg-bg py-24 lg:py-32">
|
||||
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">
|
||||
<FadeIn className="mb-16 max-w-2xl">
|
||||
<p className="text-emerald mb-3 text-sm font-semibold tracking-widest uppercase">
|
||||
Why choose Axil
|
||||
</p>
|
||||
<h2 className="font-display text-charcoal text-4xl font-bold sm:text-5xl">
|
||||
Numbers that
|
||||
<br />
|
||||
speak for themselves
|
||||
</h2>
|
||||
</FadeIn>
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="mb-20 grid grid-cols-2 gap-x-8 gap-y-10 border-b border-black/8 pb-20 lg:grid-cols-4">
|
||||
{STATS.map((s, i) => (
|
||||
<FadeIn key={s.label} delay={i * 0.08}>
|
||||
<p className="text-charcoal mb-2 font-mono text-[clamp(2.8rem,5vw,4.5rem)] leading-none font-bold tracking-tight">
|
||||
{s.prefix}
|
||||
<StatCounter value={s.value} />
|
||||
{s.suffix}
|
||||
</p>
|
||||
<p className="text-muted text-sm font-medium">{s.label}</p>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* USP grid */}
|
||||
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{USPS.map((u, i) => (
|
||||
<FadeIn key={u.title} delay={i * 0.08}>
|
||||
<div className={`mb-5 flex size-12 items-center justify-center rounded-xl ${u.bg}`}>
|
||||
<u.Icon size={24} color={u.color} />
|
||||
</div>
|
||||
<h3 className="font-display text-charcoal mb-2 text-base font-semibold">{u.title}</h3>
|
||||
<p className="text-muted text-sm leading-relaxed">{u.body}</p>
|
||||
</FadeIn>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
84
src/components/ui/Button.tsx
Normal file
84
src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import { Spinner } from './Spinner';
|
||||
import { ArrowRightIcon } from './icons';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost';
|
||||
type Size = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
trailingArrow?: boolean;
|
||||
leadingIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
'bg-emerald text-white hover:bg-emerald-dark active:bg-emerald-deeper ' +
|
||||
'focus-visible:ring-emerald/50 shadow-sm',
|
||||
secondary:
|
||||
'border border-emerald text-emerald bg-transparent hover:bg-emerald-mist ' +
|
||||
'focus-visible:ring-emerald/50',
|
||||
ghost: 'text-emerald bg-transparent hover:bg-emerald-mist ' + 'focus-visible:ring-emerald/50',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'h-9 px-4 text-sm gap-1.5 rounded-pill',
|
||||
md: 'h-11 px-6 text-base gap-2 rounded-pill',
|
||||
lg: 'h-14 px-8 text-lg gap-2.5 rounded-pill',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
trailingArrow = false,
|
||||
leadingIcon,
|
||||
children,
|
||||
className = '',
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const arrowSize = size === 'sm' ? 14 : size === 'lg' ? 20 : 16;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
className={[
|
||||
'inline-flex cursor-pointer items-center justify-center font-medium',
|
||||
'transition-all duration-200',
|
||||
'focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
'active:scale-[0.98]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 disabled:active:scale-100',
|
||||
variants[variant],
|
||||
sizes[size],
|
||||
className,
|
||||
].join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size={size === 'lg' ? 'md' : 'sm'} />
|
||||
) : leadingIcon ? (
|
||||
leadingIcon
|
||||
) : null}
|
||||
{children}
|
||||
{!loading && trailingArrow && (
|
||||
<ArrowRightIcon
|
||||
size={arrowSize}
|
||||
className="shrink-0 transition-transform duration-200 group-hover:translate-x-0.5"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
23
src/components/ui/ContainerScroll.tsx
Normal file
23
src/components/ui/ContainerScroll.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
|
||||
export const ContainerScroll = ({
|
||||
titleComponent,
|
||||
children,
|
||||
}: {
|
||||
titleComponent: string | React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div className="flex flex-col items-center px-4 pb-16 md:px-20">
|
||||
<div className="w-full max-w-5xl text-center">{titleComponent}</div>
|
||||
|
||||
<div
|
||||
className="animate-fade-in-up-d3 mx-auto h-[26rem] w-full max-w-5xl overflow-hidden rounded-[30px] border-2 border-black/10 bg-white shadow-2xl md:h-[38rem]"
|
||||
style={{
|
||||
boxShadow:
|
||||
'0 0 #0000004d, 0 9px 20px #0000004a, 0 37px 37px #00000042, 0 84px 50px #00000026, 0 149px 60px #0000000a, 0 233px 65px #00000003',
|
||||
}}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden rounded-[28px] bg-white">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
11
src/components/ui/Divider.tsx
Normal file
11
src/components/ui/Divider.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interface DividerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Divider({ className = '' }: DividerProps) {
|
||||
return (
|
||||
<hr
|
||||
className={`via-emerald/30 h-px border-0 bg-gradient-to-r from-transparent to-transparent ${className}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
40
src/components/ui/FadeIn.tsx
Normal file
40
src/components/ui/FadeIn.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { gsap, ScrollTrigger } from '@/lib/gsap';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
y?: number;
|
||||
x?: number;
|
||||
}
|
||||
|
||||
export function FadeIn({ children, className = '', delay = 0, y = 24, x = 0 }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
gsap.set(el, { opacity: 0, y, x });
|
||||
|
||||
const trigger = ScrollTrigger.create({
|
||||
trigger: el,
|
||||
start: 'top 88%',
|
||||
once: true,
|
||||
onEnter: () => {
|
||||
gsap.to(el, { opacity: 1, y: 0, x: 0, duration: 0.85, delay, ease: 'power3.out' });
|
||||
},
|
||||
});
|
||||
|
||||
return () => trigger.kill();
|
||||
}, [delay, y, x]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} style={{ opacity: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/ui/GlassCard.tsx
Normal file
21
src/components/ui/GlassCard.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
interface GlassCardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
dark?: boolean;
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export function GlassCard({
|
||||
children,
|
||||
className = '',
|
||||
dark = false,
|
||||
hover = true,
|
||||
}: GlassCardProps) {
|
||||
const base = dark
|
||||
? 'bg-white/5 border border-white/10 backdrop-blur-md hover-glow-emerald-dark'
|
||||
: 'bg-white border border-emerald/15 shadow-sm hover-glow-emerald';
|
||||
|
||||
const hoverClasses = hover ? 'hover:-translate-y-1 hover:border-emerald/35' : '';
|
||||
|
||||
return <div className={`rounded-card ${base} ${hoverClasses} ${className}`}>{children}</div>;
|
||||
}
|
||||
16
src/components/ui/Heading.tsx
Normal file
16
src/components/ui/Heading.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
interface HeadingProps {
|
||||
as?: 'h1' | 'h2' | 'h3' | 'h4';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const defaultClasses = {
|
||||
h1: 'font-display text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl',
|
||||
h2: 'font-display text-3xl font-bold tracking-tight sm:text-4xl',
|
||||
h3: 'font-display text-2xl font-semibold sm:text-3xl',
|
||||
h4: 'font-display text-xl font-semibold sm:text-2xl',
|
||||
};
|
||||
|
||||
export function Heading({ as: Tag = 'h2', children, className = '' }: HeadingProps) {
|
||||
return <Tag className={`${defaultClasses[Tag]} ${className}`}>{children}</Tag>;
|
||||
}
|
||||
79
src/components/ui/InteractiveMenu.tsx
Normal file
79
src/components/ui/InteractiveMenu.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import type { ElementType } from 'react';
|
||||
|
||||
type IconComponentType = ElementType<{ className?: string; size?: number }>;
|
||||
|
||||
export interface InteractiveMenuItem {
|
||||
label: string;
|
||||
icon: IconComponentType;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export interface InteractiveMenuProps {
|
||||
items: InteractiveMenuItem[];
|
||||
accentColor?: string;
|
||||
onSelect?: (index: number, item: InteractiveMenuItem) => void;
|
||||
}
|
||||
|
||||
export function InteractiveMenu({ items, accentColor, onSelect }: InteractiveMenuProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const textRefs = useRef<(HTMLElement | null)[]>([]);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const setLineWidth = () => {
|
||||
const activeItemEl = itemRefs.current[activeIndex];
|
||||
const activeTextEl = textRefs.current[activeIndex];
|
||||
if (activeItemEl && activeTextEl) {
|
||||
activeItemEl.style.setProperty('--lineWidth', `${activeTextEl.offsetWidth}px`);
|
||||
}
|
||||
};
|
||||
setLineWidth();
|
||||
window.addEventListener('resize', setLineWidth);
|
||||
return () => window.removeEventListener('resize', setLineWidth);
|
||||
}, [activeIndex, items]);
|
||||
|
||||
const navStyle = useMemo(
|
||||
() =>
|
||||
({
|
||||
'--component-active-color': accentColor || 'var(--emerald)',
|
||||
}) as React.CSSProperties,
|
||||
[accentColor],
|
||||
);
|
||||
|
||||
const handleClick = (index: number) => {
|
||||
setActiveIndex(index);
|
||||
onSelect?.(index, items[index]);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="interactive-menu" style={navStyle} role="navigation">
|
||||
{items.map((item, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
className={`interactive-menu__item${isActive ? 'active' : ''}`}
|
||||
onClick={() => handleClick(index)}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
style={{ '--lineWidth': '0px' } as React.CSSProperties}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<span className="interactive-menu__icon">
|
||||
<Icon size={20} />
|
||||
</span>
|
||||
<strong
|
||||
className={`interactive-menu__text${isActive ? 'active' : ''}`}
|
||||
ref={(el) => (textRefs.current[index] = el as HTMLElement)}
|
||||
>
|
||||
{item.label}
|
||||
</strong>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
13
src/components/ui/Section.tsx
Normal file
13
src/components/ui/Section.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
interface SectionProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Section({ children, className = '', id }: SectionProps) {
|
||||
return (
|
||||
<section id={id} className={`w-full ${className}`}>
|
||||
<div className="mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8 xl:px-16">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/Spinner.tsx
Normal file
30
src/components/ui/Spinner.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
interface SpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: 'size-4',
|
||||
md: 'size-5',
|
||||
lg: 'size-6',
|
||||
};
|
||||
|
||||
export function Spinner({ size = 'md', className = '' }: SpinnerProps) {
|
||||
return (
|
||||
<svg
|
||||
className={`animate-spin ${sizeMap[size]} ${className}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
127
src/components/ui/SpotlightCard.tsx
Normal file
127
src/components/ui/SpotlightCard.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import React, { useEffect, useRef, ReactNode } from 'react';
|
||||
|
||||
interface SpotlightCardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
glowColor?: 'blue' | 'purple' | 'green' | 'red' | 'orange';
|
||||
customSize?: boolean;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
const glowColorMap = {
|
||||
blue: { base: 220, spread: 200 },
|
||||
purple: { base: 280, spread: 300 },
|
||||
green: { base: 150, spread: 40 }, // narrower range stays in emerald territory
|
||||
red: { base: 0, spread: 200 },
|
||||
orange: { base: 30, spread: 200 },
|
||||
};
|
||||
|
||||
export function SpotlightCard({
|
||||
children,
|
||||
className = '',
|
||||
glowColor = 'green',
|
||||
width,
|
||||
height,
|
||||
}: SpotlightCardProps) {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const syncPointer = (e: PointerEvent) => {
|
||||
const { clientX: x, clientY: y } = e;
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.setProperty('--x', x.toFixed(2));
|
||||
cardRef.current.style.setProperty('--xp', (x / window.innerWidth).toFixed(2));
|
||||
cardRef.current.style.setProperty('--y', y.toFixed(2));
|
||||
cardRef.current.style.setProperty('--yp', (y / window.innerHeight).toFixed(2));
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointermove', syncPointer);
|
||||
return () => document.removeEventListener('pointermove', syncPointer);
|
||||
}, []);
|
||||
|
||||
const { base, spread } = glowColorMap[glowColor];
|
||||
|
||||
const inlineStyles: React.CSSProperties & Record<string, string | number> = {
|
||||
'--base': base,
|
||||
'--spread': spread,
|
||||
'--radius': '16',
|
||||
'--border': '1',
|
||||
'--backdrop': 'hsl(0 0% 100% / 0.95)',
|
||||
'--backup-border': 'rgba(22,37,32,0.08)',
|
||||
'--size': '250',
|
||||
'--outer': '1',
|
||||
'--border-size': 'calc(var(--border, 1) * 1px)',
|
||||
'--spotlight-size': 'calc(var(--size, 150) * 1px)',
|
||||
'--hue': 'calc(var(--base) + (var(--xp, 0) * var(--spread, 0)))',
|
||||
backgroundImage: `radial-gradient(
|
||||
var(--spotlight-size) var(--spotlight-size) at
|
||||
calc(var(--x, 0) * 1px)
|
||||
calc(var(--y, 0) * 1px),
|
||||
hsl(var(--hue, 150) calc(var(--saturation, 80) * 1%) calc(var(--lightness, 70) * 1%) / var(--bg-spot-opacity, 0.08)), transparent
|
||||
)`,
|
||||
backgroundColor: 'var(--backdrop, white)',
|
||||
backgroundSize: 'calc(100% + (2 * var(--border-size))) calc(100% + (2 * var(--border-size)))',
|
||||
backgroundPosition: '50% 50%',
|
||||
backgroundAttachment: 'fixed',
|
||||
border: 'var(--border-size) solid var(--backup-border)',
|
||||
position: 'relative',
|
||||
touchAction: 'none',
|
||||
};
|
||||
|
||||
if (width !== undefined) inlineStyles.width = typeof width === 'number' ? `${width}px` : width;
|
||||
if (height !== undefined)
|
||||
inlineStyles.height = typeof height === 'number' ? `${height}px` : height;
|
||||
|
||||
const beforeAfterStyles = `
|
||||
[data-spotlight]::before,
|
||||
[data-spotlight]::after {
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(var(--border-size) * -1);
|
||||
border: var(--border-size) solid transparent;
|
||||
border-radius: calc(var(--radius) * 1px);
|
||||
background-attachment: fixed;
|
||||
background-size: calc(100% + (2 * var(--border-size))) calc(100% + (2 * var(--border-size)));
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50% 50%;
|
||||
mask: linear-gradient(transparent, transparent), linear-gradient(white, white);
|
||||
mask-clip: padding-box, border-box;
|
||||
mask-composite: intersect;
|
||||
}
|
||||
[data-spotlight]::before {
|
||||
background-image: radial-gradient(
|
||||
calc(var(--spotlight-size) * 0.75) calc(var(--spotlight-size) * 0.75) at
|
||||
calc(var(--x, 0) * 1px)
|
||||
calc(var(--y, 0) * 1px),
|
||||
hsl(var(--hue, 150) calc(var(--saturation, 80) * 1%) calc(var(--lightness, 50) * 1%) / var(--border-spot-opacity, 0.8)), transparent 100%
|
||||
);
|
||||
filter: brightness(1.5);
|
||||
}
|
||||
[data-spotlight]::after {
|
||||
background-image: radial-gradient(
|
||||
calc(var(--spotlight-size) * 0.5) calc(var(--spotlight-size) * 0.5) at
|
||||
calc(var(--x, 0) * 1px)
|
||||
calc(var(--y, 0) * 1px),
|
||||
hsl(0 100% 100% / var(--border-light-opacity, 0.5)), transparent 100%
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style dangerouslySetInnerHTML={{ __html: beforeAfterStyles }} />
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-spotlight
|
||||
style={inlineStyles}
|
||||
className={`rounded-hero shadow-sm ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
22
src/components/ui/StarRating.tsx
Normal file
22
src/components/ui/StarRating.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { StarIcon } from './icons';
|
||||
|
||||
interface StarRatingProps {
|
||||
rating: number;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function StarRating({ rating, size = 'md', className = '' }: StarRatingProps) {
|
||||
const iconSize = size === 'sm' ? 14 : 18;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-0.5 ${className}`}
|
||||
aria-label={`${rating} out of 5 stars`}
|
||||
>
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<StarIcon key={i} size={iconSize} filled={i < rating} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/components/ui/StatCounter.tsx
Normal file
40
src/components/ui/StatCounter.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { gsap, ScrollTrigger } from '@/lib/gsap';
|
||||
|
||||
interface Props {
|
||||
value: number;
|
||||
decimals?: number;
|
||||
}
|
||||
|
||||
export function StatCounter({ value, decimals = 0 }: Props) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const obj = { val: 0 };
|
||||
|
||||
const trigger = ScrollTrigger.create({
|
||||
trigger: el,
|
||||
start: 'top 80%',
|
||||
once: true,
|
||||
onEnter: () => {
|
||||
gsap.to(obj, {
|
||||
val: value,
|
||||
duration: 2.2,
|
||||
ease: 'power2.out',
|
||||
onUpdate() {
|
||||
el.textContent = obj.val.toFixed(decimals);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => trigger.kill();
|
||||
}, [value, decimals]);
|
||||
|
||||
return <span ref={ref}>0</span>;
|
||||
}
|
||||
21
src/components/ui/Tag.tsx
Normal file
21
src/components/ui/Tag.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
interface TagProps {
|
||||
variant?: 'green' | 'grey' | 'blue';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantClasses = {
|
||||
green: 'bg-emerald-mist text-emerald-deeper border border-emerald/20',
|
||||
grey: 'bg-gray-100 text-muted border border-gray-200',
|
||||
blue: 'bg-blue-mist text-blue-dark border border-blue/20',
|
||||
};
|
||||
|
||||
export function Tag({ variant = 'green', children, className = '' }: TagProps) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded-pill inline-flex items-center px-3 py-1 text-sm font-medium ${variantClasses[variant]} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/icons/ArrowRightIcon.tsx
Normal file
24
src/components/ui/icons/ArrowRightIcon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ArrowRightIcon({ size = 16, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
32
src/components/ui/icons/BookkeepingIcon.tsx
Normal file
32
src/components/ui/icons/BookkeepingIcon.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BookkeepingIcon({ size = 32, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Book/ledger shape */}
|
||||
<rect x="5" y="3" width="18" height="24" rx="2" />
|
||||
<line x1="5" y1="8" x2="23" y2="8" />
|
||||
{/* Ledger lines */}
|
||||
<line x1="9" y1="13" x2="19" y2="13" />
|
||||
<line x1="9" y1="17" x2="19" y2="17" />
|
||||
<line x1="9" y1="21" x2="15" y2="21" />
|
||||
{/* Spine */}
|
||||
<line x1="9" y1="3" x2="9" y2="27" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
25
src/components/ui/icons/CheckCircleIcon.tsx
Normal file
25
src/components/ui/icons/CheckCircleIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CheckCircleIcon({ size = 16, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/icons/ChevronDownIcon.tsx
Normal file
24
src/components/ui/icons/ChevronDownIcon.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ChevronDownIcon({ size = 16, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
25
src/components/ui/icons/CloudIcon.tsx
Normal file
25
src/components/ui/icons/CloudIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CloudIcon({ size = 24, color = 'currentColor', className = '' }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path
|
||||
d="M7 19h10a4.5 4.5 0 000-9h-.5A5 5 0 006.2 13.8 3.5 3.5 0 007 19z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 15.5v4M9.5 17.5L12 20l2.5-2.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/icons/MenuIcon.tsx
Normal file
26
src/components/ui/icons/MenuIcon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MenuIcon({ size = 24, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/icons/PayrollIcon.tsx
Normal file
33
src/components/ui/icons/PayrollIcon.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PayrollIcon({ size = 32, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Person */}
|
||||
<circle cx="11" cy="9" r="4" />
|
||||
<path d="M3 27c0-5 3.6-8 8-8" />
|
||||
{/* Pay slip */}
|
||||
<rect x="16" y="14" width="13" height="10" rx="2" />
|
||||
<line x1="19" y1="18" x2="22" y2="18" />
|
||||
<line x1="19" y1="21" x2="26" y2="21" />
|
||||
{/* Coin / pound */}
|
||||
<circle cx="25" cy="9" r="4" />
|
||||
<path d="M23.5 9.5c0-1 .6-2 1.5-2s1.5 1 1.5 2-.7 1.5-1.5 1.5H23" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
20
src/components/ui/icons/PersonCircleIcon.tsx
Normal file
20
src/components/ui/icons/PersonCircleIcon.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PersonCircleIcon({ size = 24, color = 'currentColor', className = '' }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth="1.5" />
|
||||
<circle cx="12" cy="10" r="3" stroke={color} strokeWidth="1.5" />
|
||||
<path
|
||||
d="M6.5 19.5c.8-2.8 3-4.5 5.5-4.5s4.7 1.7 5.5 4.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
src/components/ui/icons/ReceiptIcon.tsx
Normal file
21
src/components/ui/icons/ReceiptIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ReceiptIcon({ size = 24, color = 'currentColor', className = '' }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path
|
||||
d="M4 3v18l2-1.5L8 21l2-1.5L12 21l2-1.5L16 21l2-1.5L20 21V3l-2 1.5L16 3l-2 1.5L12 3l-2 1.5L8 3 6 4.5 4 3z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<line x1="8" y1="9" x2="16" y2="9" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="8" y1="13" x2="13" y2="13" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/icons/ShieldCheckIcon.tsx
Normal file
26
src/components/ui/icons/ShieldCheckIcon.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ShieldCheckIcon({ size = 24, color = 'currentColor', className = '' }: Props) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
|
||||
<path
|
||||
d="M12 2.5L4 6v5.5c0 4.7 3.5 9.1 8 10.3 4.5-1.2 8-5.6 8-10.3V6L12 2.5z"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="9,12.5 11,14.5 15,10.5"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/icons/TaxIcon.tsx
Normal file
30
src/components/ui/icons/TaxIcon.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TaxIcon({ size = 32, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Document */}
|
||||
<path d="M6 4h14l6 6v18H6V4z" />
|
||||
<polyline points="20 4 20 10 26 10" />
|
||||
{/* Percentage symbol */}
|
||||
<circle cx="12" cy="15" r="2" />
|
||||
<circle cx="20" cy="21" r="2" />
|
||||
<line x1="19" y1="13" x2="13" y2="23" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
31
src/components/ui/icons/VATIcon.tsx
Normal file
31
src/components/ui/icons/VATIcon.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function VATIcon({ size = 32, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Receipt shape */}
|
||||
<path d="M6 2h20v26l-3-2-3 2-3-2-3 2-3-2-3 2V2z" />
|
||||
{/* VAT % */}
|
||||
<circle cx="12" cy="13" r="2" />
|
||||
<circle cx="20" cy="19" r="2" />
|
||||
<line x1="19" y1="11" x2="13" y2="21" />
|
||||
{/* Bottom line */}
|
||||
<line x1="10" y1="24" x2="22" y2="24" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
25
src/components/ui/icons/XIcon.tsx
Normal file
25
src/components/ui/icons/XIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
interface IconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function XIcon({ size = 24, color = 'currentColor', className = '' }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
14
src/components/ui/icons/index.ts
Normal file
14
src/components/ui/icons/index.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export { ArrowRightIcon } from './ArrowRightIcon';
|
||||
export { CheckCircleIcon } from './CheckCircleIcon';
|
||||
export { StarIcon } from './StarIcon';
|
||||
export { MenuIcon } from './MenuIcon';
|
||||
export { XIcon } from './XIcon';
|
||||
export { ChevronDownIcon } from './ChevronDownIcon';
|
||||
export { BookkeepingIcon } from './BookkeepingIcon';
|
||||
export { TaxIcon } from './TaxIcon';
|
||||
export { PayrollIcon } from './PayrollIcon';
|
||||
export { VATIcon } from './VATIcon';
|
||||
export { ShieldCheckIcon } from './ShieldCheckIcon';
|
||||
export { ReceiptIcon } from './ReceiptIcon';
|
||||
export { PersonCircleIcon } from './PersonCircleIcon';
|
||||
export { CloudIcon } from './CloudIcon';
|
||||
8
src/lib/gsap.ts
Normal file
8
src/lib/gsap.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { gsap } from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
|
||||
export { gsap, ScrollTrigger };
|
||||
3
src/lib/utils.ts
Normal file
3
src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function cn(...classes: (string | undefined | null | false)[]) {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
}
|
||||
72
src/payload.config.ts
Normal file
72
src/payload.config.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { buildConfig } from 'payload';
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres';
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical';
|
||||
import { formBuilderPlugin } from '@payloadcms/plugin-form-builder';
|
||||
|
||||
// Collections
|
||||
import { Users } from './payload/collections/Users';
|
||||
import { Media } from './payload/collections/Media';
|
||||
import { Services } from './payload/collections/Services';
|
||||
import { Categories } from './payload/collections/Categories';
|
||||
import { Posts } from './payload/collections/Posts';
|
||||
import { TeamMembers } from './payload/collections/TeamMembers';
|
||||
import { Testimonials } from './payload/collections/Testimonials';
|
||||
import { FAQs } from './payload/collections/FAQs';
|
||||
|
||||
// Globals
|
||||
import { Navigation } from './payload/globals/Navigation';
|
||||
import { Footer } from './payload/globals/Footer';
|
||||
import { SiteSettings } from './payload/globals/SiteSettings';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||
|
||||
secret: process.env.PAYLOAD_SECRET || 'dev-secret-change-in-production',
|
||||
|
||||
admin: {
|
||||
user: 'users',
|
||||
meta: {
|
||||
titleSuffix: '— Axil Accountants CMS',
|
||||
},
|
||||
},
|
||||
|
||||
editor: lexicalEditor(),
|
||||
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.DATABASE_URI,
|
||||
},
|
||||
}),
|
||||
|
||||
collections: [Users, Media, Services, Categories, Posts, TeamMembers, Testimonials, FAQs],
|
||||
|
||||
globals: [Navigation, Footer, SiteSettings],
|
||||
|
||||
upload: {
|
||||
limits: {
|
||||
fileSize: 10_000_000, // 10MB
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
formBuilderPlugin({
|
||||
fields: {
|
||||
text: true,
|
||||
email: true,
|
||||
select: true,
|
||||
checkbox: true,
|
||||
textarea: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, 'payload-types.ts'),
|
||||
},
|
||||
});
|
||||
31
src/payload/collections/Categories.ts
Normal file
31
src/payload/collections/Categories.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Categories: CollectionConfig = {
|
||||
slug: 'categories',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'colour',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Hex colour for tag display (e.g. #3CC68A)',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
22
src/payload/collections/FAQs.ts
Normal file
22
src/payload/collections/FAQs.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const FAQs: CollectionConfig = {
|
||||
slug: 'faqs',
|
||||
admin: {
|
||||
useAsTitle: 'question',
|
||||
defaultColumns: ['question', 'service', 'global', 'order'],
|
||||
},
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: 'question', type: 'text', required: true },
|
||||
{ name: 'answer', type: 'richText' },
|
||||
{ name: 'service', type: 'relationship', relationTo: 'services' },
|
||||
{
|
||||
name: 'global',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: { description: 'Show on generic FAQ sections (not service-specific)' },
|
||||
},
|
||||
{ name: 'order', type: 'number' },
|
||||
],
|
||||
};
|
||||
32
src/payload/collections/Media.ts
Normal file
32
src/payload/collections/Media.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
admin: {
|
||||
useAsTitle: 'filename',
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
upload: {
|
||||
staticDir: 'public/media',
|
||||
imageSizes: [
|
||||
{ name: 'thumbnail', width: 400, height: 300, position: 'centre' },
|
||||
{ name: 'card', width: 800, height: 600, position: 'centre' },
|
||||
{ name: 'hero', width: 1920, height: 1080, position: 'centre' },
|
||||
],
|
||||
adminThumbnail: 'thumbnail',
|
||||
mimeTypes: ['image/*'],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
100
src/payload/collections/Posts.ts
Normal file
100
src/payload/collections/Posts.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import type { CollectionConfig } from 'payload';
|
||||
import type { CollectionAfterChangeHook } from 'payload';
|
||||
|
||||
const calculateReadingTime: CollectionAfterChangeHook = async ({ doc, req: { payload } }) => {
|
||||
if (!doc.content) return doc;
|
||||
// Rough estimate: 200 words per minute
|
||||
const wordCount = JSON.stringify(doc.content).split(' ').length;
|
||||
const minutes = Math.ceil(wordCount / 200);
|
||||
await payload.update({ collection: 'posts', id: doc.id, data: { readingTime: minutes } });
|
||||
return doc;
|
||||
};
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'status', 'publishedAt', 'author'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
hooks: {
|
||||
afterChange: [calculateReadingTime],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
{
|
||||
name: 'author',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'relationship',
|
||||
relationTo: 'categories',
|
||||
},
|
||||
{
|
||||
name: 'coverImage',
|
||||
type: 'upload',
|
||||
relationTo: 'media',
|
||||
},
|
||||
{
|
||||
name: 'excerpt',
|
||||
type: 'textarea',
|
||||
admin: {
|
||||
description: 'Short summary shown on listing pages (max 150 chars)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: 'richText',
|
||||
},
|
||||
{
|
||||
name: 'readingTime',
|
||||
type: 'number',
|
||||
admin: {
|
||||
description: 'Auto-calculated on save (minutes)',
|
||||
readOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
{ label: 'Scheduled', value: 'scheduled' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
type: 'array',
|
||||
fields: [{ name: 'tag', type: 'text' }],
|
||||
},
|
||||
{
|
||||
name: 'seo',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'metaTitle', type: 'text' },
|
||||
{ name: 'metaDescription', type: 'textarea' },
|
||||
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
127
src/payload/collections/Services.ts
Normal file
127
src/payload/collections/Services.ts
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Services: CollectionConfig = {
|
||||
slug: 'services',
|
||||
admin: {
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['title', 'status', 'publishedAt'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
required: true,
|
||||
unique: true,
|
||||
admin: {
|
||||
description: 'URL-friendly identifier (auto-generated from title)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'icon',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'Bookkeeping', value: 'bookkeeping' },
|
||||
{ label: 'Tax Returns', value: 'tax' },
|
||||
{ label: 'Payroll', value: 'payroll' },
|
||||
{ label: 'VAT Returns', value: 'vat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'tagline',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'Short subtitle shown on cards and hero',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'richText',
|
||||
},
|
||||
{
|
||||
name: 'whatsIncluded',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'item',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'whoItsFor',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{
|
||||
name: 'audience',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Sole Traders', value: 'sole-trader' },
|
||||
{ label: 'Limited Companies', value: 'limited-company' },
|
||||
{ label: 'Startups', value: 'startup' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'howItWorks',
|
||||
type: 'array',
|
||||
maxRows: 3,
|
||||
fields: [
|
||||
{ name: 'step', type: 'number', required: true },
|
||||
{ name: 'title', type: 'text', required: true },
|
||||
{ name: 'description', type: 'text', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'faq',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'question', type: 'text', required: true },
|
||||
{ name: 'answer', type: 'richText' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'relatedTestimonials',
|
||||
type: 'relationship',
|
||||
relationTo: 'testimonials',
|
||||
hasMany: true,
|
||||
},
|
||||
{
|
||||
name: 'seo',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'metaTitle', type: 'text' },
|
||||
{ name: 'metaDescription', type: 'textarea' },
|
||||
{ name: 'ogImage', type: 'upload', relationTo: 'media' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'publishedAt',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'select',
|
||||
defaultValue: 'draft',
|
||||
options: [
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Published', value: 'published' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
29
src/payload/collections/TeamMembers.ts
Normal file
29
src/payload/collections/TeamMembers.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const TeamMembers: CollectionConfig = {
|
||||
slug: 'team-members',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'role', 'order'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'role', type: 'text' },
|
||||
{
|
||||
name: 'qualifications',
|
||||
type: 'text',
|
||||
admin: { description: 'e.g. "ACCA, MAAT"' },
|
||||
},
|
||||
{ name: 'bio', type: 'textarea' },
|
||||
{ name: 'photo', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'linkedIn', type: 'text' },
|
||||
{
|
||||
name: 'order',
|
||||
type: 'number',
|
||||
admin: { description: 'Display order (lower = first)' },
|
||||
},
|
||||
],
|
||||
};
|
||||
51
src/payload/collections/Testimonials.ts
Normal file
51
src/payload/collections/Testimonials.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Testimonials: CollectionConfig = {
|
||||
slug: 'testimonials',
|
||||
admin: {
|
||||
useAsTitle: 'clientName',
|
||||
defaultColumns: ['clientName', 'rating', 'featured', 'publishedAt'],
|
||||
},
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{ name: 'clientName', type: 'text', required: true },
|
||||
{ name: 'businessName', type: 'text' },
|
||||
{
|
||||
name: 'businessType',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Sole Trader', value: 'sole-trader' },
|
||||
{ label: 'Limited Company', value: 'limited-company' },
|
||||
{ label: 'Startup', value: 'startup' },
|
||||
{ label: 'Other', value: 'other' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'rating',
|
||||
type: 'select',
|
||||
options: ['1', '2', '3', '4', '5'].map((v) => ({
|
||||
label: `${v} star${v !== '1' ? 's' : ''}`,
|
||||
value: v,
|
||||
})),
|
||||
},
|
||||
{ name: 'quote', type: 'textarea', required: true },
|
||||
{ name: 'photo', type: 'upload', relationTo: 'media' },
|
||||
{
|
||||
name: 'source',
|
||||
type: 'select',
|
||||
defaultValue: 'manual',
|
||||
options: [
|
||||
{ label: 'Google Review', value: 'google' },
|
||||
{ label: 'Manual Entry', value: 'manual' },
|
||||
],
|
||||
},
|
||||
{ name: 'service', type: 'relationship', relationTo: 'services' },
|
||||
{
|
||||
name: 'featured',
|
||||
type: 'checkbox',
|
||||
defaultValue: false,
|
||||
admin: { description: 'Show in homepage carousel' },
|
||||
},
|
||||
{ name: 'publishedAt', type: 'date' },
|
||||
],
|
||||
};
|
||||
26
src/payload/collections/Users.ts
Normal file
26
src/payload/collections/Users.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { CollectionConfig } from 'payload';
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'role',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'editor',
|
||||
options: [
|
||||
{ label: 'Admin', value: 'admin' },
|
||||
{ label: 'Editor', value: 'editor' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
54
src/payload/globals/Footer.ts
Normal file
54
src/payload/globals/Footer.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type { GlobalConfig } from 'payload';
|
||||
|
||||
export const Footer: GlobalConfig = {
|
||||
slug: 'footer',
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{
|
||||
name: 'columns',
|
||||
type: 'array',
|
||||
maxRows: 4,
|
||||
fields: [
|
||||
{ name: 'heading', type: 'text', required: true },
|
||||
{
|
||||
name: 'links',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'label', type: 'text', required: true },
|
||||
{ name: 'href', type: 'text', required: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'contactInfo',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'address', type: 'text' },
|
||||
{ name: 'phone', type: 'text' },
|
||||
{ name: 'email', type: 'email' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'socialLinks',
|
||||
type: 'group',
|
||||
fields: [
|
||||
{ name: 'linkedIn', type: 'text' },
|
||||
{ name: 'facebook', type: 'text' },
|
||||
{ name: 'instagram', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'legalLinks',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'label', type: 'text', required: true },
|
||||
{ name: 'href', type: 'text', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'copyrightText',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
};
|
||||
28
src/payload/globals/Navigation.ts
Normal file
28
src/payload/globals/Navigation.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { GlobalConfig } from 'payload';
|
||||
|
||||
export const Navigation: GlobalConfig = {
|
||||
slug: 'navigation',
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
{
|
||||
name: 'items',
|
||||
type: 'array',
|
||||
fields: [
|
||||
{ name: 'label', type: 'text', required: true },
|
||||
{ name: 'href', type: 'text' },
|
||||
{ name: 'isDropdown', type: 'checkbox', defaultValue: false },
|
||||
{
|
||||
name: 'children',
|
||||
type: 'array',
|
||||
admin: { condition: (_, siblingData) => siblingData?.isDropdown },
|
||||
fields: [
|
||||
{ name: 'label', type: 'text', required: true },
|
||||
{ name: 'href', type: 'text', required: true },
|
||||
{ name: 'icon', type: 'text' },
|
||||
{ name: 'description', type: 'text' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
123
src/payload/globals/SiteSettings.ts
Normal file
123
src/payload/globals/SiteSettings.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import type { GlobalConfig } from 'payload';
|
||||
|
||||
export const SiteSettings: GlobalConfig = {
|
||||
slug: 'site-settings',
|
||||
access: { read: () => true },
|
||||
fields: [
|
||||
// Brand
|
||||
{
|
||||
name: 'brand',
|
||||
type: 'group',
|
||||
label: 'Brand',
|
||||
fields: [
|
||||
{ name: 'siteName', type: 'text' },
|
||||
{ name: 'tagline', type: 'text' },
|
||||
{ name: 'logo', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'logoDark', type: 'upload', relationTo: 'media' },
|
||||
{ name: 'favicon', type: 'upload', relationTo: 'media' },
|
||||
],
|
||||
},
|
||||
// Contact
|
||||
{
|
||||
name: 'contact',
|
||||
type: 'group',
|
||||
label: 'Contact',
|
||||
fields: [
|
||||
{ name: 'address', type: 'text' },
|
||||
{ name: 'phone', type: 'text' },
|
||||
{ name: 'email', type: 'email' },
|
||||
{ name: 'officeHours', type: 'text' },
|
||||
],
|
||||
},
|
||||
// Social
|
||||
{
|
||||
name: 'social',
|
||||
type: 'group',
|
||||
label: 'Social',
|
||||
fields: [
|
||||
{ name: 'linkedIn', type: 'text' },
|
||||
{ name: 'facebook', type: 'text' },
|
||||
{ name: 'instagram', type: 'text' },
|
||||
],
|
||||
},
|
||||
// Analytics
|
||||
{
|
||||
name: 'analytics',
|
||||
type: 'group',
|
||||
label: 'Analytics',
|
||||
fields: [
|
||||
{ name: 'googleAnalyticsId', type: 'text' },
|
||||
{ name: 'googleTagManagerId', type: 'text' },
|
||||
{ name: 'facebookPixelId', type: 'text' },
|
||||
],
|
||||
},
|
||||
// Booking
|
||||
{
|
||||
name: 'booking',
|
||||
type: 'group',
|
||||
label: 'Booking',
|
||||
fields: [
|
||||
{ name: 'calendlyUrl', type: 'text' },
|
||||
{ name: 'calendlyInline', type: 'checkbox', defaultValue: false },
|
||||
],
|
||||
},
|
||||
// Chat Bot
|
||||
{
|
||||
name: 'chatBot',
|
||||
type: 'group',
|
||||
label: 'Chat Bot',
|
||||
fields: [
|
||||
{
|
||||
name: 'chatBotMode',
|
||||
type: 'select',
|
||||
defaultValue: 'disabled',
|
||||
options: [
|
||||
{ label: 'Disabled', value: 'disabled' },
|
||||
{ label: 'AI Chat', value: 'ai' },
|
||||
{ label: 'Live Chat', value: 'livechat' },
|
||||
{ label: 'Lead Capture', value: 'leadcapture' },
|
||||
],
|
||||
},
|
||||
{ name: 'chatBotCustomCode', type: 'code', admin: { language: 'html' } },
|
||||
],
|
||||
},
|
||||
// Webhooks Registry
|
||||
{
|
||||
name: 'webhooks',
|
||||
type: 'array',
|
||||
label: 'Webhooks Registry',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'url', type: 'text', required: true },
|
||||
{ name: 'active', type: 'checkbox', defaultValue: true },
|
||||
],
|
||||
},
|
||||
// Email
|
||||
{
|
||||
name: 'notificationEmail',
|
||||
type: 'email',
|
||||
label: 'Notification Email',
|
||||
},
|
||||
// SEO Defaults
|
||||
{
|
||||
name: 'seoDefaults',
|
||||
type: 'group',
|
||||
label: 'SEO Defaults',
|
||||
fields: [
|
||||
{ name: 'defaultMetaTitle', type: 'text' },
|
||||
{ name: 'defaultMetaDescription', type: 'textarea' },
|
||||
{ name: 'defaultOgImage', type: 'upload', relationTo: 'media' },
|
||||
],
|
||||
},
|
||||
// Scripts
|
||||
{
|
||||
name: 'scripts',
|
||||
type: 'group',
|
||||
label: 'Scripts',
|
||||
fields: [
|
||||
{ name: 'headerScripts', type: 'code', admin: { language: 'html' } },
|
||||
{ name: 'footerScripts', type: 'code', admin: { language: 'html' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
@ -19,7 +19,8 @@
|
|||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@payload-config": ["./src/payload.config.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue