# About GRAVILOCH FINISHING LTD
GRAVILOCH FINISHING LTD is the sole distributor of Nikkolor Italian Decorative Paint in Nigeria, specialising in luxury wall finishes — Venetian Plaster, Stucco, Travertino, Marmorino, Metallic, Liquid Metal, and Microcemento. The company is headquartered at 89 Stadium Road, Port Harcourt, Rivers State, Nigeria.
Led by Mr. Christian N. Ugwu, GRAVILOCH operates across Port Harcourt, Lagos, Abuja, Calabar, and Uyo, delivering authentic Italian craftsmanship trained under the Nikkolor programme.
📞 +234 803 507 0793 | 📧 gravilochfinishings@gmail.com
# Technology Stack
| Layer | Technology | Notes |
|---|
| Framework | Next.js 16.1.5 | App Router + Turbopack |
| Language | TypeScript 5 | Strict mode |
| Styling | CSS Modules + Tailwind classes | Per-component scoped CSS |
| Animation | Framer Motion | Page transitions, modals, cards |
| ORM | Prisma 6 | Type-safe DB queries |
| Database | CockroachDB (PostgreSQL-compatible) | Serverless scaling |
| Auth | NextAuth.js (Auth.js v5) | JWT sessions, credentials provider |
| Storage | Cloudflare R2 | Image + video uploads |
| Email | Resend | Contact form transactional emails |
| Messaging | WhatsApp Business deep-link | Enquiry CTAs |
| Testing | Jest + React Testing Library | Unit & hook tests |
| Package manager | npm | Standard lockfile |
# Architecture Overview
The application follows Next.js App Routerconventions. Server Components handle data fetching and SEO metadata at the top of each route; Client Components (marked "use client") handle interactivity (modals, forms, animations). The admin section is dynamically rendered on every request (ƒ Dynamic) while most public pages are statically generated (○ Static) at build time.
Route (app)
┌ ○ / — Home (static)
├ ○ /about — About (static)
├ ○ /colours — Colours (static)
├ ○ /contact — Contact (static)
├ ƒ /gallery — Gallery (dynamic, DB query)
├ ƒ /samples — Samples (dynamic, DB query)
├ ƒ /shop — Shop (dynamic, DB query)
├ ƒ /testimonials — Testimonials (dynamic)
├ ƒ /admin/* — Admin panel (protected)
└ ƒ /api/* — REST API routes
Data flows through Prisma to CockroachDB. File uploads go via Cloudflare R2 presigned URLs. WhatsApp CTAs use deep-link URLs generated in src/lib/whatsapp.ts.
# Project Structure
graviloch-finishing/
├── prisma/
│ └── schema.prisma # DB models (Admin, Product, GalleryImage, Review, Analytics, Sample)
├── public/
│ ├── images/ # Static images (gallery/, about/, hero/, samples/, services/, stores/)
│ ├── Castle-finished-work.mp4
│ ├── manifest.json # PWA manifest
│ └── sw.js # Service worker
├── scripts/
│ ├── seed-admin.js # Seeds first admin account
│ ├── seed-gallery.ts # Appends gallery images (no wipe)
│ └── seed-samples.ts # Re-seeds sample cards (clears & refills)
└── src/
├── app/ # Next.js App Router pages + API routes
├── components/ # Feature & layout components
├── hooks/ # Custom React hooks
├── lib/ # Server utilities (prisma, email, whatsapp, r2, analytics)
└── types/ # TypeScript types + Zod schemas
# Home Page — /
The landing page features a full-screen animated hero, a services overview, featured gallery images, and product highlights. It is statically generated using only layout components — no DB calls.
HeroSection — animated headline with CTA buttonsServicesSection — six finish types with iconsFeaturedGallery — fetches up to 6 featured images from /api/gallery?featured=trueWhyChooseUs — differentiating values gridCTABanner — WhatsApp enquiry + contact link
# About Page — /about
Static page composed of five discrete about-specific sections:
HeroAbout — full-bleed hero with page titleStorySection — company narrative with 4-image grid (master-craftman.jpg, team-at-work.webp, paint-craftsmanship.webp, finished_work.jpg)ServicesSection — cards for each finish type offeredProcessSection — step-by-step "How We Work" guideCTASection — background uses master-craftman.jpg with green gradient overlay; links to Contact and Gallery
All about components live in src/components/about/.
# Colours Page — /colours
Showcases Nikkolor colour ranges and finish types using static visual data. Uses ColourCard components to display swatches. The page is primarily inspirational—no cart or DB interaction.
# Shop / Products — /shop
Dynamically rendered product listing page. Products are fetched from the Prisma Product model via /api/products. Supports category filtering, search, and sorting.
- Category filter:
venetian | marmorino | travertino | metallic | liquid-metal | decorative | specialty | tools | other - Sort options:
newest | oldest | price-low | price-high | most-viewed | most-liked | most-contacted - Each product card has views, likes, and a WhatsApp enquiry CTA
- Product clicks increment the
views counter via analytics event
# Gallery Page — /gallery
Displays a masonry grid of completed project photos fetched from theGalleryImage model. Supports category tabs and a sort control via URL query params.
- Category tabs:
interior | exterior | office | commercial | residential | dining | bedroom | living-room | bathroom | other - Sort:
newest | oldest | most-viewed | most-liked - Finished Work Videos section — plays
/Castle-finished-work.mp4 natively in a styled video player
Use the admin panel to add or manage gallery images without re-deploying.
# Samples Page — /samples
Interactive catalogue of real finish samples, each referencing an actual painted board. Samples are DB-driven and filterable by category. Clicking a sample opens a full-screen detail modal with enlarged image and a WhatsApp enquiry shortcut.
- Category tabs:
All | Venetian | Stucco | Travertino | Metallic | Liquid Metal | Microcemento | Other - Modal opens on card click — closes on overlay click or ✕
- Background scroll is locked while modal is open
- WhatsApp enquiry URL passes sample title and category as context
The stucco category replaces the deprecated marmorino label you may see in older seeds or product data. The database field is a plain String, so both values can coexist—but the UI only surfaces stucco.
# Testimonials — /testimonials
Public-facing review wall that only renders approved reviews from the Review model. Statistics (total count, average rating, star distribution) are fetched from /api/reviews?stats=true.
- Anonymous submission form for new customer reviews
- Admin approval required before a review becomes visible
- Rating breakdown chart (1–5 stars)
# Contact Page — /contact
Static layout with a multi-field contact form (left) and company contact info (right). The form supports three send channels:email, whatsapp, or both.
- Fields: Full Name*, Email Address*, Phone, Subject, Message*
- Submission hits
POST /api/contact - Email channel uses Resend; WhatsApp channel opens a deep-link
- Business hours displayed: Mon–Fri 9am–6pm, Sat 10am–4pm
# Admin Authentication
Admin authentication is handled by NextAuth.js with the Credentials provider. Sessions are JWT-based with a server-side database check on every sign-in.
- Login route:
/admin/login - Register route:
/admin/register (requires optional registration code from ADMIN_REGISTRATION_CODE env var) - Protected layout at
src/app/admin/layout.tsx redirects unauthenticated users - Session data:
id, email, name, role
Seed the first admin with node scripts/seed-admin.js after deployment. Do not expose the registration endpoint publicly without setting ADMIN_REGISTRATION_CODE.
# Admin Dashboard
Route: /admin (redirects to /admin/dashboard). Composed of:
DashboardStats — live counts for products, gallery images, reviews pending, total contactsQuickActions — shortcut buttons to common admin tasks (Upload Image, Add Product, Review Pending)RecentActivity — latest analytics events in chronological order
# Gallery Management
Route: /admin/gallery. Admins can upload new gallery images via the Cloudflare R2 presigned-URL flow, set a title and category, and toggle featured status.
- Upload via
POST /api/upload → presigned URL → direct R2 upload - Create record via
POST /api/gallery - Delete record via
DELETE /api/gallery/:id - Categories map to the
GalleryCategoryEnum
# Samples Management
Route: /admin/samples. Create, edit, or retire finish sample cards shown on the public /samples page.
- Toggle
isAvailable to show/hide a sample without deleting it - Category must be one of the
SampleCategoryEnum values - Image upload uses the same R2 presigned flow as gallery
# Products Management
Route: /admin/products. Full CRUD for the product catalogue shown on /shop.
- Fields: name, description, price (₦), category, imageUrl, inStock
- Engagement metrics (views, likes, contacts, shares) are read-only in the admin
- Admin can hard-delete products; consider toggling
inStock=false first
# Reviews Moderation
Route: /admin/reviews. All newly submitted reviews default to approved=false. Admins toggle approval to make them visible on the public testimonials page.
- Bulk approve or reject via the data table checkboxes
- Can mark a review as
featured to pin it at the top - Reviews from the API are paginated (50 per page)
# Analytics Dashboard
Route: /admin/analytics. Aggregates data from the Analytics model to surface:
- Total page views + unique visitor estimate
- Event breakdown: page_view, product_view, whatsapp_click, contact_form, etc.
- Top pages by view count
- Daily view trend chart (last 30 days)
- Conversion metrics: view → contact rate
The useAnalytics hook (client) fires a POST /api/analytics event on each meaningful user interaction. IP is stored as a SHA-256 hash for privacy compliance.
# Colours Management
Route: /admin/colours. Manage the display of Nikkolor colour ranges shown on the public /colours page. Entries can be uploaded, categorised, and reordered.
# Gallery API — /api/gallery
| Method | Path | Auth | Description |
|---|
| GET | /api/gallery | Public | List images. Params: category, sort, featured, page, limit |
| POST | /api/gallery | Admin | Create a gallery entry. Body: { title, category, imageUrl } |
| PUT | /api/gallery/[id] | Admin | Update title, category, or featured flag |
| DELETE | /api/gallery/[id] | Admin | Permanently delete an image record |
# Products API — /api/products
| Method | Path | Auth | Description |
|---|
| GET | /api/products | Public | List products with optional category, sort, search, inStock, pagination |
| POST | /api/products | Admin | Create product. Body validated against CreateProductSchema |
| PUT | /api/products/[id] | Admin | Partial update (name, price, stock, category, etc.) |
| DELETE | /api/products/[id] | Admin | Hard delete a product |
# Samples API — /api/samples
| Method | Path | Auth | Description |
|---|
| GET | /api/samples | Public | List samples. Params: category, isAvailable |
| POST | /api/samples | Admin | Create sample. Body: { title, description, category, imageUrl, isAvailable } |
| PUT | /api/samples/[id] | Admin | Update sample fields including availability toggle |
| DELETE | /api/samples/[id] | Admin | Delete a sample record |
# Reviews API — /api/reviews
| Method | Path | Auth | Description |
|---|
| GET | /api/reviews | Public | List approved reviews. Add ?stats=true for aggregate stats. |
| POST | /api/reviews | Public | Submit new review. Body: { name, email?, rating, message }. Defaults to unapproved. |
| PATCH | /api/reviews/[id] | Admin | Toggle approved or featured |
| DELETE | /api/reviews/[id] | Admin | Remove a review permanently |
# Contact API — /api/contact
POST /api/contact — Public. Processes the contact form.
// Request body (ContactFormInput)
{
name: string;
email: string;
phone?: string;
subject?: string;
message: string;
productId?: string; // Optional: links enquiry to a product
productName?: string;
sendVia: "email" | "whatsapp" | "both";
}
// Response
{
success: true,
whatsappUrl?: string, // Present when sendVia is "whatsapp" or "both"
emailSent?: boolean // Present when sendVia is "email" or "both"
}
# Upload API — /api/upload
POST /api/upload — Admin. Returns a presigned Cloudflare R2 URL. The client uploads directly to R2, and the returned publicUrl is stored in the DB record.
// Request body
{
filename: string; // e.g. "my-image.jpg"
contentType: string; // e.g. "image/jpeg"
folder: "products" | "gallery" | "reviews" | "samples";
}
// Response
{
success: true,
uploadUrl: string, // PUT directly to this R2 URL
publicUrl: string // Store this in your DB record
}
# Analytics API — /api/analytics
POST /api/analytics — Public (no auth required). Called by the useAnalytics hook on the client.
// Request body (TrackEventInput)
{
event: AnalyticsEventType; // see types/index.ts for full list
page: string; // e.g. "/gallery"
productId?: string;
metadata?: Record<string, unknown>;
}
Supported event types include: page_view, product_view, product_like, product_share, product_contact, gallery_view, gallery_like, whatsapp_click, contact_form, review_submit, first_visit.
# Auth API — /api/auth
Auth routes are managed by NextAuth.js. The catch-all route at /api/auth/[...nextauth] handles sign-in, sign-out, and session management. A separate POST /api/auth/register route allows new admin creation (protected by ADMIN_REGISTRATION_CODE).
# Admin Model
model Admin {
id String @id @default(cuid())
email String @unique
password String // bcrypt hashed
name String
role String @default("admin")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
# Product Model
model Product {
id String @id @default(cuid())
name String
description String
price Float
category String // venetian, marmorino, travertino, metallic, etc.
imageUrl String
inStock Boolean @default(true)
views Int @default(0)
likes Int @default(0)
contacts Int @default(0)
shares Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
# GalleryImage Model
model GalleryImage {
id String @id @default(cuid())
title String
category String // interior, exterior, office, dining, etc.
imageUrl String
featured Boolean @default(false)
likes Int @default(0)
views Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
# Review Model
model Review {
id String @id @default(cuid())
name String
email String? // Optional for anonymous reviews
rating Int // 1–5 stars
message String
approved Boolean @default(false) // Hidden until admin approves
isAvailable Boolean @default(true)
featured Boolean @default(false) // Pin to top of testimonials
createdAt DateTime @default(now())
}
# Analytics Model
model Analytics {
id String @id @default(cuid())
event String // page_view, product_view, contact_click, like, share…
page String // e.g. "/gallery"
productId String?
metadata String? // JSON string for extra context
userAgent String?
ipHash String? // SHA-256 hashed for privacy
createdAt DateTime @default(now())
}
# Sample Model
model Sample {
id String @id @default(cuid())
title String
description String?
imageUrl String
category String // venetian, stucco, travertino, metallic, liquid-metal, microcemento, other
isAvailable Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Seed samples using npx tsx scripts/seed-samples.ts. This script clears existing samples before re-seeding. For gallery images, use seed-gallery.ts — itappends data without wiping.
# Layout Components
Located in src/components/layout/.
- Header — sticky gradient nav bar with logo, nav links, and active-page detection
- Footer — company details, social links, service columns, legal
- Providers — wraps the app with SessionProvider and analytics on mount
# UI Primitives
Located in src/components/ui/.
Button — variants: primary | secondary | outline | ghost | danger, sizes: sm | md | lgLoader / PageLoader — full-page and inline spinners for Suspense boundariesToast — accessible notification overlay (info / success / error / warning)
# Gallery Components
Located in src/components/gallery/.
GalleryGrid — masonry container; fetches images from API, handles loading/empty statesGalleryCard — individual image tile with like button and analytics trackingCategoryTabs — horizontal scrollable filter tabs that push ?category= query params
# Samples Components
Located in src/components/samples/.
SamplesGrid — fetches samples from API, renders category tabs and sample cards; manages modal state with AnimatePresence- Modal lifecycle: click card →
setSelectedSample(sample) → Framer Motion scale-in → click overlay or ✕ → scale-out and null - WhatsApp enquiry URL built by
generateGalleryInquiryUrl(title, category) in src/lib/whatsapp.ts
# Admin Components
Located in src/components/admin/.
AdminHeader — top bar with page title and sign-out buttonAdminSidebar — collapsible navigation with route highlightingDataTable — reusable sortable table with pagination and bulk selectionDashboardStats — metric cards with live API dataRecentActivity — event feed from analytics endpoint
# Custom Hooks
Located in src/hooks/.
useAnalytics() — exposes a track(event, page, meta?) function; debounces duplicate events; calls POST /api/analyticsuseLocalStorage(key, defaultValue) — persisted state with SSR safetyuseFirstVisit() — returns true on the user's first ever visit (used for welcome animations)
# Environment Variables
Create a .env.local file in the project root (never commit this to version control):
# ─── Database ─────────────────────────────────────────────────
DATABASE_URL="postgresql://..." # CockroachDB connection string
# ─── Auth ──────────────────────────────────────────────────────
NEXTAUTH_URL="https://your-domain.com" # or http://localhost:3000
NEXTAUTH_SECRET="generate with: openssl rand -base64 32"
ADMIN_REGISTRATION_CODE="your-secret-code" # Optional
# ─── Cloudflare R2 ─────────────────────────────────────────────
R2_ACCOUNT_ID="..."
R2_ACCESS_KEY_ID="..."
R2_SECRET_ACCESS_KEY="..."
R2_BUCKET_NAME="graviloch-assets"
R2_PUBLIC_URL="https://pub-xxx.r2.dev"
# ─── Resend (email) ─────────────────────────────────────────────
RESEND_API_KEY="re_..."
FROM_EMAIL="noreply@your-domain.com"
CONTACT_EMAIL="gravilochfinishings@gmail.com"
# ─── WhatsApp ───────────────────────────────────────────────────
NEXT_PUBLIC_WHATSAPP_NUMBER="2348035070793"
WHATSAPP_NUMBER="2348035070793"
Variables prefixed NEXT_PUBLIC_ are exposed in the browser bundle. Do not prefix secrets with this prefix.
# Next.js Configuration
Configuration is in next.config.ts. Key settings:
- Turbopack enabled for development with
--turbopack flag - Remote image patterns configured for the R2 public bucket domain
optimizePackageImports for Framer Motion tree-shaking
# Deployment
The project is designed for Vercel deployment. Recommended build pipeline:
# 1. Install
npm install
# 2. Generate Prisma client
npx prisma generate
# 3. Push schema to CockroachDB (first deploy)
npx prisma db push
# 4. Seed admin account
node scripts/seed-admin.js
# 5. Seed gallery and samples (optional)
npx tsx scripts/seed-gallery.ts
npx tsx scripts/seed-samples.ts
# 6. Build
npm run build
# 7. Start
npm start
On Vercel, set all environment variables in the project settings dashboard. The build command should include npx prisma generate && next build.
# PWA & Service Worker
The site ships a basic PWA manifest at public/manifest.json and a service worker at public/sw.js. The manifest defines app name, icons, and theme color. The service worker pre-caches key assets for improved performance on repeat visits.
# WhatsApp Integration
Located in src/lib/whatsapp.ts. Utility functions generate pre-populated WhatsApp deep-link URLs using the wa.me API:
generateGalleryInquiryUrl(title, category) — for sample/gallery enquiriesgenerateProductEnquiryUrl(productName, price?) — for shop product enquiriesgenerateContactUrl(name, message) — for contact form WhatsApp channel
// All functions return:
// "https://wa.me/2348035070793?text=<encoded-message>"