Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,8 @@ CONCIERGE_MAX_OUTPUT_TOKENS="1400"
CONCIERGE_DAILY_SPEND_USD="25"
CONCIERGE_MONTHLY_SPEND_USD="300"
CONCIERGE_CATALOGUE_ONLY="false"
# Authenticates the Vercel scheduled-publishing cron endpoint.
CRON_SECRET=
BLOB_READ_WRITE_TOKEN=
# Optional: force "blob" or "local". With no override, a Blob token selects Vercel Blob.
MEDIA_STORAGE=
15 changes: 15 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## Summary

Describe the customer or operator outcome and the main implementation choices.

## Verification

- [ ] Typecheck, lint, and relevant tests pass
- [ ] Database changes are additive and include migration/rollback notes
- [ ] Admin mutations are server-authorized and audited

## Future feature parity

- [ ] Every new public-facing feature includes its data model, admin interface, server-side validation and authorization, frontend rendering, preview/publication state where relevant, error handling, audit logging, tests, and a coverage-matrix update.

If an item is intentionally not applicable, explain why in the PR description.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,6 @@ prisma/dev.db-journal
.claude
.cursor
.gstack/

# Runtime-uploaded media (local storage adapter)
public/uploads/
15 changes: 15 additions & 0 deletions app/[...path]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { notFound, permanentRedirect, redirect } from "next/navigation"
import { prisma } from "@/lib/prisma"

export const dynamic = "force-dynamic"

export default async function RedirectResolver({ params }: { params: Promise<{ path: string[] }> }) {
const source = `/${(await params).path.join("/")}`
let item = null
try {
item = await prisma.redirect.findUnique({ where: { source } })
} catch {}
if (!item?.active) notFound()
if (item.permanent) permanentRedirect(item.destination)
redirect(item.destination)
}
12 changes: 10 additions & 2 deletions app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import Link from "next/link"
import { ArrowRight } 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: "About Fádé",
description:
"Fádé is a Lagos house for rare and coveted perfumes, curated with care, described honestly, delivered nationwide.",
}

export function generateMetadata() { return getManagedPageMetadata("about", fallbackMetadata) }

const PRINCIPLES = [
{
title: "Sourced deliberately",
Expand All @@ -29,7 +35,9 @@ const PRINCIPLES = [
},
]

export default function AboutPage() {
export default async function AboutPage() {
const managed = await getPublishedPage("about")
if (managed) return <ManagedPage page={managed} />
return (
<MainLayout>
{/* Manifesto hero */}
Expand Down
251 changes: 251 additions & 0 deletions app/admin/audit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import Link from "next/link"

import { prisma } from "@/lib/prisma"
import {
AUDIT_PAGE_SIZE,
buildAuditWhere,
pageCount,
pageSkip,
parsePage,
} from "@/lib/audit/filters"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"

export const dynamic = "force-dynamic"

interface AdminAuditPageProps {
searchParams?: Promise<{
actorId?: string
action?: string
targetType?: string
targetId?: string
page?: string
}>
}

interface AuditRow {
id: string
actorId: string | null
actorRole: string | null
action: string
targetType: string
targetId: string | null
metadata: unknown
createdAt: Date
}

function formatWhen(value: Date): string {
// ASCII-only, locale-stable. Example: 2026-07-20 14:03:22
const iso = value.toISOString()
return `${iso.slice(0, 10)} ${iso.slice(11, 19)}`
}

function metadataPreview(metadata: unknown): string {
if (metadata == null) return ""
try {
const s = JSON.stringify(metadata)
return s.length > 160 ? `${s.slice(0, 157)}...` : s
} catch {
return ""
}
}

export default async function AdminAuditPage({ searchParams }: AdminAuditPageProps) {
const params = (await searchParams) ?? {}
const filters = {
actorId: params.actorId ?? "",
action: params.action ?? "",
targetType: params.targetType ?? "",
targetId: params.targetId ?? "",
}
const page = parsePage(params.page)
const where = buildAuditWhere(filters)

let entries: AuditRow[] = []
let total = 0
let loadError = false
try {
const [rows, count] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
take: AUDIT_PAGE_SIZE,
skip: pageSkip(page),
}),
prisma.auditLog.count({ where }),
])
entries = rows as AuditRow[]
total = count
} catch {
loadError = true
}

const totalPages = pageCount(total)
const currentPage = Math.min(page, totalPages)

const pageHref = (target: number) => {
const sp = new URLSearchParams()
if (filters.actorId) sp.set("actorId", filters.actorId)
if (filters.action) sp.set("action", filters.action)
if (filters.targetType) sp.set("targetType", filters.targetType)
if (filters.targetId) sp.set("targetId", filters.targetId)
if (target > 1) sp.set("page", String(target))
const qs = sp.toString()
return qs ? `/admin/audit?${qs}` : "/admin/audit"
}

return (
<div className="space-y-6">
<div>
<h1 className="font-serif text-2xl font-semibold tracking-tight">Audit log</h1>
<p className="text-muted-foreground">
Append-only trail of every administrative change. Read-only: entries are written
automatically and cannot be edited or deleted from here.
</p>
</div>

<Card>
<CardContent className="pt-6">
<form method="get" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5 lg:items-end">
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">Action contains</span>
<Input name="action" defaultValue={filters.action} placeholder="e.g. publish" />
</label>
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">Target type</span>
<Input name="targetType" defaultValue={filters.targetType} placeholder="e.g. Story" />
</label>
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">Target id</span>
<Input name="targetId" defaultValue={filters.targetId} placeholder="exact id" />
</label>
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">Actor id</span>
<Input name="actorId" defaultValue={filters.actorId} placeholder="exact user id" />
</label>
<div className="flex gap-2">
<Button type="submit" className="flex-1">
Filter
</Button>
{(filters.action || filters.targetType || filters.targetId || filters.actorId) && (
<Button asChild variant="outline">
<Link href="/admin/audit">Clear</Link>
</Button>
)}
</div>
</form>
</CardContent>
</Card>

{loadError ? (
<Card>
<CardContent className="py-10 text-center text-muted-foreground">
The audit log could not be loaded. The audit table may not exist in this environment yet.
</CardContent>
</Card>
) : (
<Card>
<CardContent className="pt-6">
<div className="mb-3 flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} {total === 1 ? "entry" : "entries"}
</span>
<span>
Page {currentPage} of {totalPages}
</span>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="whitespace-nowrap">When (UTC)</TableHead>
<TableHead>Action</TableHead>
<TableHead>Target</TableHead>
<TableHead>Actor</TableHead>
<TableHead>Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="py-10 text-center text-muted-foreground">
No audit entries match these filters.
</TableCell>
</TableRow>
) : (
entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell className="whitespace-nowrap font-mono text-xs text-muted-foreground">
{formatWhen(entry.createdAt)}
</TableCell>
<TableCell>
<Badge variant="secondary">{entry.action}</Badge>
</TableCell>
<TableCell className="text-sm">
<span className="font-medium">{entry.targetType}</span>
{entry.targetId ? (
<span className="block font-mono text-xs text-muted-foreground">
{entry.targetId}
</span>
) : null}
</TableCell>
<TableCell className="text-sm">
{entry.actorRole ? (
<span className="block">{entry.actorRole}</span>
) : (
<span className="text-muted-foreground">system</span>
)}
{entry.actorId ? (
<span className="block font-mono text-xs text-muted-foreground">
{entry.actorId}
</span>
) : null}
</TableCell>
<TableCell className="max-w-xs">
<span className="block truncate font-mono text-xs text-muted-foreground">
{metadataPreview(entry.metadata)}
</span>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>

{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
{currentPage > 1 ? (
<Button asChild variant="outline">
<Link href={pageHref(currentPage - 1)}>Previous</Link>
</Button>
) : (
<Button variant="outline" disabled>
Previous
</Button>
)}
{currentPage < totalPages ? (
<Button asChild variant="outline">
<Link href={pageHref(currentPage + 1)}>Next</Link>
</Button>
) : (
<Button variant="outline" disabled>
Next
</Button>
)}
</div>
)}
</CardContent>
</Card>
)}
</div>
)
}
4 changes: 4 additions & 0 deletions app/admin/campaigns/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { prisma } from "@/lib/prisma"
import { CampaignManager } from "@/components/admin/campaign-manager"
export const dynamic = "force-dynamic"
export default async function CampaignsPage() { let campaigns: any[] = [], coupons: any[] = [], products: { id: string; name: string }[] = []; try { [campaigns, coupons, products] = await Promise.all([prisma.campaign.findMany({ orderBy: { createdAt: "desc" } }), prisma.coupon.findMany({ orderBy: { createdAt: "desc" } }), prisma.product.findMany({ where: { deletedAt: null }, select: { id: true, name: true }, orderBy: { name: "asc" } })]) } catch {} return <div className="space-y-6"><div><h1 className="font-serif text-2xl font-semibold">Campaigns and discounts</h1><p className="text-muted-foreground">Schedule storefront launches and manage promo codes.</p></div><CampaignManager campaigns={campaigns} coupons={coupons} products={products} /></div> }
8 changes: 8 additions & 0 deletions app/admin/collections/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export default async function AdminCollectionsPage({ searchParams }: AdminCollec
name: (formData.get("name") as string) ?? "",
slug: (formData.get("slug") as string) ?? "",
description: (formData.get("description") as string) || undefined,
seoTitle: (formData.get("seoTitle") as string) || undefined,
seoDescription: (formData.get("seoDescription") as string) || undefined,
})

redirect("/admin/collections?success=created")
Expand All @@ -51,6 +53,8 @@ export default async function AdminCollectionsPage({ searchParams }: AdminCollec
name: (formData.get("name") as string) ?? "",
slug: (formData.get("slug") as string) ?? "",
description: (formData.get("description") as string) || undefined,
seoTitle: (formData.get("seoTitle") as string) || undefined,
seoDescription: (formData.get("seoDescription") as string) || undefined,
})

redirect("/admin/collections?success=updated")
Expand Down Expand Up @@ -121,6 +125,8 @@ export default async function AdminCollectionsPage({ searchParams }: AdminCollec
placeholder="Short description shown in the UI."
/>
</div>
<div className="space-y-2"><Label htmlFor="seoTitle">SEO title</Label><Input id="seoTitle" name="seoTitle" maxLength={70} /></div>
<div className="space-y-2"><Label htmlFor="seoDescription">SEO description</Label><Input id="seoDescription" name="seoDescription" maxLength={180} /></div>
<Button type="submit" size="sm">
Create collection
</Button>
Expand Down Expand Up @@ -162,6 +168,8 @@ export default async function AdminCollectionsPage({ searchParams }: AdminCollec
<Label htmlFor="new-description">Description</Label>
<Input id="new-description" name="description" placeholder="Updated description" />
</div>
<div className="space-y-2"><Label htmlFor="new-seo-title">SEO title</Label><Input id="new-seo-title" name="seoTitle" maxLength={70} /></div>
<div className="space-y-2"><Label htmlFor="new-seo-description">SEO description</Label><Input id="new-seo-description" name="seoDescription" maxLength={180} /></div>
<Button type="submit" size="sm">
Save changes
</Button>
Expand Down
Loading
Loading