-
Thank you for reaching out, ${name}!
+
Thank you for reaching out, ${safeName}!
We've received your message and will get back to you within 24–48 hours.
Your message:
- ${message}
+ ${safeMessage}
If you need urgent assistance, you can also reach us at ${STORE_EMAIL} or +234 8160591348.
diff --git a/app/api/cron/publishing/route.ts b/app/api/cron/publishing/route.ts
new file mode 100644
index 0000000..9a396b2
--- /dev/null
+++ b/app/api/cron/publishing/route.ts
@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from "next/server"
+import { runScheduledPublishing } from "@/lib/publishing/service"
+
+export const runtime = "nodejs"
+export const dynamic = "force-dynamic"
+
+export async function GET(request: NextRequest) {
+ const secret = process.env.CRON_SECRET
+ if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
+ }
+ try {
+ return NextResponse.json({ ok: true, result: await runScheduledPublishing() })
+ } catch (error) {
+ console.error("Scheduled publishing failed:", error)
+ return NextResponse.json({ error: "Scheduled publishing failed" }, { status: 500 })
+ }
+}
diff --git a/app/api/v1/admin/ai-cost/route.ts b/app/api/v1/admin/ai-cost/route.ts
index fe3c877..bd25eb2 100644
--- a/app/api/v1/admin/ai-cost/route.ts
+++ b/app/api/v1/admin/ai-cost/route.ts
@@ -3,6 +3,7 @@
// note on durable persistence). Owner-only.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { aiUsageSnapshot, PROMPT_VERSIONS } from '@/integrations/ai/cost'
export const runtime = 'nodejs'
@@ -11,6 +12,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async () => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN')
const snapshot = aiUsageSnapshot()
return { data: { usage: snapshot.records, totals: snapshot.totals, promptVersions: PROMPT_VERSIONS, scope: 'process' } }
})
diff --git a/app/api/v1/admin/approvals/[id]/route.ts b/app/api/v1/admin/approvals/[id]/route.ts
index 914d225..37aab90 100644
--- a/app/api/v1/admin/approvals/[id]/route.ts
+++ b/app/api/v1/admin/approvals/[id]/route.ts
@@ -4,6 +4,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { decideApproval, executeApproval } from '@/lib/approvals/service'
export const runtime = 'nodejs'
@@ -13,6 +14,7 @@ const bodySchema = z.object({ op: z.enum(['approve', 'reject', 'execute']) })
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'orders:manage')) raise('FORBIDDEN')
const parts = req.nextUrl.pathname.split('/').filter(Boolean)
const id = parts[parts.length - 1]
diff --git a/app/api/v1/admin/approvals/route.ts b/app/api/v1/admin/approvals/route.ts
index 5733dca..2930ead 100644
--- a/app/api/v1/admin/approvals/route.ts
+++ b/app/api/v1/admin/approvals/route.ts
@@ -4,6 +4,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { prisma } from '@/lib/prisma'
import { createApproval } from '@/lib/approvals/service'
import type { ApprovalStatus } from '@prisma/client'
@@ -16,6 +17,7 @@ const VALID: ApprovalStatus[] = ['PENDING', 'APPROVED', 'REJECTED', 'EXECUTED']
export const GET = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'orders:manage')) raise('FORBIDDEN')
const statusParam = (req.nextUrl.searchParams.get('status') || 'PENDING').toUpperCase()
const status = (VALID as string[]).includes(statusParam) ? (statusParam as ApprovalStatus) : 'PENDING'
const approvals = await prisma.approvalRequest.findMany({ where: { status }, orderBy: { createdAt: 'desc' }, take: 100 })
@@ -34,6 +36,7 @@ const createSchema = z.object({
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'orders:manage')) raise('FORBIDDEN')
const body = createSchema.parse(await req.json())
const approval = await createApproval({ ...body, createdBy: admin!.id })
return { data: { approval }, status: 201 }
diff --git a/app/api/v1/admin/audit/route.ts b/app/api/v1/admin/audit/route.ts
index 6ae8f39..787f2a6 100644
--- a/app/api/v1/admin/audit/route.ts
+++ b/app/api/v1/admin/audit/route.ts
@@ -2,6 +2,7 @@
// GET /api/v1/admin/audit?targetType=&targetId=&action= -> ADMIN-only audit-log search.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs'
@@ -10,6 +11,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'audit:view')) raise('FORBIDDEN')
const sp = req.nextUrl.searchParams
const where: Record = {}
diff --git a/app/api/v1/admin/copilot/insights/route.ts b/app/api/v1/admin/copilot/insights/route.ts
index 0d8e326..46909e3 100644
--- a/app/api/v1/admin/copilot/insights/route.ts
+++ b/app/api/v1/admin/copilot/insights/route.ts
@@ -3,6 +3,7 @@
// repeat-purchase and top products. Never exposes individual private conversations. Owner-only.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { buildInsightsReport } from '@/lib/copilot/insights'
export const runtime = 'nodejs'
@@ -11,6 +12,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async () => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN')
const report = await buildInsightsReport()
return { data: { report } }
})
diff --git a/app/api/v1/admin/copilot/inventory/route.ts b/app/api/v1/admin/copilot/inventory/route.ts
index f9856d1..3a392e1 100644
--- a/app/api/v1/admin/copilot/inventory/route.ts
+++ b/app/api/v1/admin/copilot/inventory/route.ts
@@ -3,6 +3,7 @@
// confidence + assumptions), dead stock and back-in-stock demand. Read-only; executes nothing.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { buildInventoryAssistantReport } from '@/lib/copilot/inventory-assistant'
export const runtime = 'nodejs'
@@ -11,6 +12,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async () => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN')
const report = await buildInventoryAssistantReport()
return { data: { report } }
})
diff --git a/app/api/v1/admin/copilot/margin/route.ts b/app/api/v1/admin/copilot/margin/route.ts
index fd0dd93..c197c04 100644
--- a/app/api/v1/admin/copilot/margin/route.ts
+++ b/app/api/v1/admin/copilot/margin/route.ts
@@ -3,6 +3,7 @@
// is absent rather than fabricating numbers. Owner-only.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { buildMarginReport } from '@/lib/copilot/margin'
export const runtime = 'nodejs'
@@ -11,6 +12,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN')
const daysRaw = Number(req.nextUrl.searchParams.get('days') ?? 30)
const days = Number.isFinite(daysRaw) ? Math.min(Math.max(daysRaw, 1), 365) : 30
const report = await buildMarginReport(days)
diff --git a/app/api/v1/admin/copilot/marketing/route.ts b/app/api/v1/admin/copilot/marketing/route.ts
index 634ba89..8e91500 100644
--- a/app/api/v1/admin/copilot/marketing/route.ts
+++ b/app/api/v1/admin/copilot/marketing/route.ts
@@ -4,6 +4,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { generateMarketingDraft } from '@/lib/copilot/marketing'
export const runtime = 'nodejs'
@@ -23,6 +24,7 @@ const bodySchema = z.object({
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN')
const body = bodySchema.parse(await req.json())
const draft = await generateMarketingDraft(body)
return { data: { draft } }
diff --git a/app/api/v1/admin/daily-brief/route.ts b/app/api/v1/admin/daily-brief/route.ts
index 0f68eee..3579595 100644
--- a/app/api/v1/admin/daily-brief/route.ts
+++ b/app/api/v1/admin/daily-brief/route.ts
@@ -4,6 +4,7 @@
// The AI summary must derive only from the metrics JSON; it never executes any action.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { getAi } from '@/integrations/registry'
import { buildDailyBrief } from '@/lib/copilot/daily-brief'
@@ -13,6 +14,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async () => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN')
const brief = await buildDailyBrief()
diff --git a/app/api/v1/admin/feature-flags/route.ts b/app/api/v1/admin/feature-flags/route.ts
index 87ebfdd..8d7ca7f 100644
--- a/app/api/v1/admin/feature-flags/route.ts
+++ b/app/api/v1/admin/feature-flags/route.ts
@@ -4,6 +4,7 @@
// lib/config/feature-flags.ts). Owner/admin-only.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { prisma } from '@/lib/prisma'
import { allFlags } from '@/lib/config/feature-flags'
@@ -13,6 +14,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async () => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN')
const persisted = await prisma.featureFlag.findMany({ select: { key: true, enabled: true, description: true } }).catch(() => [])
return { data: { effective: allFlags(), persisted, source: 'FEATURE_FLAGS env var' } }
})
diff --git a/app/api/v1/admin/integration-events/[id]/reprocess/route.ts b/app/api/v1/admin/integration-events/[id]/reprocess/route.ts
index 364f0d9..8d63bbe 100644
--- a/app/api/v1/admin/integration-events/[id]/reprocess/route.ts
+++ b/app/api/v1/admin/integration-events/[id]/reprocess/route.ts
@@ -3,6 +3,7 @@
// Idempotency (WebhookReceipt) still guards duplicate side-effects.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { reprocessIntegrationEvent } from '@/lib/ops/reprocess'
export const runtime = 'nodejs'
@@ -10,6 +11,7 @@ export const runtime = 'nodejs'
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN')
const parts = req.nextUrl.pathname.split('/').filter(Boolean)
const id = parts[parts.length - 2] // .../integration-events//reprocess
if (!id) raise('VALIDATION_ERROR', 'Missing integration event id.')
diff --git a/app/api/v1/admin/integration-events/route.ts b/app/api/v1/admin/integration-events/route.ts
index 064b8bf..a957e35 100644
--- a/app/api/v1/admin/integration-events/route.ts
+++ b/app/api/v1/admin/integration-events/route.ts
@@ -2,6 +2,7 @@
// GET /api/v1/admin/integration-events?processed=false&provider= -> ADMIN-only webhook/event log.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs'
@@ -10,6 +11,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN')
const sp = req.nextUrl.searchParams
const where: Record = {}
const processed = sp.get('processed')
diff --git a/app/api/v1/admin/inventory/route.ts b/app/api/v1/admin/inventory/route.ts
index e38b666..afeec77 100644
--- a/app/api/v1/admin/inventory/route.ts
+++ b/app/api/v1/admin/inventory/route.ts
@@ -4,6 +4,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { inventoryHealth, setStock } from '@/lib/catalogue/inventory'
export const runtime = 'nodejs'
@@ -12,6 +13,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async () => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN')
const health = await inventoryHealth()
return { data: { health } }
})
@@ -21,6 +23,7 @@ const bodySchema = z.object({ productId: z.string().min(1), stock: z.number().in
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN')
const { productId, stock } = bodySchema.parse(await req.json())
const result = await setStock(productId, stock, admin!.id)
return { data: result }
diff --git a/app/api/v1/admin/jobs/[id]/reprocess/route.ts b/app/api/v1/admin/jobs/[id]/reprocess/route.ts
index a1fc457..853399a 100644
--- a/app/api/v1/admin/jobs/[id]/reprocess/route.ts
+++ b/app/api/v1/admin/jobs/[id]/reprocess/route.ts
@@ -2,6 +2,7 @@
// POST /api/v1/admin/jobs/:id/reprocess -> ADMIN-only re-queue of a FAILED job.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { reprocessJob } from '@/lib/ops/reprocess'
export const runtime = 'nodejs'
@@ -9,6 +10,7 @@ export const runtime = 'nodejs'
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN')
const parts = req.nextUrl.pathname.split('/').filter(Boolean)
// .../jobs//reprocess -> id is second-to-last
const id = parts[parts.length - 2]
diff --git a/app/api/v1/admin/jobs/route.ts b/app/api/v1/admin/jobs/route.ts
index 7bb6d49..ce1f780 100644
--- a/app/api/v1/admin/jobs/route.ts
+++ b/app/api/v1/admin/jobs/route.ts
@@ -2,6 +2,7 @@
// GET /api/v1/admin/jobs?status=FAILED -> ADMIN-only job-run list (failed-job retention view).
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { prisma } from '@/lib/prisma'
import type { JobStatus } from '@prisma/client'
@@ -13,6 +14,7 @@ const VALID: JobStatus[] = ['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED']
export const GET = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'settings:manage')) raise('FORBIDDEN')
const statusParam = req.nextUrl.searchParams.get('status')?.toUpperCase()
const where = statusParam && (VALID as string[]).includes(statusParam) ? { status: statusParam as JobStatus } : {}
const jobs = await prisma.jobRun.findMany({ where, orderBy: { createdAt: 'desc' }, take: 100 })
diff --git a/app/api/v1/admin/loyalty/route.ts b/app/api/v1/admin/loyalty/route.ts
index b1e0c60..9c33795 100644
--- a/app/api/v1/admin/loyalty/route.ts
+++ b/app/api/v1/admin/loyalty/route.ts
@@ -3,6 +3,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { adjustPoints } from '@/lib/loyalty/service'
export const runtime = 'nodejs'
@@ -17,6 +18,7 @@ const bodySchema = z.object({
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN')
const { userId, delta, reason } = bodySchema.parse(await req.json())
const result = await adjustPoints(userId, delta, reason, admin!.id)
return { data: { balanceAfter: result.balanceAfter }, status: 201 }
diff --git a/app/api/v1/admin/products/sync/route.ts b/app/api/v1/admin/products/sync/route.ts
index 2c0e3be..cf57c6c 100644
--- a/app/api/v1/admin/products/sync/route.ts
+++ b/app/api/v1/admin/products/sync/route.ts
@@ -4,6 +4,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { syncCatalog } from '@/lib/catalogue/sync'
export const runtime = 'nodejs'
@@ -14,6 +15,7 @@ const bodySchema = z.object({ apply: z.boolean().optional() }).optional()
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'products:manage')) raise('FORBIDDEN')
let apply = false
try {
const body = bodySchema.parse(await req.json().catch(() => ({})))
diff --git a/app/api/v1/admin/referrals/route.ts b/app/api/v1/admin/referrals/route.ts
index d5e162b..a92700f 100644
--- a/app/api/v1/admin/referrals/route.ts
+++ b/app/api/v1/admin/referrals/route.ts
@@ -5,6 +5,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { prisma } from '@/lib/prisma'
import { requestReferralReward, reverseReferral } from '@/lib/referrals/service'
@@ -16,6 +17,7 @@ const STATUSES = ['PENDING', 'QUALIFIED', 'REWARDED', 'REVERSED']
export const GET = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN')
const statusParam = req.nextUrl.searchParams.get('status')?.toUpperCase()
const where = statusParam && STATUSES.includes(statusParam) ? { status: statusParam } : {}
const referrals = await prisma.referral.findMany({ where, orderBy: { createdAt: 'desc' }, take: 100 })
@@ -30,6 +32,7 @@ const bodySchema = z.object({
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN')
const { referralId, op } = bodySchema.parse(await req.json())
const data: { approval: unknown; referral: unknown } = { approval: null, referral: null }
if (op === 'request_reward') {
diff --git a/app/api/v1/admin/reviews/[id]/route.ts b/app/api/v1/admin/reviews/[id]/route.ts
index d47356e..3f59239 100644
--- a/app/api/v1/admin/reviews/[id]/route.ts
+++ b/app/api/v1/admin/reviews/[id]/route.ts
@@ -4,6 +4,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { prisma } from '@/lib/prisma'
import { writeAudit } from '@/lib/audit'
@@ -14,6 +15,7 @@ const bodySchema = z.object({ decision: z.enum(['approve', 'reject']), reason: z
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'content:manage')) raise('FORBIDDEN')
const parts = req.nextUrl.pathname.split('/').filter(Boolean)
const id = parts[parts.length - 1]
diff --git a/app/api/v1/admin/reviews/route.ts b/app/api/v1/admin/reviews/route.ts
index 5e96c5b..0efa93f 100644
--- a/app/api/v1/admin/reviews/route.ts
+++ b/app/api/v1/admin/reviews/route.ts
@@ -2,6 +2,7 @@
// GET /api/v1/admin/reviews?status=PENDING -> ADMIN-only review moderation queue.
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { prisma } from '@/lib/prisma'
import type { ModerationStatus } from '@prisma/client'
@@ -13,6 +14,7 @@ const VALID: ModerationStatus[] = ['PENDING', 'APPROVED', 'REJECTED']
export const GET = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'content:manage')) raise('FORBIDDEN')
const statusParam = (req.nextUrl.searchParams.get('status') || 'PENDING').toUpperCase()
const status = (VALID as string[]).includes(statusParam) ? (statusParam as ModerationStatus) : 'PENDING'
diff --git a/app/api/v1/admin/sample-credits/route.ts b/app/api/v1/admin/sample-credits/route.ts
index 2081325..8fcf760 100644
--- a/app/api/v1/admin/sample-credits/route.ts
+++ b/app/api/v1/admin/sample-credits/route.ts
@@ -4,6 +4,7 @@
import { z } from 'zod'
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { grantSampleCredit } from '@/lib/samples/service'
export const runtime = 'nodejs'
@@ -19,6 +20,7 @@ const bodySchema = z.object({
export const POST = route(async ({ req }) => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'marketing:manage')) raise('FORBIDDEN')
const body = bodySchema.parse(await req.json())
const credit = await grantSampleCredit(body, admin!.id)
return { data: { credit: { id: credit.id, amountNGN: credit.amountNGN, remainingNGN: credit.remainingNGN, expiresAt: credit.expiresAt } }, status: 201 }
diff --git a/app/api/v1/admin/status/route.ts b/app/api/v1/admin/status/route.ts
index 35d1066..bb9db12 100644
--- a/app/api/v1/admin/status/route.ts
+++ b/app/api/v1/admin/status/route.ts
@@ -3,6 +3,7 @@
// Never exposes secret values (only whether each integration is configured).
import { route, raise } from '@/lib/http/handler'
import { getAdminUser } from '@/lib/admin'
+import { hasCapability, resolveRole } from '@/lib/authz-core'
import { integrationStatus } from '@/lib/env'
import { providerStatus } from '@/integrations/registry'
import { allFlags } from '@/lib/config/feature-flags'
@@ -13,6 +14,7 @@ export const dynamic = 'force-dynamic'
export const GET = route(async () => {
const admin = await getAdminUser()
if (!admin) raise('FORBIDDEN')
+ if (!hasCapability(resolveRole(admin), 'dashboard:view')) raise('FORBIDDEN')
return {
data: {
integrations: integrationStatus(),
diff --git a/app/drops/page.tsx b/app/drops/page.tsx
index 0ad8c1c..177764a 100644
--- a/app/drops/page.tsx
+++ b/app/drops/page.tsx
@@ -7,6 +7,8 @@ import { ProductCard } from "@/components/ui/product-card"
import { ProductGrid } from "@/components/ui/product-grid"
import { CountdownTimer } from "@/components/drops/countdown-timer"
import { NotifyButton } from "@/components/drops/notify-button"
+import { getActiveCampaign } from "@/lib/campaigns/service"
+import Link from "next/link"
export const metadata = {
title: "Limited Drops | Fádé",
@@ -18,6 +20,7 @@ export const dynamic = "force-dynamic"
export default async function DropsPage() {
const now = new Date()
+ const campaign = await getActiveCampaign(now)
const upcomingDrops: Awaited> = []
const liveDrops: Awaited> = []
@@ -46,6 +49,17 @@ export default async function DropsPage() {
return (
+ {campaign && (
+
+ {campaign.desktopImage && }
+
+
Current campaign
+
{campaign.title}
+ {campaign.description &&
{campaign.description}
}
+ {campaign.ctaLabel && campaign.ctaHref &&
{campaign.ctaLabel}}
+
+
+ )}
{/* Night hero */}
diff --git a/app/faq/page.tsx b/app/faq/page.tsx
index 39ce435..82e44fa 100644
--- a/app/faq/page.tsx
+++ b/app/faq/page.tsx
@@ -1,12 +1,18 @@
import type { Metadata } from "next"
import { MainLayout } from "@/components/layout/main-layout"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
+import { ManagedPage } from "@/components/pages/managed-page"
+import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries"
-export const metadata: Metadata = {
+export const dynamic = "force-dynamic"
+
+const fallbackMetadata: Metadata = {
title: "FAQ | Fádé Essence",
description: "Frequently asked questions about ordering, delivery, payments, and returns at Fádé Essence.",
}
+export function generateMetadata() { return getManagedPageMetadata("faq", fallbackMetadata) }
+
const faqs = [
{
question: "Do you deliver nationwide?",
@@ -50,7 +56,9 @@ const faqs = [
},
]
-export default function FAQPage() {
+export default async function FAQPage() {
+ const managed = await getPublishedPage("faq")
+ if (managed) return
return (
diff --git a/app/help/faq/page.tsx b/app/help/faq/page.tsx
index ddf3f21..e01914c 100644
--- a/app/help/faq/page.tsx
+++ b/app/help/faq/page.tsx
@@ -8,12 +8,18 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { RETURNS_WINDOW_DAYS } from "@/lib/pdp/policy";
+import { ManagedPage } from "@/components/pages/managed-page";
+import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries";
-export const metadata: Metadata = {
+export const dynamic = "force-dynamic";
+
+const fallbackMetadata: Metadata = {
title: "FAQ | Fádé",
description: "Frequently asked questions about shopping at Fádé.",
};
+export function generateMetadata() { return getManagedPageMetadata("help/faq", fallbackMetadata); }
+
const faqs = [
{
question: "What payment methods do you accept?",
@@ -66,7 +72,9 @@ const faqs = [
},
];
-export default function FAQPage() {
+export default async function FAQPage() {
+ const managed = await getPublishedPage("help/faq");
+ if (managed) return ;
return (
diff --git a/app/help/page.tsx b/app/help/page.tsx
index 7b9e134..c718418 100644
--- a/app/help/page.tsx
+++ b/app/help/page.tsx
@@ -3,12 +3,18 @@ import Link from "next/link"
import { ArrowUpRight } from "lucide-react"
import { MainLayout } from "@/components/layout/main-layout"
+import { ManagedPage } from "@/components/pages/managed-page"
+import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries"
-export const metadata: Metadata = {
+export const dynamic = "force-dynamic"
+
+const fallbackMetadata: Metadata = {
title: "Help Center | Fádé",
description: "Get help with your orders, shipping, and returns.",
}
+export function generateMetadata() { return getManagedPageMetadata("help", fallbackMetadata) }
+
const helpTopics = [
{
title: "FAQ",
@@ -42,7 +48,9 @@ const helpTopics = [
},
]
-export default function HelpPage() {
+export default async function HelpPage() {
+ const managed = await getPublishedPage("help")
+ if (managed) return
return (
;
return (
diff --git a/app/help/shipping/page.tsx b/app/help/shipping/page.tsx
index 60d0218..7c79f38 100644
--- a/app/help/shipping/page.tsx
+++ b/app/help/shipping/page.tsx
@@ -4,13 +4,21 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Truck, Clock, Package, MapPin } from "lucide-react";
import { getCommerceConfig } from "@/lib/config/commerce";
import { formatPrice } from "@/lib/format";
+import { ManagedPage } from "@/components/pages/managed-page";
+import { getManagedPageMetadata, getPublishedPage } from "@/lib/pages/queries";
-export const metadata: Metadata = {
+export const dynamic = "force-dynamic";
+
+const fallbackMetadata: Metadata = {
title: "Shipping Information | Fádé",
description: "Learn about our shipping options and delivery times.",
};
-export default function ShippingPage() {
+export function generateMetadata() { return getManagedPageMetadata("help/shipping", fallbackMetadata); }
+
+export default async function ShippingPage() {
+ const managed = await getPublishedPage("help/shipping");
+ if (managed) return
;
const { shipping } = getCommerceConfig();
return (
diff --git a/app/journal/[slug]/page.tsx b/app/journal/[slug]/page.tsx
index 9dcd466..adb4b9b 100644
--- a/app/journal/[slug]/page.tsx
+++ b/app/journal/[slug]/page.tsx
@@ -1,15 +1,24 @@
-import { journalArticles, articleContent } from "@/lib/journal-articles"
import { notFound } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ArrowUpRight } from "lucide-react"
+import type { Metadata } from "next"
+
import { prisma } from "@/lib/prisma"
+import {
+ getPublicStory,
+ getPublishedStoryCards,
+ getAllStorySlugs,
+} from "@/lib/stories/queries"
import { MainLayout } from "@/components/layout/main-layout"
import { ProductCard } from "@/components/ui/product-card"
import { mapPrismaProductToCard } from "@/lib/queries/products"
-import type { Metadata } from "next"
+import { StoryBlocks } from "@/components/journal/story-blocks"
+
+export const dynamic = "force-dynamic"
-export function generateStaticParams() {
- return journalArticles.map((a) => ({ slug: a.slug }))
+export async function generateStaticParams() {
+ const slugs = await getAllStorySlugs()
+ return slugs.map((slug) => ({ slug }))
}
export async function generateMetadata({
@@ -18,11 +27,14 @@ export async function generateMetadata({
params: Promise<{ slug: string }>
}): Promise
{
const { slug } = await params
- const article = journalArticles.find((a) => a.slug === slug)
- if (!article) return {}
+ const story = await getPublicStory(slug)
+ if (!story) return {}
return {
- title: `${article.title} | The Journal | Fádé`,
- description: article.excerpt,
+ title: `${story.seoTitle ?? story.title} | The Journal | Fádé`,
+ description: story.seoDescription ?? story.excerpt,
+ openGraph: story.socialImageUrl
+ ? { images: [{ url: story.socialImageUrl }] }
+ : undefined,
}
}
@@ -32,23 +44,35 @@ export default async function ArticlePage({
params: Promise<{ slug: string }>
}) {
const { slug } = await params
- const article = journalArticles.find((a) => a.slug === slug)
- if (!article) notFound()
+ const story = await getPublicStory(slug)
+ if (!story) notFound()
- const paragraphs = articleContent[slug] ?? []
- const others = journalArticles.filter((a) => a.slug !== slug).slice(0, 3)
+ // Related products: prefer explicit relations, then any product blocks.
+ const productSlugs = Array.from(
+ new Set([
+ ...story.relatedProductSlugs,
+ ...story.blocks
+ .filter((b) => b.type === "product")
+ .map((b) => String((b.data as { productSlug?: string }).productSlug ?? ""))
+ .filter(Boolean),
+ ])
+ )
let relatedProducts: Awaited> = []
- if (article.relatedProductSlugs?.length) {
+ if (productSlugs.length) {
try {
relatedProducts = await prisma.product.findMany({
- where: { slug: { in: article.relatedProductSlugs }, deletedAt: null },
+ where: { slug: { in: productSlugs }, deletedAt: null },
})
} catch {
// silently degrade
}
}
+ const others = (await getPublishedStoryCards())
+ .filter((s) => s.slug !== slug)
+ .slice(0, 3)
+
return (
- {/* Back link */}
- {/* Header */}
- {article.category}
- ·
- {article.readTime}
+ {story.category}
+ {story.readTime && (
+ <>
+ ·
+ {story.readTime}
+ >
+ )}
·
- {new Date(article.date).toLocaleDateString("en-NG", {
+ {new Date(story.date).toLocaleDateString("en-NG", {
year: "numeric",
month: "long",
day: "numeric",
@@ -81,28 +107,19 @@ export default async function ArticlePage({
- {article.title}
+ {story.title}
-
- {article.excerpt}
-
+ {(story.subtitle || story.excerpt) && (
+
+ {story.subtitle || story.excerpt}
+
+ )}
- {/* Body */}
-
- {paragraphs.map((para, i) => (
-
- {para}
-
- ))}
-
+
- {/* Related Products */}
{relatedProducts.length > 0 && (
Referenced in this story
@@ -114,33 +131,35 @@ export default async function ArticlePage({
)}
- {/* More Articles */}
-
-
More from the Journal
-
- {others.map((a) => (
-
-
-
- {a.category} · {a.readTime}
-
-
- {a.title}
-
-
-
-
- ))}
+ {others.length > 0 && (
+
+
More from the Journal
+
+ {others.map((a) => (
+
+
+
+ {a.category}
+ {a.readTime ? ` · ${a.readTime}` : ""}
+
+
+ {a.title}
+
+
+
+
+ ))}
+
-
+ )}
diff --git a/app/journal/page.tsx b/app/journal/page.tsx
index b4c1114..4f711df 100644
--- a/app/journal/page.tsx
+++ b/app/journal/page.tsx
@@ -1,9 +1,11 @@
import Link from "next/link"
import { ArrowRight, ArrowUpRight } from "lucide-react"
-import { journalArticles } from "@/lib/journal-articles"
+import { getPublishedStoryCards } from "@/lib/stories/queries"
import { MainLayout } from "@/components/layout/main-layout"
+export const dynamic = "force-dynamic"
+
export const metadata = {
title: "The Journal | Fádé",
description:
@@ -18,8 +20,32 @@ function formatDate(date: string) {
})
}
-export default function JournalPage() {
- const [featured, ...rest] = journalArticles
+export default async function JournalPage() {
+ const cards = await getPublishedStoryCards()
+ const [featured, ...rest] = cards
+
+ if (!featured) {
+ return (
+
+
+
+
+ The Journal
+
+ Notes & stories
+
+
+
+ New stories are coming soon.
+
+
+
+
+ )
+ }
return (
diff --git a/app/layout.tsx b/app/layout.tsx
index 23c15f3..f428766 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -8,6 +8,9 @@ import { ThemeProvider } from "@/components/theme-provider";
import { ScrollToTop } from "@/components/scroll-to-top";
import { NavigationProgress } from "@/components/loading/navigation-progress";
import { Suspense } from "react";
+import { SiteChromeProvider } from "@/components/layout/site-chrome-context";
+import { getSiteSettings } from "@/lib/settings/service";
+import { getNavigation } from "@/lib/navigation/service";
const fraunces = Fraunces({
subsets: ["latin"],
@@ -48,76 +51,76 @@ function resolveSiteUrl() {
const siteUrl = resolveSiteUrl();
-export const metadata: Metadata = {
- metadataBase: siteUrl,
- title: {
- default: "Fádé Essence | Luxury Perfumes",
- template: "%s | Fádé Essence",
- },
- description:
+const str = (v: unknown, fallback: string): string =>
+ typeof v === "string" && v.trim() ? v : fallback;
+
+// SEO defaults are admin-editable (Site settings). Falls back to the built-in copy if unset or
+// if the settings store is unavailable.
+export async function generateMetadata(): Promise {
+ const settings = await getSiteSettings().catch(() => ({}) as Record);
+
+ const siteName = str(settings.siteName, "Fádé Essence");
+ const defaultTitle = str(settings.seoDefaultTitle, "Fádé Essence | Luxury Perfumes");
+ const description = str(
+ settings.seoDefaultDescription,
"Discover premium luxury perfumes at Fádé Essence. Curated collection of timeless fragrances and sophistication.",
- keywords: [
- "luxury perfumes",
- "fragrances",
- "Fádé",
- "premium perfume",
- "Nigeria",
- ],
- manifest: "/manifest.webmanifest",
- appleWebApp: {
- capable: true,
- statusBarStyle: "black-translucent",
- title: "Fádé",
- },
- formatDetection: { telephone: false },
- authors: [{ name: "Fádé Essence" }],
- creator: "Fádé Essence",
- publisher: "Fádé Essence",
- openGraph: {
- type: "website",
- locale: "en_NG",
- url: siteUrl.toString(),
- siteName: "Fádé Essence",
- title: "Fádé Essence | Luxury Perfumes",
- description: "Discover premium luxury perfumes at Fádé Essence.",
- images: [
- {
- url: "/og-image.jpg",
- width: 1200,
- height: 630,
- alt: "Fádé Essence",
- },
- ],
- },
- twitter: {
- card: "summary_large_image",
- title: "Fádé Essence | Luxury Perfumes",
- description: "Discover premium luxury perfumes at Fádé Essence.",
- images: ["/og-image.jpg"],
- },
- robots: {
- index: true,
- follow: true,
- googleBot: {
+ );
+ const ogImage = str(settings.seoOgImage, "/og-image.jpg");
+
+ return {
+ metadataBase: siteUrl,
+ title: { default: defaultTitle, template: `%s | ${siteName}` },
+ description,
+ keywords: ["luxury perfumes", "fragrances", "Fádé", "premium perfume", "Nigeria"],
+ manifest: "/manifest.webmanifest",
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "black-translucent",
+ title: "Fádé",
+ },
+ formatDetection: { telephone: false },
+ authors: [{ name: siteName }],
+ creator: siteName,
+ publisher: siteName,
+ openGraph: {
+ type: "website",
+ locale: "en_NG",
+ url: siteUrl.toString(),
+ siteName,
+ title: defaultTitle,
+ description,
+ images: [{ url: ogImage, width: 1200, height: 630, alt: siteName }],
+ },
+ twitter: {
+ card: "summary_large_image",
+ title: defaultTitle,
+ description,
+ images: [ogImage],
+ },
+ robots: {
index: true,
follow: true,
- "max-video-preview": -1,
- "max-image-preview": "large",
- "max-snippet": -1,
+ googleBot: {
+ index: true,
+ follow: true,
+ "max-video-preview": -1,
+ "max-image-preview": "large",
+ "max-snippet": -1,
+ },
},
- },
- verification: {
- // Add your verification codes here when available
- // google: "your-google-verification-code",
- // yandex: "your-yandex-verification-code",
- },
-};
+ };
+}
-export default function RootLayout({
+export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
+ const [settings, nav] = await Promise.all([
+ getSiteSettings().catch(() => ({})),
+ getNavigation().catch(() => ({}) as Awaited>),
+ ]);
+
return (
-
-
-
-
-
- {children}
-
-
+
+
+
+
+
+
+ {children}
+
+
+
diff --git a/app/page.tsx b/app/page.tsx
index 98fbe9a..1ca1ac0 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -16,6 +16,8 @@ import { getApprovedFusionHeroFragrance } from "@/lib/hero/fusion-config"
import { prisma } from "@/lib/prisma"
+import { getHomepageLayout } from "@/lib/homepage/service"
+
export const dynamic = "force-dynamic"
export default async function HomePage() {
@@ -82,25 +84,73 @@ export default async function HomePage() {
}
})
- return (
+ const layout = await getHomepageLayout()
+
+ const renderSection = (type: string, config: Record) => {
+ switch (type) {
+ case "hero":
+ return (
+
+ )
+ case "featured_products":
+ return (
+
+ )
+ case "fragrance_families":
+ return (
+
+ )
+ case "brand_story":
+ return (
+
+ )
+ case "concierge_invitation":
+ return (
+
+ )
+ default:
+ return null
+ }
+ }
+ return (
-
-
-
-
-
-
-
-
-
-
-
+ {layout
+ .filter((section) => section.visible)
+ .map((section) => renderSection(section.type, section.config))}
-
)
-
}
diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx
index 045a1f4..0e85efb 100644
--- a/components/admin/admin-header.tsx
+++ b/components/admin/admin-header.tsx
@@ -28,6 +28,7 @@ interface AdminHeaderProps {
id: string
name: string | null
email: string
+ roleLabel?: string | null
}
}
@@ -471,6 +472,11 @@ export function AdminHeader({ user }: AdminHeaderProps) {
{displayName}
{displayEmail}
+ {user?.roleLabel && (
+
+ {user.roleLabel}
+
+ )}
diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx
index 74aa62a..8016c44 100644
--- a/components/admin/admin-sidebar.tsx
+++ b/components/admin/admin-sidebar.tsx
@@ -3,7 +3,7 @@
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
-import { LayoutDashboard, Package, ShoppingCart, Tags, Folder, ChevronLeft, Menu, LogOut, Mail, Warehouse, Bot } from "lucide-react"
+import { LayoutDashboard, Package, ShoppingCart, Tags, Folder, ChevronLeft, Menu, LogOut, Mail, Warehouse, Bot, BookOpen, Users, Settings, Navigation as NavigationIcon, Home, ImageIcon, Shield, Flag, ScrollText, MessageSquare, FileText, Inbox, Megaphone, Route } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -11,46 +11,62 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { signOutAction } from "@/app/auth/signout/actions"
const navItems = [
- { name: "Dashboard", href: "/admin", icon: LayoutDashboard },
- { name: "Products", href: "/admin/products", icon: Package },
- { name: "Categories", href: "/admin/categories", icon: Tags },
- { name: "Collections", href: "/admin/collections", icon: Folder },
- { name: "Orders", href: "/admin/orders", icon: ShoppingCart },
- { name: "Inventory", href: "/admin/inventory", icon: Warehouse },
- { name: "Newsletter", href: "/admin/newsletter", icon: Mail },
- { name: "Concierge V2", href: "/admin/concierge", icon: Bot },
+ { name: "Dashboard", href: "/admin", icon: LayoutDashboard, capability: "dashboard:view" },
+ { name: "Homepage", href: "/admin/homepage", icon: Home, capability: "content:manage" },
+ { name: "Products", href: "/admin/products", icon: Package, capability: "products:manage" },
+ { name: "Categories", href: "/admin/categories", icon: Tags, capability: "products:manage" },
+ { name: "Collections", href: "/admin/collections", icon: Folder, capability: "products:manage" },
+ { name: "Journal", href: "/admin/stories", icon: BookOpen, capability: "content:manage" },
+ { name: "Pages", href: "/admin/pages", icon: FileText, capability: "content:manage" },
+ { name: "Media", href: "/admin/media", icon: ImageIcon, capability: "content:manage" },
+ { name: "Reviews", href: "/admin/reviews", icon: MessageSquare, capability: "content:manage" },
+ { name: "Orders", href: "/admin/orders", icon: ShoppingCart, capability: "orders:manage" },
+ { name: "Inventory", href: "/admin/inventory", icon: Warehouse, capability: "products:manage" },
+ { name: "Customers", href: "/admin/customers", icon: Users, capability: "customers:view" },
+ { name: "Enquiries", href: "/admin/enquiries", icon: Inbox, capability: "support:manage" },
+ { name: "Newsletter", href: "/admin/newsletter", icon: Mail, capability: "marketing:manage" },
+ { name: "Campaigns", href: "/admin/campaigns", icon: Megaphone, capability: "marketing:manage" },
+ { name: "Concierge V2", href: "/admin/concierge", icon: Bot, capability: "content:manage" },
+ { name: "Navigation", href: "/admin/navigation", icon: NavigationIcon, capability: "settings:manage" },
+ { name: "Settings", href: "/admin/settings", icon: Settings, capability: "settings:manage" },
+ { name: "Redirects", href: "/admin/redirects", icon: Route, capability: "settings:manage" },
+ { name: "Email templates", href: "/admin/email-templates", icon: Mail, capability: "settings:manage" },
+ { name: "Feature flags", href: "/admin/feature-flags", icon: Flag, capability: "settings:manage" },
+ { name: "Audit log", href: "/admin/audit", icon: ScrollText, capability: "audit:view" },
+ { name: "Users & roles", href: "/admin/users", icon: Shield, capability: "users:manage" },
]
-function SidebarContent() {
+function SidebarContent({ capabilities }: { capabilities: string[] }) {
const pathname = usePathname()
const [isSigningOut, startSignOutTransition] = React.useTransition()
+ const visibleItems = navItems.filter((item) => capabilities.includes(item.capability))
return (
-
+
{/* Brand */}
-
+
Fádé
- Admin
+ Admin
- {/* Navigation */}
-
- {navItems.map((item) => {
+ {/* Navigation — scrolls independently when items exceed the viewport */}
+
+ {visibleItems.map((item) => {
const isActive = pathname === item.href || (item.href !== "/admin" && pathname.startsWith(item.href))
return (
-
+
{item.name}
)
@@ -58,10 +74,10 @@ function SidebarContent() {
{/* Footer */}
-
+
View Storefront
@@ -85,14 +101,14 @@ function SidebarContent() {
)
}
-export function AdminSidebar() {
+export function AdminSidebar({ capabilities = [] }: { capabilities?: string[] }) {
const [open, setOpen] = React.useState(false)
return (
<>
{/* Desktop Sidebar */}
-
-
+
{/* Mobile Sidebar */}
@@ -103,8 +119,8 @@ export function AdminSidebar() {
Open menu
-
-
+
+
>
diff --git a/components/admin/campaign-manager.tsx b/components/admin/campaign-manager.tsx
new file mode 100644
index 0000000..74694d2
--- /dev/null
+++ b/components/admin/campaign-manager.tsx
@@ -0,0 +1,15 @@
+"use client"
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { MediaPicker } from "@/components/admin/media-picker"
+
+export function CampaignManager({ campaigns, coupons, products }: { campaigns: any[]; coupons: any[]; products: { id: string; name: string }[] }) {
+ const router = useRouter(); const [image, setImage] = React.useState(""); const [mobileImage, setMobileImage] = React.useState(""); const [productIds, setProductIds] = React.useState([])
+ async function submit(event: React.FormEvent, kind: "campaign" | "coupon") { event.preventDefault(); const form = new FormData(event.currentTarget); const body: Record = {}; form.forEach((value, key) => { body[key] = value }); const response = await fetch("/api/admin/campaigns", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body, kind, desktopImage: image, mobileImage, productIds, status: form.get("published") ? "PUBLISHED" : "DRAFT", active: Boolean(form.get("active")) }) }); const data = await response.json().catch(() => ({})); if (!response.ok) return toast.error(data.error || "Save failed"); toast.success(kind === "campaign" ? "Campaign saved" : "Coupon saved"); router.refresh(); event.currentTarget.reset(); setImage(""); setMobileImage(""); setProductIds([]) }
+ return New campaign {campaigns.map((item) =>
{item.title}
{item.status} · /{item.slug}
)}
New discount code {coupons.map((item) =>
{item.code}
{item.type} {item.value} · {item.active ? "Active" : "Inactive"}
)}
+}
diff --git a/components/admin/customer-manager.tsx b/components/admin/customer-manager.tsx
new file mode 100644
index 0000000..80b4ed8
--- /dev/null
+++ b/components/admin/customer-manager.tsx
@@ -0,0 +1,12 @@
+"use client"
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+type Customer = { id: string; name: string | null; email: string; tags: string | null; notes: string | null; segment: string | null; status: string; createdAt: string | Date; orderCount: number; totalSpent: number }
+function Row({ customer }: { customer: Customer }) { const router = useRouter(); const [tags, setTags] = React.useState(customer.tags ?? ""); const [notes, setNotes] = React.useState(customer.notes ?? ""); const [segment, setSegment] = React.useState(customer.segment ?? ""); async function save() { const response = await fetch(`/api/admin/customers/${customer.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tags, notes, segment }) }); if (response.ok) { toast.success("Customer updated"); router.refresh() } else toast.error("Update failed") } return {customer.name || "Unnamed customer"}
{customer.email}
{customer.orderCount} orders · ₦{customer.totalSpent.toLocaleString()}
Segment setSegment(e.target.value)} placeholder="VIP" />
Tags setTags(e.target.value)} placeholder="vip, wholesale" />
Private notes
Save }
+export function CustomerManager({ customers, query, segment }: { customers: Customer[]; query: string; segment: string }) { return Customers Real customer records, order value, tags, segments, and private notes.
{customers.length ? customers.map((customer) =>
|
) :
No matching customers. }
}
diff --git a/components/admin/email-template-manager.tsx b/components/admin/email-template-manager.tsx
new file mode 100644
index 0000000..58e5487
--- /dev/null
+++ b/components/admin/email-template-manager.tsx
@@ -0,0 +1,11 @@
+"use client"
+import * as React from "react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+type Def = { key: string; name: string; variables: readonly string[] }; type Template = { key: string; subject: string; bodyText: string; enabled: boolean }
+function Editor({ def, saved }: { def: Def; saved?: Template }) { const [subject, setSubject] = React.useState(saved?.subject ?? ""); const [bodyText, setBody] = React.useState(saved?.bodyText ?? ""); const [enabled, setEnabled] = React.useState(saved?.enabled ?? false); async function save() { const response = await fetch("/api/admin/email-templates", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key: def.key, subject, bodyText, enabled }) }); if (response.ok) toast.success("Template saved"); else toast.error((await response.json().catch(() => ({}))).error || "Save failed") } return {def.name} Variables: {def.variables.map((v) => `{{${v}}}`).join(", ")}
Subject setSubject(e.target.value)} />
Plain-text body
setEnabled(e.target.checked)} /> Enable overrideSave template }
+export function EmailTemplateManager({ catalogue, templates }: { catalogue: Def[]; templates: Template[] }) { return {catalogue.map((def) => item.key === def.key)} />)}
}
diff --git a/components/admin/enquiries-inbox.tsx b/components/admin/enquiries-inbox.tsx
new file mode 100644
index 0000000..df7f298
--- /dev/null
+++ b/components/admin/enquiries-inbox.tsx
@@ -0,0 +1,39 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Download, Loader2, Save } from "lucide-react"
+import { toast } from "sonner"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+
+type Status = "NEW" | "IN_PROGRESS" | "RESOLVED" | "SPAM"
+type Submission = { id: string; source: string; name: string | null; email: string; subject: string | null; message: string; status: Status; notes: string | null; createdAt: string | Date }
+const LABELS: Record = { NEW: "New", IN_PROGRESS: "In progress", RESOLVED: "Resolved", SPAM: "Spam" }
+
+function EnquiryCard({ item }: { item: Submission }) {
+ const router = useRouter()
+ const [status, setStatus] = React.useState(item.status)
+ const [notes, setNotes] = React.useState(item.notes ?? "")
+ const [saving, setSaving] = React.useState(false)
+ async function save() {
+ setSaving(true)
+ try {
+ const response = await fetch(`/api/admin/enquiries/${item.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ status, notes }) })
+ const data = await response.json().catch(() => ({}))
+ if (!response.ok) throw new Error(data.error || "Failed to update enquiry")
+ toast.success("Enquiry updated")
+ router.refresh()
+ } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to update enquiry") } finally { setSaving(false) }
+ }
+ return {item.subject || "No subject"} {item.name || "Unknown"} · {item.email} · {new Date(item.createdAt).toLocaleString("en-NG")}
{LABELS[item.status]} {item.message}
Status setStatus(value as Status)}>{Object.entries(LABELS).map(([value, label]) => {label} )}
Internal notes
{saving ? : }Save
+}
+
+export function EnquiriesInbox({ submissions, counts, query, status }: { submissions: Submission[]; counts: Partial>; query: string; status: string }) {
+ return Enquiries Contact-form messages, triage state, and private support notes.
Export CSV{submissions.length ? submissions.map((item) =>
) :
No enquiries match this view. }
+}
diff --git a/components/admin/homepage-editor.tsx b/components/admin/homepage-editor.tsx
new file mode 100644
index 0000000..e36782d
--- /dev/null
+++ b/components/admin/homepage-editor.tsx
@@ -0,0 +1,197 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Save, Loader2, ArrowUp, ArrowDown, Eye, EyeOff, ExternalLink } from "lucide-react"
+import { toast } from "sonner"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+
+interface Field {
+ key: string
+ label: string
+ type: "text" | "textarea" | "url"
+ placeholder: string
+}
+interface SectionDef {
+ type: string
+ label: string
+ description: string
+ fields: Field[]
+}
+interface SectionView {
+ type: string
+ label: string
+ visible: boolean
+ position: number
+ config: Record
+}
+
+export function HomepageEditor({
+ initialSections,
+ catalogue,
+}: {
+ initialSections: SectionView[]
+ catalogue: SectionDef[]
+}) {
+ const router = useRouter()
+ const defByType = React.useMemo(
+ () => Object.fromEntries(catalogue.map((c) => [c.type, c])),
+ [catalogue]
+ )
+ const [sections, setSections] = React.useState(initialSections)
+ const [dirty, setDirty] = React.useState(false)
+ const [saving, setSaving] = React.useState(false)
+
+ React.useEffect(() => {
+ const handler = (e: BeforeUnloadEvent) => {
+ if (dirty) {
+ e.preventDefault()
+ e.returnValue = ""
+ }
+ }
+ window.addEventListener("beforeunload", handler)
+ return () => window.removeEventListener("beforeunload", handler)
+ }, [dirty])
+
+ const mutate = (next: SectionView[]) => {
+ setSections(next.map((s, i) => ({ ...s, position: i })))
+ setDirty(true)
+ }
+ const move = (i: number, dir: -1 | 1) => {
+ const next = [...sections]
+ const j = i + dir
+ if (j < 0 || j >= next.length) return
+ ;[next[i], next[j]] = [next[j], next[i]]
+ mutate(next)
+ }
+ const toggleVisible = (i: number) =>
+ mutate(sections.map((s, idx) => (idx === i ? { ...s, visible: !s.visible } : s)))
+ const setField = (i: number, key: string, value: string) =>
+ mutate(
+ sections.map((s, idx) =>
+ idx === i ? { ...s, config: { ...s.config, [key]: value } } : s
+ )
+ )
+
+ async function save() {
+ setSaving(true)
+ try {
+ const res = await fetch("/api/admin/homepage", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ sections }),
+ })
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) {
+ setSections(data.sections ?? sections)
+ setDirty(false)
+ toast.success("Homepage saved")
+ router.refresh()
+ } else {
+ toast.error(data.error || "Failed to save homepage")
+ }
+ } catch {
+ toast.error("Network error while saving")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+ {sections.map((section, i) => {
+ const def = defByType[section.type]
+ return (
+
+
+
+
+ {i + 1}.
+ {def?.label ?? section.type}
+ {!section.visible && (
+
+ Hidden
+
+ )}
+
+ {def?.description && (
+
{def.description}
+ )}
+
+
+
move(i, -1)} disabled={i === 0} aria-label="Move up">
+
+
+
move(i, 1)} disabled={i === sections.length - 1} aria-label="Move down">
+
+
+
toggleVisible(i)}
+ aria-label={section.visible ? "Hide section" : "Show section"}
+ title={section.visible ? "Visible" : "Hidden"}
+ >
+ {section.visible ? : }
+
+
+
+ {def && def.fields.length > 0 && (
+
+ {def.fields.map((field) => (
+
+
+ {field.label}
+
+ {field.type === "textarea" ? (
+
+ ))}
+
+ )}
+
+ )
+ })}
+
+
+
+ {saving ? : }
+ Save changes
+
+
+
+ )
+}
diff --git a/components/admin/media-library.tsx b/components/admin/media-library.tsx
new file mode 100644
index 0000000..1e06e70
--- /dev/null
+++ b/components/admin/media-library.tsx
@@ -0,0 +1,392 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Upload, Link2, Loader2, Trash2, Copy, Search, X } from "lucide-react"
+import { toast } from "sonner"
+
+import { Card, CardContent } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+
+interface Asset {
+ id: string
+ url: string
+ kind: string
+ filename: string | null
+ alt: string | null
+ caption: string | null
+ sizeBytes: number | null
+ source: string
+}
+
+function humanSize(bytes: number | null): string {
+ if (!bytes) return ""
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
+}
+
+export function MediaLibrary({
+ initialAssets,
+ loadError,
+}: {
+ initialAssets: Asset[]
+ loadError: boolean
+}) {
+ const router = useRouter()
+ const fileInput = React.useRef(null)
+ const [assets, setAssets] = React.useState(initialAssets)
+ const [search, setSearch] = React.useState("")
+ const [kind, setKind] = React.useState<"all" | "image" | "video">("all")
+ const [uploading, setUploading] = React.useState(false)
+ const [urlOpen, setUrlOpen] = React.useState(false)
+ const [urlValue, setUrlValue] = React.useState("")
+ const [urlKind, setUrlKind] = React.useState<"image" | "video">("image")
+ const [selected, setSelected] = React.useState(null)
+ const [deleting, setDeleting] = React.useState<{ asset: Asset; usage: number; forced: boolean } | null>(null)
+
+ const refresh = React.useCallback(async () => {
+ const params = new URLSearchParams()
+ if (search) params.set("search", search)
+ if (kind !== "all") params.set("kind", kind)
+ const res = await fetch(`/api/admin/media?${params}`)
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) setAssets(data.assets ?? [])
+ }, [search, kind])
+
+ React.useEffect(() => {
+ const t = setTimeout(refresh, 250)
+ return () => clearTimeout(t)
+ }, [refresh])
+
+ async function onUpload(files: FileList | null) {
+ if (!files || files.length === 0) return
+ setUploading(true)
+ try {
+ for (const file of Array.from(files)) {
+ const body = new FormData()
+ body.append("file", file)
+ const res = await fetch("/api/admin/media/upload", { method: "POST", body })
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) {
+ toast.success(`Uploaded ${file.name}`)
+ } else {
+ toast.error(data.error || `Failed to upload ${file.name}`)
+ }
+ }
+ await refresh()
+ router.refresh()
+ } finally {
+ setUploading(false)
+ if (fileInput.current) fileInput.current.value = ""
+ }
+ }
+
+ async function registerUrl() {
+ const res = await fetch("/api/admin/media", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ url: urlValue, kind: urlKind }),
+ })
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) {
+ toast.success("Media added")
+ setUrlOpen(false)
+ setUrlValue("")
+ await refresh()
+ router.refresh()
+ } else {
+ toast.error(data.error || "Failed to add media")
+ }
+ }
+
+ async function saveMeta() {
+ if (!selected) return
+ const res = await fetch(`/api/admin/media/${selected.id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ alt: selected.alt ?? "", caption: selected.caption ?? "" }),
+ })
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) {
+ toast.success("Saved")
+ setAssets((prev) => prev.map((a) => (a.id === selected.id ? { ...a, ...data.asset } : a)))
+ setSelected(null)
+ } else {
+ toast.error(data.error || "Failed to save")
+ }
+ }
+
+ async function requestDelete(asset: Asset) {
+ const res = await fetch(`/api/admin/media/${asset.id}`, { method: "DELETE" })
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) {
+ toast.success("Deleted")
+ setAssets((prev) => prev.filter((a) => a.id !== asset.id))
+ return
+ }
+ if (res.status === 409) {
+ // Referenced: ask for confirmation to force.
+ const usageRes = await fetch(`/api/admin/media/${asset.id}`)
+ const usageData = await usageRes.json().catch(() => ({ usage: [] }))
+ setDeleting({ asset, usage: (usageData.usage ?? []).length, forced: true })
+ return
+ }
+ toast.error(data.error || "Failed to delete")
+ }
+
+ async function confirmForceDelete() {
+ if (!deleting) return
+ const res = await fetch(`/api/admin/media/${deleting.asset.id}?force=1`, { method: "DELETE" })
+ if (res.ok) {
+ toast.success("Deleted")
+ setAssets((prev) => prev.filter((a) => a.id !== deleting.asset.id))
+ } else {
+ toast.error("Failed to delete")
+ }
+ setDeleting(null)
+ }
+
+ return (
+
+ {loadError && (
+
+
+ The media table could not be reached. If the media_library migration has
+ not been applied to this environment yet, apply it to enable the library.
+
+
+ )}
+
+ {/* Toolbar */}
+
+
onUpload(e.target.files)}
+ />
+
fileInput.current?.click()} disabled={uploading}>
+ {uploading ? : }
+ Upload
+
+
setUrlOpen(true)}>
+
+ Add by URL
+
+
+
+ setSearch(e.target.value)}
+ className="w-56 pl-9"
+ />
+
+
+ {(["all", "image", "video"] as const).map((k) => (
+ setKind(k)}
+ className={`px-3 py-1.5 text-xs capitalize ${kind === k ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted"}`}
+ >
+ {k}
+
+ ))}
+
+
+
+ {/* Grid */}
+ {assets.length === 0 ? (
+
+
+ No media yet. Upload files or add an existing URL to get started.
+
+
+ ) : (
+
+ {assets.map((asset) => (
+
+ setSelected(asset)}
+ title="Edit details"
+ >
+ {asset.kind === "video" ? (
+
+ ) : (
+
+ )}
+ {!asset.alt && asset.kind === "image" && (
+
+ No alt
+
+ )}
+
+
+
+ {asset.filename ?? asset.url}
+
+
+ {
+ navigator.clipboard?.writeText(asset.url)
+ toast.success("URL copied")
+ }}
+ >
+
+
+ requestDelete(asset)}
+ >
+
+
+
+
+
+ ))}
+
+ )}
+
+ {/* Add-by-URL dialog */}
+
+
+
+ Add media by URL
+
+
+
+ URL
+ setUrlValue(e.target.value)}
+ />
+
+
+ {(["image", "video"] as const).map((k) => (
+ setUrlKind(k)}
+ className="capitalize"
+ >
+ {k}
+
+ ))}
+
+
+
+ setUrlOpen(false)}>
+ Cancel
+
+
+ Add
+
+
+
+
+
+ {/* Details / edit dialog */}
+
!open && setSelected(null)}>
+
+
+ Media details
+
+ {selected && (
+
+
+ {selected.kind === "video" ? (
+
+ ) : (
+
+ )}
+
+
+ {selected.url} · {selected.source} · {humanSize(selected.sizeBytes)}
+
+
+ Alt text
+ setSelected({ ...selected, alt: e.target.value })}
+ placeholder="Describe the image for accessibility"
+ />
+
+
+ Caption
+ setSelected({ ...selected, caption: e.target.value })}
+ />
+
+
+ )}
+
+ setSelected(null)}>
+
+ Close
+
+ Save
+
+
+
+
+ {/* Force-delete confirmation */}
+
!open && setDeleting(null)}>
+
+
+ This asset is in use
+
+ It is referenced by {deleting?.usage} item(s). Deleting it may leave broken images.
+ Remove those references first, or force-delete anyway.
+
+
+
+ Cancel
+
+ Force delete
+
+
+
+
+
+ )
+}
diff --git a/components/admin/media-picker.tsx b/components/admin/media-picker.tsx
new file mode 100644
index 0000000..735612a
--- /dev/null
+++ b/components/admin/media-picker.tsx
@@ -0,0 +1,42 @@
+"use client"
+
+import * as React from "react"
+import { ImageIcon, Loader2, Search } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+
+type Asset = { id: string; url: string; alt: string | null; filename: string | null }
+
+export function MediaPicker({ value, onChange, placeholder, className }: { value: string; onChange: (url: string) => void; placeholder?: string; className?: string }) {
+ const [open, setOpen] = React.useState(false)
+ const [search, setSearch] = React.useState("")
+ const [assets, setAssets] = React.useState([])
+ const [loading, setLoading] = React.useState(false)
+
+ React.useEffect(() => {
+ if (!open) return
+ const controller = new AbortController()
+ const timer = window.setTimeout(async () => {
+ setLoading(true)
+ try {
+ const response = await fetch(`/api/admin/media?kind=image&search=${encodeURIComponent(search)}`, { signal: controller.signal })
+ const data = await response.json()
+ setAssets(response.ok ? data.assets ?? [] : [])
+ } catch { if (!controller.signal.aborted) setAssets([]) } finally { if (!controller.signal.aborted) setLoading(false) }
+ }, 150)
+ return () => { window.clearTimeout(timer); controller.abort() }
+ }, [open, search])
+
+ return
+
onChange(event.target.value)} placeholder={placeholder ?? "/images/... or https://..."} />
+
+
+
+ Choose media
+ setSearch(event.target.value)} placeholder="Search filename, alt text, or URL" />
+ {loading ?
: assets.length === 0 ? No matching images. Register or upload one in Media first.
: {assets.map((asset) =>
{ onChange(asset.url); setOpen(false) }}>{asset.alt || asset.filename || asset.url} )}
}
+
+
+
+}
diff --git a/components/admin/navigation-editor.tsx b/components/admin/navigation-editor.tsx
new file mode 100644
index 0000000..606050e
--- /dev/null
+++ b/components/admin/navigation-editor.tsx
@@ -0,0 +1,189 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Save, Loader2, Plus, Trash2, ArrowUp, ArrowDown, Eye, EyeOff } from "lucide-react"
+import { toast } from "sonner"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+
+interface NavItem {
+ id?: string
+ label: string
+ href: string
+ newTab: boolean
+ visible: boolean
+}
+
+type Menus = Record
+
+export function NavigationEditor({
+ locations,
+ initialMenus,
+}: {
+ locations: { location: string; label: string }[]
+ initialMenus: Menus
+}) {
+ const router = useRouter()
+ const [menus, setMenus] = React.useState(() => {
+ const seeded: Menus = {}
+ for (const { location } of locations) seeded[location] = initialMenus[location] ?? []
+ return seeded
+ })
+ const [savingLoc, setSavingLoc] = React.useState(null)
+ const [dirty, setDirty] = React.useState>({})
+
+ const update = (loc: string, next: NavItem[]) => {
+ setMenus((prev) => ({ ...prev, [loc]: next }))
+ setDirty((prev) => ({ ...prev, [loc]: true }))
+ }
+
+ const addItem = (loc: string) =>
+ update(loc, [...menus[loc], { label: "", href: "/", newTab: false, visible: true }])
+ const removeItem = (loc: string, i: number) =>
+ update(loc, menus[loc].filter((_, idx) => idx !== i))
+ const editItem = (loc: string, i: number, patch: Partial) =>
+ update(loc, menus[loc].map((it, idx) => (idx === i ? { ...it, ...patch } : it)))
+ const move = (loc: string, i: number, dir: -1 | 1) => {
+ const items = [...menus[loc]]
+ const j = i + dir
+ if (j < 0 || j >= items.length) return
+ ;[items[i], items[j]] = [items[j], items[i]]
+ update(loc, items)
+ }
+
+ async function saveMenu(loc: string) {
+ setSavingLoc(loc)
+ try {
+ const res = await fetch("/api/admin/navigation", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ location: loc, items: menus[loc] }),
+ })
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) {
+ if (data.menus) {
+ setMenus((prev) => ({ ...prev, [loc]: data.menus[loc] ?? [] }))
+ }
+ setDirty((prev) => ({ ...prev, [loc]: false }))
+ toast.success("Menu saved")
+ router.refresh()
+ } else {
+ toast.error(data.error || "Failed to save menu")
+ }
+ } catch {
+ toast.error("Network error while saving")
+ } finally {
+ setSavingLoc(null)
+ }
+ }
+
+ return (
+
+ {locations.map(({ location, label }) => {
+ const items = menus[location] ?? []
+ return (
+
+
+ {label}
+ saveMenu(location)}
+ disabled={savingLoc === location || !dirty[location]}
+ >
+ {savingLoc === location ? (
+
+ ) : (
+
+ )}
+ Save
+
+
+
+ {items.length === 0 && (
+
+ No custom items yet: using the built-in default for this menu. Add an item to
+ override it.
+
+ )}
+ {items.map((item, i) => (
+
+
editItem(location, i, { label: e.target.value })}
+ />
+
editItem(location, i, { href: e.target.value })}
+ />
+
editItem(location, i, { newTab: !item.newTab })}
+ title="Open in new tab"
+ >
+ New tab
+
+
editItem(location, i, { visible: !item.visible })}
+ title={item.visible ? "Visible" : "Hidden"}
+ aria-label={item.visible ? "Hide item" : "Show item"}
+ >
+ {item.visible ? : }
+
+
move(location, i, -1)}
+ disabled={i === 0}
+ aria-label="Move up"
+ >
+
+
+
move(location, i, 1)}
+ disabled={i === items.length - 1}
+ aria-label="Move down"
+ >
+
+
+
removeItem(location, i)}
+ aria-label="Remove item"
+ >
+
+
+
+ ))}
+ addItem(location)}>
+
+ Add item
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/components/admin/page-editor.tsx b/components/admin/page-editor.tsx
new file mode 100644
index 0000000..814fb29
--- /dev/null
+++ b/components/admin/page-editor.tsx
@@ -0,0 +1,91 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { ArrowDown, ArrowUp, Loader2, Plus, Save, Trash2 } from "lucide-react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Textarea } from "@/components/ui/textarea"
+import { MediaPicker } from "@/components/admin/media-picker"
+import { RevisionHistory } from "@/components/admin/revision-history"
+
+type BlockType = "heading" | "paragraph" | "quote" | "image" | "divider" | "button"
+type EditorBlock = { type: BlockType; data: Record }
+
+export interface PageEditorInitial {
+ id?: string
+ updatedAt?: string
+ slug: string
+ title: string
+ eyebrow: string
+ excerpt: string
+ seoTitle: string
+ seoDescription: string
+ status: "DRAFT" | "PUBLISHED" | "ARCHIVED"
+ scheduledFor: string
+ unpublishAt: string
+ blocks: EditorBlock[]
+}
+
+const BLOCK_LABELS: Record = {
+ heading: "Heading", paragraph: "Paragraph", quote: "Quote", image: "Image", divider: "Divider", button: "Button",
+}
+
+function newBlock(type: BlockType): EditorBlock {
+ if (type === "heading") return { type, data: { text: "", level: 2 } }
+ if (type === "image") return { type, data: { url: "", alt: "", caption: "" } }
+ if (type === "button") return { type, data: { label: "", href: "" } }
+ if (type === "quote") return { type, data: { text: "", attribution: "" } }
+ if (type === "divider") return { type, data: {} }
+ return { type, data: { text: "" } }
+}
+
+function BlockFields({ block, onChange }: { block: EditorBlock; onChange: (data: EditorBlock["data"]) => void }) {
+ const set = (key: string, value: string | number) => onChange({ ...block.data, [key]: value })
+ if (block.type === "divider") return A visual divider.
+ if (block.type === "image") return set("url", value)} /> set("alt", e.target.value)} placeholder="Alt text" /> set("caption", e.target.value)} placeholder="Caption (optional)" />
+ if (block.type === "button") return set("label", e.target.value)} placeholder="Button label" /> set("href", e.target.value)} placeholder="/safe-link" />
+ return
+}
+
+export function PageEditor({ initial, mode }: { initial: PageEditorInitial; mode: "create" | "edit" }) {
+ const router = useRouter()
+ const [form, setForm] = React.useState(initial)
+ const [saving, setSaving] = React.useState(false)
+ const [newType, setNewType] = React.useState("paragraph")
+ const set = (key: K, value: PageEditorInitial[K]) => setForm((old) => ({ ...old, [key]: value }))
+ const changeBlock = (index: number, data: EditorBlock["data"]) => set("blocks", form.blocks.map((block, i) => i === index ? { ...block, data } : block))
+ const move = (index: number, offset: number) => {
+ const next = [...form.blocks]
+ const target = index + offset
+ if (target < 0 || target >= next.length) return
+ ;[next[index], next[target]] = [next[target], next[index]]
+ set("blocks", next)
+ }
+ async function save(status: PageEditorInitial["status"]) {
+ if (!form.title.trim() || !form.slug.trim()) return toast.error("Title and route are required")
+ setSaving(true)
+ try {
+ const response = await fetch(mode === "create" ? "/api/admin/pages" : `/api/admin/pages/${form.id}`, {
+ method: mode === "create" ? "POST" : "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...form, status, expectedUpdatedAt: form.updatedAt, scheduledFor: form.scheduledFor || null, unpublishAt: form.unpublishAt || null, blocks: form.blocks.map((block, position) => ({ ...block, position })) }),
+ })
+ const data = await response.json().catch(() => ({}))
+ if (!response.ok) throw new Error(data.error || "Failed to save page")
+ toast.success(mode === "create" ? "Page created" : "Page saved")
+ if (mode === "create") router.push(`/admin/pages/${data.page.id}/edit`)
+ else { setForm((old) => ({ ...old, status: data.page.status, updatedAt: data.page.updatedAt })); router.refresh() }
+ } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to save page") } finally { setSaving(false) }
+ }
+ return
+
{mode === "create" ? "New page" : "Edit page"} Public route: /{form.slug || "..."}
save("DRAFT")} disabled={saving}>{saving ? : }Save draft save("PUBLISHED")} disabled={saving}>{form.scheduledFor && new Date(form.scheduledFor) > new Date() ? "Schedule" : "Publish"}
+
Page details Title set("title", e.target.value)} />
Route set("slug", e.target.value)} placeholder="help/shipping" />
Eyebrow set("eyebrow", e.target.value)} />
Introduction
+
Content blocks {form.blocks.map((block, index) => {index + 1}. {BLOCK_LABELS[block.type]} move(index, -1)} disabled={index === 0}> move(index, 1)} disabled={index === form.blocks.length - 1}> set("blocks", form.blocks.filter((_, i) => i !== index))}> changeBlock(index, data)} /> )} setNewType(value as BlockType)}>{Object.entries(BLOCK_LABELS).map(([value, label]) => {label} )} set("blocks", [...form.blocks, newBlock(newType)])}> Add block
+
SEO and publishing SEO title set("seoTitle", e.target.value)} />
SEO description
Publish at (optional) set("scheduledFor", e.target.value)} />
Unpublish at (optional) set("unpublishAt", e.target.value)} />
+
+}
diff --git a/components/admin/page-row-actions.tsx b/components/admin/page-row-actions.tsx
new file mode 100644
index 0000000..2afce6b
--- /dev/null
+++ b/components/admin/page-row-actions.tsx
@@ -0,0 +1,31 @@
+"use client"
+
+import { useState } from "react"
+import { useRouter } from "next/navigation"
+import { ArchiveRestore, Trash2 } from "lucide-react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+
+export function PageRowActions({ id, deleted }: { id: string; deleted: boolean }) {
+ const router = useRouter()
+ const [busy, setBusy] = useState(false)
+ async function act() {
+ if (!deleted && !window.confirm("Move this page to trash? Its built-in fallback will become visible.")) return
+ setBusy(true)
+ try {
+ const response = await fetch(`/api/admin/pages/${id}${deleted ? "/restore" : ""}`, { method: deleted ? "POST" : "DELETE" })
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || "Request failed")
+ toast.success(deleted ? "Page restored as draft" : "Page moved to trash")
+ router.refresh()
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Request failed")
+ } finally {
+ setBusy(false)
+ }
+ }
+ return (
+
+ {deleted ? : }
+
+ )
+}
diff --git a/components/admin/product-form.tsx b/components/admin/product-form.tsx
index c553f61..fbf9582 100644
--- a/components/admin/product-form.tsx
+++ b/components/admin/product-form.tsx
@@ -42,6 +42,12 @@ export interface ProductFormInitialValues {
images?: string[]
collectionId?: string | null
fragranceFamily?: string | null
+ sku?: string | null; barcode?: string | null; launchYear?: number | null; perfumer?: string | null; countryOfOrigin?: string | null
+ concentration?: string | null; longevity?: string | null; sillage?: string | null; intensity?: string | null; sprayGuidance?: string | null
+ climate?: string | null; season?: string | null; timeOfDay?: string | null; occasion?: string | null
+ weightGrams?: number | null; shippingClass?: string | null; reorderPoint?: number | null; dropDate?: string | null
+ seoTitle?: string | null; seoDescription?: string | null; publishStatus?: "DRAFT" | "PUBLISHED" | "ARCHIVED"
+ beginnerFriendly?: boolean; returnEligible?: boolean; isPreorder?: boolean; isWaitlist?: boolean
}
interface ProductFormProps {
@@ -377,6 +383,19 @@ export function ProductForm({ initialValues, categories, collections, brands = [
+
+
+ Catalogue, performance, commerce and SEO
+
+ {[['sku','SKU'],['barcode','Barcode'],['launchYear','Launch year'],['perfumer','Perfumer'],['countryOfOrigin','Country of origin'],['concentration','Concentration'],['longevity','Longevity'],['sillage','Sillage'],['intensity','Intensity'],['climate','Climate'],['season','Season'],['timeOfDay','Time of day'],['occasion','Occasion'],['weightGrams','Weight (grams)'],['shippingClass','Shipping class'],['reorderPoint','Reorder point'],['dropDate','Drop date']].map(([key,label]) =>
{label}
)}
+
+ Spray guidance
+
+ {[['beginnerFriendly','Beginner friendly'],['returnEligible','Return eligible'],['isPreorder','Preorder'],['isWaitlist','Waitlist']].map(([key,label]) => {label} )}
+ Publish status Draft Published Archived
+
+
+
{submitLabel}
diff --git a/components/admin/redirect-manager.tsx b/components/admin/redirect-manager.tsx
new file mode 100644
index 0000000..3d97a4f
--- /dev/null
+++ b/components/admin/redirect-manager.tsx
@@ -0,0 +1,17 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Trash2 } from "lucide-react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import { Card, CardContent } from "@/components/ui/card"
+import { Input } from "@/components/ui/input"
+
+type Item = { id: string; source: string; destination: string; permanent: boolean; active: boolean }
+export function RedirectManager({ redirects }: { redirects: Item[] }) {
+ const router = useRouter(); const [source, setSource] = React.useState(""); const [destination, setDestination] = React.useState("")
+ async function save() { const response = await fetch("/api/admin/redirects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source, destination, permanent: true, active: true }) }); const data = await response.json().catch(() => ({})); if (!response.ok) return toast.error(data.error || "Save failed"); setSource(""); setDestination(""); toast.success("Redirect saved"); router.refresh() }
+ async function remove(id: string) { if (!window.confirm("Delete this redirect?")) return; await fetch(`/api/admin/redirects/${id}`, { method: "DELETE" }); router.refresh() }
+ return
+}
diff --git a/components/admin/review-moderation-actions.tsx b/components/admin/review-moderation-actions.tsx
new file mode 100644
index 0000000..5a5ca56
--- /dev/null
+++ b/components/admin/review-moderation-actions.tsx
@@ -0,0 +1,79 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Check, X, Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+
+type Decision = "approve" | "reject"
+
+interface Props {
+ reviewId: string
+ status: "PENDING" | "APPROVED" | "REJECTED"
+}
+
+export function ReviewModerationActions({ reviewId, status }: Props) {
+ const router = useRouter()
+ const [pending, setPending] = React.useState(null)
+
+ const moderate = async (decision: Decision) => {
+ setPending(decision)
+ try {
+ const res = await fetch(`/api/v1/admin/reviews/${reviewId}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ decision }),
+ })
+ const body = await res.json().catch(() => ({}))
+ if (res.ok) {
+ toast.success(decision === "approve" ? "Review approved" : "Review rejected")
+ router.refresh()
+ } else {
+ toast.error(body?.error?.message || "Could not moderate this review")
+ }
+ } catch {
+ toast.error("Network error while moderating")
+ } finally {
+ setPending(null)
+ }
+ }
+
+ return (
+
+ {status !== "APPROVED" && (
+ moderate("approve")}
+ >
+ {pending === "approve" ? (
+
+ ) : (
+
+ )}
+ Approve
+
+ )}
+ {status !== "REJECTED" && (
+ moderate("reject")}
+ >
+ {pending === "reject" ? (
+
+ ) : (
+
+ )}
+ Reject
+
+ )}
+
+ )
+}
diff --git a/components/admin/revision-history.tsx b/components/admin/revision-history.tsx
new file mode 100644
index 0000000..a910eda
--- /dev/null
+++ b/components/admin/revision-history.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import * as React from "react"
+import { History, Loader2 } from "lucide-react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
+
+type Revision = { id: string; version: number; createdAt: string; createdBy: string | null }
+
+export function RevisionHistory({ entityType, entityId }: { entityType: "Story" | "Page"; entityId?: string }) {
+ const [open, setOpen] = React.useState(false)
+ const [loading, setLoading] = React.useState(false)
+ const [revisions, setRevisions] = React.useState([])
+ React.useEffect(() => { if (!open || !entityId) return; setLoading(true); fetch(`/api/admin/revisions?entityType=${entityType}&entityId=${entityId}`).then((response) => response.json()).then((data) => setRevisions(data.revisions ?? [])).finally(() => setLoading(false)) }, [open, entityId, entityType])
+ if (!entityId) return null
+ async function restore(id: string) {
+ if (!window.confirm("Restore this revision? The current version will be saved first.")) return
+ const response = await fetch(`/api/admin/revisions/${id}/restore`, { method: "POST" })
+ if (response.ok) { toast.success("Revision restored"); window.location.reload() } else toast.error((await response.json().catch(() => ({}))).error || "Restore failed")
+ }
+ return HistoryVersion history {loading ? : revisions.length === 0 ? No earlier versions yet.
: {revisions.map((revision) =>
Version {revision.version}
{new Date(revision.createdAt).toLocaleString("en-NG")}
restore(revision.id)}>Restore )}
}
+}
diff --git a/components/admin/settings-form.tsx b/components/admin/settings-form.tsx
new file mode 100644
index 0000000..8318012
--- /dev/null
+++ b/components/admin/settings-form.tsx
@@ -0,0 +1,148 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Save, Loader2 } from "lucide-react"
+import { toast } from "sonner"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import type { SettingDef } from "@/lib/settings/schema"
+import { MediaPicker } from "@/components/admin/media-picker"
+
+type Values = Record
+
+export function SettingsForm({
+ fields,
+ initialValues,
+}: {
+ fields: SettingDef[]
+ initialValues: Values
+}) {
+ const router = useRouter()
+ const [values, setValues] = React.useState(initialValues)
+ const [dirty, setDirty] = React.useState(false)
+ const [saving, setSaving] = React.useState(false)
+
+ const set = (key: string, value: string | boolean) => {
+ setValues((prev) => ({ ...prev, [key]: value }))
+ setDirty(true)
+ }
+
+ React.useEffect(() => {
+ const handler = (e: BeforeUnloadEvent) => {
+ if (dirty) {
+ e.preventDefault()
+ e.returnValue = ""
+ }
+ }
+ window.addEventListener("beforeunload", handler)
+ return () => window.removeEventListener("beforeunload", handler)
+ }, [dirty])
+
+ const groups = React.useMemo(() => {
+ const map = new Map()
+ for (const f of fields) {
+ const list = map.get(f.group) ?? []
+ list.push(f)
+ map.set(f.group, list)
+ }
+ return Array.from(map.entries())
+ }, [fields])
+
+ async function save() {
+ setSaving(true)
+ try {
+ const res = await fetch("/api/admin/settings", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ values }),
+ })
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) {
+ setValues(data.values ?? values)
+ setDirty(false)
+ toast.success("Settings saved")
+ router.refresh()
+ } else {
+ toast.error(data.error || "Failed to save settings")
+ }
+ } catch {
+ toast.error("Network error while saving")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+ {saving ? : }
+ Save changes
+
+
+
+ {groups.map(([group, defs]) => (
+
+
+ {group}
+
+
+ {defs.map((def) => (
+
+ {def.type === "boolean" ? (
+
+ {def.label}
+ set(def.key, v)}
+ />
+
+ ) : (
+ <>
+
+ {def.label}
+
+ {def.type === "image" ? (
+
set(def.key, value)}
+ />
+ ) : def.type === "textarea" ? (
+
+ ))}
+
+
+ ))}
+
+
+
+ {saving ? : }
+ Save changes
+
+
+
+ )
+}
diff --git a/components/admin/story-editor.tsx b/components/admin/story-editor.tsx
new file mode 100644
index 0000000..6c197c7
--- /dev/null
+++ b/components/admin/story-editor.tsx
@@ -0,0 +1,569 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import {
+ Save,
+ Loader2,
+ Trash2,
+ ArrowUp,
+ ArrowDown,
+ ExternalLink,
+ Type,
+ Quote,
+ ImageIcon,
+ Minus,
+ Package,
+ MousePointerClick,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Switch } from "@/components/ui/switch"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import type { StoryBlockType } from "@/lib/stories/types"
+import { MediaPicker } from "@/components/admin/media-picker"
+import { RevisionHistory } from "@/components/admin/revision-history"
+
+export interface ProductOption {
+ id: string
+ slug: string
+ name: string
+}
+
+export interface EditorBlock {
+ type: StoryBlockType
+ data: Record
+}
+
+export interface StoryEditorInitial {
+ id?: string
+ updatedAt?: string
+ slug?: string
+ title: string
+ subtitle: string
+ excerpt: string
+ category: string
+ readTime: string
+ author: string
+ tags: string
+ coverImageUrl: string
+ mobileCoverUrl: string
+ socialImageUrl: string
+ seoTitle: string
+ seoDescription: string
+ featured: boolean
+ position: number
+ status: "DRAFT" | "PUBLISHED" | "ARCHIVED"
+ scheduledFor: string
+ unpublishAt: string
+ blocks: EditorBlock[]
+ relatedProductIds: string[]
+}
+
+const BLOCK_MENU: { type: StoryBlockType; label: string; icon: React.ElementType }[] = [
+ { type: "heading", label: "Heading", icon: Type },
+ { type: "paragraph", label: "Paragraph", icon: Type },
+ { type: "quote", label: "Quote", icon: Quote },
+ { type: "image", label: "Image", icon: ImageIcon },
+ { type: "product", label: "Product", icon: Package },
+ { type: "button", label: "Button", icon: MousePointerClick },
+ { type: "divider", label: "Divider", icon: Minus },
+]
+
+function emptyBlock(type: StoryBlockType): EditorBlock {
+ switch (type) {
+ case "heading":
+ return { type, data: { text: "", level: 2 } }
+ case "image":
+ return { type, data: { url: "", alt: "", caption: "" } }
+ case "product":
+ return { type, data: { productSlug: "" } }
+ case "button":
+ return { type, data: { label: "", href: "" } }
+ case "divider":
+ return { type, data: {} }
+ default:
+ return { type, data: { text: "" } }
+ }
+}
+
+export function StoryEditor({
+ initial,
+ products,
+ mode,
+}: {
+ initial: StoryEditorInitial
+ products: ProductOption[]
+ mode: "create" | "edit"
+}) {
+ const router = useRouter()
+ const [form, setForm] = React.useState(initial)
+ const [saving, setSaving] = React.useState(false)
+ const [dirty, setDirty] = React.useState(false)
+ const [productQuery, setProductQuery] = React.useState("")
+
+ const set = (key: K, value: StoryEditorInitial[K]) => {
+ setForm((prev) => ({ ...prev, [key]: value }))
+ setDirty(true)
+ }
+
+ // Warn on unsaved changes when navigating away.
+ React.useEffect(() => {
+ const handler = (e: BeforeUnloadEvent) => {
+ if (dirty) {
+ e.preventDefault()
+ e.returnValue = ""
+ }
+ }
+ window.addEventListener("beforeunload", handler)
+ return () => window.removeEventListener("beforeunload", handler)
+ }, [dirty])
+
+ const addBlock = (type: StoryBlockType) => {
+ setForm((prev) => ({ ...prev, blocks: [...prev.blocks, emptyBlock(type)] }))
+ setDirty(true)
+ }
+ const updateBlock = (i: number, data: Record) => {
+ setForm((prev) => {
+ const blocks = [...prev.blocks]
+ blocks[i] = { ...blocks[i], data: { ...blocks[i].data, ...data } }
+ return { ...prev, blocks }
+ })
+ setDirty(true)
+ }
+ const removeBlock = (i: number) => {
+ setForm((prev) => ({ ...prev, blocks: prev.blocks.filter((_, idx) => idx !== i) }))
+ setDirty(true)
+ }
+ const moveBlock = (i: number, dir: -1 | 1) => {
+ setForm((prev) => {
+ const blocks = [...prev.blocks]
+ const j = i + dir
+ if (j < 0 || j >= blocks.length) return prev
+ ;[blocks[i], blocks[j]] = [blocks[j], blocks[i]]
+ return { ...prev, blocks }
+ })
+ setDirty(true)
+ }
+
+ const toggleProduct = (id: string) => {
+ setForm((prev) => {
+ const has = prev.relatedProductIds.includes(id)
+ return {
+ ...prev,
+ relatedProductIds: has
+ ? prev.relatedProductIds.filter((x) => x !== id)
+ : [...prev.relatedProductIds, id],
+ }
+ })
+ setDirty(true)
+ }
+
+ async function save(nextStatus?: StoryEditorInitial["status"]) {
+ if (!form.title.trim()) {
+ toast.error("Title is required")
+ return
+ }
+ setSaving(true)
+ const status = nextStatus ?? form.status
+ const payload = {
+ ...form,
+ status,
+ position: Number(form.position) || 0,
+ scheduledFor: form.scheduledFor || null,
+ unpublishAt: form.unpublishAt || null,
+ expectedUpdatedAt: form.updatedAt,
+ }
+ try {
+ const res = await fetch(
+ mode === "create" ? "/api/admin/stories" : `/api/admin/stories/${form.id}`,
+ {
+ method: mode === "create" ? "POST" : "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ }
+ )
+ const data = await res.json().catch(() => ({}))
+ if (res.ok) {
+ setDirty(false)
+ toast.success(mode === "create" ? "Story created" : "Story saved")
+ if (mode === "create" && data.story?.id) {
+ router.push(`/admin/stories/${data.story.id}/edit`)
+ } else if (data.story) {
+ setForm((prev) => ({ ...prev, status: data.story.status, updatedAt: data.story.updatedAt }))
+ router.refresh()
+ }
+ } else if (res.status === 409) {
+ toast.error(data.error || "This story changed elsewhere. Reload before saving.")
+ } else {
+ toast.error(data.error || "Failed to save story")
+ }
+ } catch {
+ toast.error("Network error while saving")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const filteredProducts = productQuery
+ ? products.filter(
+ (p) =>
+ p.name.toLowerCase().includes(productQuery.toLowerCase()) ||
+ p.slug.toLowerCase().includes(productQuery.toLowerCase())
+ )
+ : products
+
+ return (
+
+
+
+
+ {mode === "create" ? "New story" : "Edit story"}
+
+
+ {mode === "edit" && form.slug ? (
+ <>
+ Public URL: /journal/{form.slug}
+ >
+ ) : (
+ "Draft an editorial story for the Journal."
+ )}
+
+
+
+
+ {mode === "edit" && form.status === "PUBLISHED" && form.slug && (
+
+
+
+ View live
+
+
+ )}
+
save("DRAFT")} disabled={saving}>
+ {saving ? : }
+ Save draft
+
+
save("PUBLISHED")} disabled={saving}>
+ {form.scheduledFor && new Date(form.scheduledFor).getTime() > Date.now()
+ ? "Schedule"
+ : "Publish"}
+
+
+
+
+
+ {/* Main column */}
+
+
+
+ Details
+
+
+
+ set("title", e.target.value)} placeholder="The 5 Best Oud Fragrances for Lagos Heat" />
+
+
+
+ set("slug", e.target.value)} placeholder="auto from title" />
+
+
+ set("category", e.target.value)} placeholder="Fragrance Guide" />
+
+
+
+ set("subtitle", e.target.value)} />
+
+
+
+
+
+ set("author", e.target.value)} />
+
+
+ set("readTime", e.target.value)} placeholder="5 min read" />
+
+
+
+
+
+ {/* Blocks */}
+
+
+ Content blocks
+
+
+ {form.blocks.length === 0 && (
+
+ No content yet. Add a block below to start writing.
+
+ )}
+ {form.blocks.map((block, i) => (
+
+
+
+ {block.type}
+
+
+
moveBlock(i, -1)} disabled={i === 0}>
+
+
+
moveBlock(i, 1)} disabled={i === form.blocks.length - 1}>
+
+
+
removeBlock(i)} destructive>
+
+
+
+
+
updateBlock(i, d)} />
+
+ ))}
+
+ {BLOCK_MENU.map((b) => (
+ addBlock(b.type)}>
+
+ {b.label}
+
+ ))}
+
+
+
+
+ {/* SEO */}
+
+
+ SEO & sharing
+
+
+
+ set("seoTitle", e.target.value)} placeholder="Defaults to the story title" />
+
+
+
+
+ set("socialImageUrl", value)} />
+
+
+
+
+
+ {/* Sidebar */}
+
+
+
+ )
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
+
+function IconBtn({
+ children,
+ label,
+ onClick,
+ disabled,
+ destructive,
+}: {
+ children: React.ReactNode
+ label: string
+ onClick: () => void
+ disabled?: boolean
+ destructive?: boolean
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+function BlockFields({
+ block,
+ products,
+ onChange,
+}: {
+ block: EditorBlock
+ products: ProductOption[]
+ onChange: (data: Record) => void
+}) {
+ switch (block.type) {
+ case "heading":
+ return (
+
+ onChange({ level: Number(v) })}>
+
+
+
+
+ H2
+ H3
+
+
+ onChange({ text: e.target.value })} placeholder="Heading text" />
+
+ )
+ case "paragraph":
+ return (
+