Compare commits

...

3 Commits

33 changed files with 2098 additions and 100 deletions

32
TODO.md
View File

@@ -124,11 +124,11 @@ This file is the single source of truth for roadmap and delivery progress.
S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI
- [~] [P1] `todo/mvp1-pages-navigation-builder`: - [~] [P1] `todo/mvp1-pages-navigation-builder`:
page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds) page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds)
- [ ] [P1] `todo/mvp1-commissions-customers`: - [~] [P1] `todo/mvp1-commissions-customers`:
commission request intake + admin CRUD + kanban + customer entity/linking commission request intake + admin CRUD + kanban + customer entity/linking
- [ ] [P1] `todo/mvp1-announcements-news`: - [~] [P1] `todo/mvp1-announcements-news`:
announcement management/rendering + news/blog CRUD and public rendering announcement management/rendering + news/blog CRUD and public rendering
- [ ] [P1] `todo/mvp1-public-rendering-integration`: - [~] [P1] `todo/mvp1-public-rendering-integration`:
public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints public rendering for pages/navigation/media/portfolio/announcements and commissioning entrypoints
- [ ] [P1] `todo/mvp1-e2e-happy-paths`: - [ ] [P1] `todo/mvp1-e2e-happy-paths`:
end-to-end scenarios for page publish, media flow, announcement display, commission flow end-to-end scenarios for page publish, media flow, announcement display, commission flow
@@ -156,18 +156,18 @@ This file is the single source of truth for roadmap and delivery progress.
- [ ] [P1] Users management (invite, roles, status) - [ ] [P1] Users management (invite, roles, status)
- [ ] [P1] Disable/ban user function and enforcement in auth/session checks - [ ] [P1] Disable/ban user function and enforcement in auth/session checks
- [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote) - [~] [P1] Owner/support protection rules in user management actions (cannot delete/demote)
- [ ] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks) - [~] [P1] Commissions management (request intake, owner, due date, notes, linked customer, linked artworks)
- [ ] [P1] Customer records (contact profile, notes, consent flags, recurrence marker) - [~] [P1] Customer records (contact profile, notes, consent flags, recurrence marker)
- [ ] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers) - [~] [P1] Customer-to-commission linkage and reuse workflow (no re-entry for recurring customers)
- [ ] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done) - [~] [P1] Kanban workflow for commissions (new, scoped, in-progress, review, done)
- [ ] [P1] Header banner management (message, CTA, active window) - [ ] [P1] Header banner management (message, CTA, active window)
- [ ] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting) - [~] [P1] Announcements management (prominent site notices with schedule, priority, and audience targeting)
- [ ] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata) - [~] [P2] News/blog editorial workflow (draft/review/publish, authoring metadata)
### Public App ### Public App
- [ ] [P1] Dynamic page rendering from CMS page entities - [~] [P1] Dynamic page rendering from CMS page entities
- [ ] [P1] Navigation rendering from managed menu structure - [~] [P1] Navigation rendering from managed menu structure
- [ ] [P1] Media entity rendering with enrichment data - [ ] [P1] Media entity rendering with enrichment data
- [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls - [ ] [P1] Portfolio views (gallery/album/category/tag) for artworks with filter and sort controls
- [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot - [ ] [P1] Rendition-aware media delivery (thumbnail/card/full) per template slot
@@ -179,9 +179,9 @@ This file is the single source of truth for roadmap and delivery progress.
### News / Blog (Secondary Track) ### News / Blog (Secondary Track)
- [ ] [P1] News/blog content type (editorial content for artist updates and process posts) - [~] [P1] News/blog content type (editorial content for artist updates and process posts)
- [ ] [P1] Admin list/editor for news posts - [~] [P1] Admin list/editor for news posts
- [ ] [P1] Public news index + detail pages - [~] [P1] Public news index + detail pages
- [ ] [P2] Tag/category and basic archive support - [ ] [P2] Tag/category and basic archive support
### Testing ### Testing
@@ -275,6 +275,10 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-12] Media storage keys now use asset-centric layout (`tenant/<id>/asset/<assetId>/<fileRole>/<assetId>__<variant>.<ext>`) with DB-managed media taxonomy. - [2026-02-12] Media storage keys now use asset-centric layout (`tenant/<id>/asset/<assetId>/<fileRole>/<assetId>__<variant>.<ext>`) with DB-managed media taxonomy.
- [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions. - [2026-02-12] Admin media CRUD now includes list-to-detail flow (`/media/:id`) with metadata edit and delete actions.
- [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`). - [2026-02-12] MVP1 pages/navigation baseline started: `Page`, `NavigationMenu`, and `NavigationItem` models plus admin CRUD routes (`/pages`, `/pages/:id`, `/navigation`).
- [2026-02-12] Public app now renders CMS-managed navigation (header) and CMS-managed pages by slug (including homepage when `home` page exists).
- [2026-02-12] Commissions/customer baseline added: admin `/commissions` now supports customer creation, commission intake, status transitions, and a basic kanban board.
- [2026-02-12] Announcements/news baseline added: admin `/announcements` + `/news` management screens and public announcement rendering slots (`global_top`, `homepage`).
- [2026-02-12] Public news routes now exist at `/news` and `/news/:slug` (detail restricted to published posts).
## How We Use This File ## How We Use This File

View File

@@ -0,0 +1,423 @@
import {
createAnnouncement,
deleteAnnouncement,
listAnnouncements,
updateAnnouncement,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readInputString(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readNullableString(formData: FormData, field: string): string | null {
const value = readInputString(formData, field)
return value.length > 0 ? value : null
}
function readNullableDate(formData: FormData, field: string): Date | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return null
}
return parsed
}
function readInt(formData: FormData, field: string, fallback = 100): number {
const value = readInputString(formData, field)
if (!value) {
return fallback
}
const parsed = Number.parseInt(value, 10)
if (!Number.isFinite(parsed)) {
return fallback
}
return parsed
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/announcements?${value}` : "/announcements")
}
async function createAnnouncementAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/announcements",
permission: "banner:write",
scope: "global",
})
try {
await createAnnouncement({
title: readInputString(formData, "title"),
message: readInputString(formData, "message"),
placement: readInputString(formData, "placement"),
priority: readInt(formData, "priority", 100),
ctaLabel: readNullableString(formData, "ctaLabel"),
ctaHref: readNullableString(formData, "ctaHref"),
startsAt: readNullableDate(formData, "startsAt"),
endsAt: readNullableDate(formData, "endsAt"),
isVisible: readInputString(formData, "isVisible") === "true",
})
} catch {
redirectWithState({ error: "Failed to create announcement." })
}
revalidatePath("/announcements")
revalidatePath("/")
redirectWithState({ notice: "Announcement created." })
}
async function updateAnnouncementAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/announcements",
permission: "banner:write",
scope: "global",
})
try {
await updateAnnouncement({
id: readInputString(formData, "id"),
title: readInputString(formData, "title"),
message: readInputString(formData, "message"),
placement: readInputString(formData, "placement"),
priority: readInt(formData, "priority", 100),
ctaLabel: readNullableString(formData, "ctaLabel"),
ctaHref: readNullableString(formData, "ctaHref"),
startsAt: readNullableDate(formData, "startsAt"),
endsAt: readNullableDate(formData, "endsAt"),
isVisible: readInputString(formData, "isVisible") === "true",
})
} catch {
redirectWithState({ error: "Failed to update announcement." })
}
revalidatePath("/announcements")
revalidatePath("/")
redirectWithState({ notice: "Announcement updated." })
}
async function deleteAnnouncementAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/announcements",
permission: "banner:write",
scope: "global",
})
try {
await deleteAnnouncement(readInputString(formData, "id"))
} catch {
redirectWithState({ error: "Failed to delete announcement." })
}
revalidatePath("/announcements")
revalidatePath("/")
redirectWithState({ notice: "Announcement deleted." })
}
function dateInputValue(value: Date | null): string {
if (!value) {
return ""
}
return value.toISOString().slice(0, 10)
}
export default async function AnnouncementsPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/announcements",
permission: "banner:read",
scope: "global",
})
const [resolvedSearchParams, announcements] = await Promise.all([
searchParams,
listAnnouncements(200),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/announcements"
badge="Admin App"
title="Announcements"
description="Manage public site announcements with schedule and placement controls."
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Announcement</h2>
<form action={createAnnouncementAction} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Message</span>
<textarea
name="message"
rows={3}
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Placement</span>
<select
name="placement"
defaultValue="global_top"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="global_top">global_top</option>
<option value="homepage">homepage</option>
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Priority</span>
<input
name="priority"
type="number"
min={0}
defaultValue="100"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="inline-flex items-center gap-2 pt-6 text-sm text-neutral-700">
<input
name="isVisible"
type="checkbox"
value="true"
defaultChecked
className="size-4"
/>
Visible
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">CTA label</span>
<input
name="ctaLabel"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">CTA href</span>
<input
name="ctaHref"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Starts at</span>
<input
name="startsAt"
type="date"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Ends at</span>
<input
name="endsAt"
type="date"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<Button type="submit">Create announcement</Button>
</form>
</section>
<section className="space-y-3">
{announcements.length === 0 ? (
<article className="rounded-xl border border-neutral-200 p-6 text-sm text-neutral-600">
No announcements yet.
</article>
) : (
announcements.map((announcement) => (
<form
key={announcement.id}
action={updateAnnouncementAction}
className="rounded-xl border border-neutral-200 p-6"
>
<input type="hidden" name="id" value={announcement.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={announcement.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Placement</span>
<select
name="placement"
defaultValue={announcement.placement}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="global_top">global_top</option>
<option value="homepage">homepage</option>
</select>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Message</span>
<textarea
name="message"
rows={2}
defaultValue={announcement.message}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Priority</span>
<input
name="priority"
type="number"
min={0}
defaultValue={announcement.priority}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Starts</span>
<input
name="startsAt"
type="date"
defaultValue={dateInputValue(announcement.startsAt)}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Ends</span>
<input
name="endsAt"
type="date"
defaultValue={dateInputValue(announcement.endsAt)}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">CTA label</span>
<input
name="ctaLabel"
defaultValue={announcement.ctaLabel ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">CTA href</span>
<input
name="ctaHref"
defaultValue={announcement.ctaHref ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input
name="isVisible"
type="checkbox"
value="true"
defaultChecked={announcement.isVisible}
className="size-4"
/>
Visible
</label>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button
type="submit"
formAction={deleteAnnouncementAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
))
)}
</section>
</AdminShell>
)
}

View File

@@ -1,34 +1,454 @@
import { AdminSectionPlaceholder } from "@/components/admin-section-placeholder" import {
commissionKanbanOrder,
createCommission,
createCustomer,
listCommissions,
listCustomers,
updateCommissionStatus,
} from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell" import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards" import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function CommissionsManagementPage() { type SearchParamsInput = Record<string, string | string[] | undefined>
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readInputString(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readNullableString(formData: FormData, field: string): string | null {
const value = readInputString(formData, field)
return value.length > 0 ? value : null
}
function readNullableNumber(formData: FormData, field: string): number | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = Number.parseFloat(value)
if (!Number.isFinite(parsed)) {
return null
}
return parsed
}
function readNullableDate(formData: FormData, field: string): Date | null {
const value = readInputString(formData, field)
if (!value) {
return null
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return null
}
return parsed
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/commissions?${value}` : "/commissions")
}
async function createCustomerAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/commissions",
permission: "commissions:write",
scope: "own",
})
try {
await createCustomer({
name: readInputString(formData, "name"),
email: readNullableString(formData, "email"),
phone: readNullableString(formData, "phone"),
instagram: readNullableString(formData, "instagram"),
notes: readNullableString(formData, "notes"),
isRecurring: readInputString(formData, "isRecurring") === "true",
})
} catch {
redirectWithState({ error: "Failed to create customer." })
}
revalidatePath("/commissions")
redirectWithState({ notice: "Customer created." })
}
async function createCommissionAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/commissions",
permission: "commissions:write",
scope: "own",
})
try {
await createCommission({
title: readInputString(formData, "title"),
description: readNullableString(formData, "description"),
status: readInputString(formData, "status"),
customerId: readNullableString(formData, "customerId"),
assignedUserId: readNullableString(formData, "assignedUserId"),
budgetMin: readNullableNumber(formData, "budgetMin"),
budgetMax: readNullableNumber(formData, "budgetMax"),
dueAt: readNullableDate(formData, "dueAt"),
})
} catch {
redirectWithState({ error: "Failed to create commission." })
}
revalidatePath("/commissions")
redirectWithState({ notice: "Commission created." })
}
async function updateCommissionStatusAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/commissions",
permission: "commissions:transition",
scope: "own",
})
try {
await updateCommissionStatus({
id: readInputString(formData, "id"),
status: readInputString(formData, "status"),
})
} catch {
redirectWithState({ error: "Failed to transition commission." })
}
revalidatePath("/commissions")
redirectWithState({ notice: "Commission status updated." })
}
function formatDate(value: Date | null) {
if (!value) {
return "-"
}
return value.toLocaleDateString("en-US")
}
export default async function CommissionsManagementPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({ const role = await requirePermissionForRoute({
nextPath: "/commissions", nextPath: "/commissions",
permission: "commissions:read", permission: "commissions:read",
scope: "own", scope: "own",
}) })
const [resolvedSearchParams, customers, commissions] = await Promise.all([
searchParams,
listCustomers(200),
listCommissions(300),
])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return ( return (
<AdminShell <AdminShell
role={role} role={role}
activePath="/commissions" activePath="/commissions"
badge="Admin App" badge="Admin App"
title="Commissions" title="Commissions"
description="Prepare commissions intake and kanban workflow tooling." description="Manage customers and commission requests with kanban-style status transitions."
> >
<AdminSectionPlaceholder {notice ? (
feature="Commissions Workflow" <section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
summary="This route is reserved for request intake, ownership assignment, and kanban transitions." {notice}
requiredPermission="commissions:read (own)" </section>
nextSteps={[ ) : null}
"Add commissions board with status columns.",
"Add assignment, due-date, and notes editing.", {error ? (
"Add transition rules and audit history.", <section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
]} {error}
/> </section>
) : null}
<section className="grid gap-4 xl:grid-cols-2">
<article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Customer</h2>
<form action={createCustomerAction} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Name</span>
<input
name="name"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Email</span>
<input
name="email"
type="email"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Phone</span>
<input
name="phone"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Instagram</span>
<input
name="instagram"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Notes</span>
<textarea
name="notes"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
<input name="isRecurring" type="checkbox" value="true" className="size-4" />
Recurring customer
</label>
<Button type="submit">Create customer</Button>
</form>
</article>
<article className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Commission</h2>
<form action={createCommissionAction} className="mt-4 space-y-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Description</span>
<textarea
name="description"
rows={3}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<select
name="status"
defaultValue="new"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
{commissionKanbanOrder.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Customer</span>
<select
name="customerId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="">(none)</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
</label>
</div>
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Assigned user id</span>
<input
name="assignedUserId"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Budget min</span>
<input
name="budgetMin"
type="number"
min={0}
step="0.01"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Budget max</span>
<input
name="budgetMax"
type="number"
min={0}
step="0.01"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Due date</span>
<input
name="dueAt"
type="date"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<Button type="submit">Create commission</Button>
</form>
</article>
</section>
<section className="space-y-4">
<h2 className="text-xl font-medium">Kanban Board</h2>
<div className="grid gap-3 xl:grid-cols-6">
{commissionKanbanOrder.map((status) => {
const items = commissions.filter((commission) => commission.status === status)
return (
<article
key={status}
className="rounded-xl border border-neutral-200 bg-neutral-50 p-3"
>
<header className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold uppercase tracking-wide text-neutral-700">
{status}
</h3>
<span className="text-xs text-neutral-500">{items.length}</span>
</header>
<div className="space-y-2">
{items.length === 0 ? (
<p className="text-xs text-neutral-500">No commissions</p>
) : (
items.map((commission) => (
<form
key={commission.id}
action={updateCommissionStatusAction}
className="rounded border border-neutral-200 bg-white p-2"
>
<input type="hidden" name="id" value={commission.id} />
<div className="space-y-1">
<p className="text-sm font-medium">{commission.title}</p>
<p className="text-xs text-neutral-600">
{commission.customer?.name ?? "No customer"}
</p>
<p className="text-xs text-neutral-500">
Due: {formatDate(commission.dueAt)}
</p>
</div>
<div className="mt-2 flex items-center gap-2">
<select
name="status"
defaultValue={commission.status}
className="w-full rounded border border-neutral-300 px-2 py-1 text-xs"
>
{commissionKanbanOrder.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
<button
type="submit"
className="rounded border border-neutral-300 px-2 py-1 text-xs"
>
Move
</button>
</div>
</form>
))
)}
</div>
</article>
)
})}
</div>
</section>
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Customers</h2>
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-4">Name</th>
<th className="py-2 pr-4">Email</th>
<th className="py-2 pr-4">Phone</th>
<th className="py-2 pr-4">Recurring</th>
</tr>
</thead>
<tbody>
{customers.length === 0 ? (
<tr>
<td className="py-3 text-neutral-500" colSpan={4}>
No customers yet.
</td>
</tr>
) : (
customers.map((customer) => (
<tr key={customer.id} className="border-t border-neutral-200">
<td className="py-3 pr-4">{customer.name}</td>
<td className="py-3 pr-4 text-neutral-600">{customer.email ?? "-"}</td>
<td className="py-3 pr-4 text-neutral-600">{customer.phone ?? "-"}</td>
<td className="py-3 pr-4">{customer.isRecurring ? "yes" : "no"}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</AdminShell> </AdminShell>
) )
} }

View File

@@ -0,0 +1,276 @@
import { createPost, deletePost, listPosts, updatePost } from "@cms/db"
import { Button } from "@cms/ui/button"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { AdminShell } from "@/components/admin-shell"
import { requirePermissionForRoute } from "@/lib/route-guards"
export const dynamic = "force-dynamic"
type SearchParamsInput = Record<string, string | string[] | undefined>
function readFirstValue(value: string | string[] | undefined): string | null {
if (Array.isArray(value)) {
return value[0] ?? null
}
return value ?? null
}
function readInputString(formData: FormData, field: string): string {
const value = formData.get(field)
return typeof value === "string" ? value.trim() : ""
}
function readNullableString(formData: FormData, field: string): string | undefined {
const value = readInputString(formData, field)
return value.length > 0 ? value : undefined
}
function redirectWithState(params: { notice?: string; error?: string }) {
const query = new URLSearchParams()
if (params.notice) {
query.set("notice", params.notice)
}
if (params.error) {
query.set("error", params.error)
}
const value = query.toString()
redirect(value ? `/news?${value}` : "/news")
}
async function createNewsAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
try {
await createPost({
title: readInputString(formData, "title"),
slug: readInputString(formData, "slug"),
excerpt: readNullableString(formData, "excerpt"),
body: readInputString(formData, "body"),
status: readInputString(formData, "status") === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Failed to create post." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post created." })
}
async function updateNewsAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
try {
await updatePost(readInputString(formData, "id"), {
title: readInputString(formData, "title"),
slug: readInputString(formData, "slug"),
excerpt: readNullableString(formData, "excerpt"),
body: readInputString(formData, "body"),
status: readInputString(formData, "status") === "published" ? "published" : "draft",
})
} catch {
redirectWithState({ error: "Failed to update post." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post updated." })
}
async function deleteNewsAction(formData: FormData) {
"use server"
await requirePermissionForRoute({
nextPath: "/news",
permission: "news:write",
scope: "team",
})
try {
await deletePost(readInputString(formData, "id"))
} catch {
redirectWithState({ error: "Failed to delete post." })
}
revalidatePath("/news")
revalidatePath("/")
redirectWithState({ notice: "Post deleted." })
}
export default async function NewsManagementPage({
searchParams,
}: {
searchParams: Promise<SearchParamsInput>
}) {
const role = await requirePermissionForRoute({
nextPath: "/news",
permission: "news:read",
scope: "team",
})
const [resolvedSearchParams, posts] = await Promise.all([searchParams, listPosts()])
const notice = readFirstValue(resolvedSearchParams.notice)
const error = readFirstValue(resolvedSearchParams.error)
return (
<AdminShell
role={role}
activePath="/news"
badge="Admin App"
title="News"
description="Manage blog/news posts for public updates and announcements archive."
>
{notice ? (
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
{notice}
</section>
) : null}
{error ? (
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
{error}
</section>
) : null}
<section className="rounded-xl border border-neutral-200 p-6">
<h2 className="text-xl font-medium">Create Post</h2>
<form action={createNewsAction} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={5}
required
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Status</span>
<select
name="status"
defaultValue="draft"
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
</label>
<Button type="submit">Create post</Button>
</form>
</section>
<section className="space-y-3">
{posts.map((post) => (
<form
key={post.id}
action={updateNewsAction}
className="rounded-xl border border-neutral-200 p-6"
>
<input type="hidden" name="id" value={post.id} />
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1">
<span className="text-xs text-neutral-600">Title</span>
<input
name="title"
defaultValue={post.title}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs text-neutral-600">Slug</span>
<input
name="slug"
defaultValue={post.slug}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
</div>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Excerpt</span>
<input
name="excerpt"
defaultValue={post.excerpt ?? ""}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<label className="mt-3 block space-y-1">
<span className="text-xs text-neutral-600">Body</span>
<textarea
name="body"
rows={4}
defaultValue={post.body}
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
/>
</label>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<select
name="status"
defaultValue={post.status}
className="rounded border border-neutral-300 px-3 py-2 text-sm"
>
<option value="draft">draft</option>
<option value="published">published</option>
</select>
<div className="flex items-center gap-2">
<Button type="submit" size="sm">
Save
</Button>
<button
type="submit"
formAction={deleteNewsAction}
className="rounded-md border border-red-300 px-3 py-2 text-sm text-red-700"
>
Delete
</button>
</div>
</div>
</form>
))}
</section>
</AdminShell>
)
}

View File

@@ -31,6 +31,8 @@ const navItems: NavItem[] = [
{ href: "/portfolio", label: "Portfolio", permission: "media:read", scope: "team" }, { href: "/portfolio", label: "Portfolio", permission: "media:read", scope: "team" },
{ href: "/users", label: "Users", permission: "users:read", scope: "own" }, { href: "/users", label: "Users", permission: "users:read", scope: "own" },
{ href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" }, { href: "/commissions", label: "Commissions", permission: "commissions:read", scope: "own" },
{ href: "/announcements", label: "Announcements", permission: "banner:read", scope: "global" },
{ href: "/news", label: "News", permission: "news:read", scope: "team" },
{ href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" }, { href: "/settings", label: "Settings", permission: "users:manage_roles", scope: "global" },
{ href: "/todo", label: "Roadmap", permission: "roadmap:read", scope: "global" }, { href: "/todo", label: "Roadmap", permission: "roadmap:read", scope: "global" },
] ]

View File

@@ -47,5 +47,13 @@ describe("admin route access rules", () => {
permission: "commissions:read", permission: "commissions:read",
scope: "own", scope: "own",
}) })
expect(getRequiredPermission("/announcements")).toEqual({
permission: "banner:read",
scope: "global",
})
expect(getRequiredPermission("/news")).toEqual({
permission: "news:read",
scope: "team",
})
}) })
}) })

View File

@@ -85,6 +85,20 @@ const guardRules: GuardRule[] = [
scope: "own", scope: "own",
}, },
}, },
{
route: /^\/announcements(?:\/|$)/,
requirement: {
permission: "banner:read",
scope: "global",
},
},
{
route: /^\/news(?:\/|$)/,
requirement: {
permission: "news:read",
scope: "team",
},
},
{ {
route: /^\/settings(?:\/|$)/, route: /^\/settings(?:\/|$)/,
requirement: { requirement: {

View File

@@ -0,0 +1,21 @@
import { getPublishedPageBySlug } from "@cms/db"
import { notFound } from "next/navigation"
import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic"
type PageProps = {
params: Promise<{ slug: string }>
}
export default async function CmsPageRoute({ params }: PageProps) {
const { slug } = await params
const page = await getPublishedPageBySlug(slug)
if (!page) {
notFound()
}
return <PublicPageView page={page} />
}

View File

@@ -1,13 +0,0 @@
import { getTranslations } from "next-intl/server"
export default async function AboutPage() {
const t = await getTranslations("About")
return (
<section className="mx-auto w-full max-w-6xl space-y-4 px-6 py-16">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="max-w-3xl text-neutral-600">{t("description")}</p>
</section>
)
}

View File

@@ -1,13 +0,0 @@
import { getTranslations } from "next-intl/server"
export default async function ContactPage() {
const t = await getTranslations("Contact")
return (
<section className="mx-auto w-full max-w-6xl space-y-4 px-6 py-16">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="max-w-3xl text-neutral-600">{t("description")}</p>
</section>
)
}

View File

@@ -3,7 +3,7 @@ import { notFound } from "next/navigation"
import { hasLocale, NextIntlClientProvider } from "next-intl" import { hasLocale, NextIntlClientProvider } from "next-intl"
import { getTranslations } from "next-intl/server" import { getTranslations } from "next-intl/server"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { PublicAnnouncements } from "@/components/public-announcements"
import { PublicHeaderBanner } from "@/components/public-header-banner" import { PublicHeaderBanner } from "@/components/public-header-banner"
import { PublicSiteFooter } from "@/components/public-site-footer" import { PublicSiteFooter } from "@/components/public-site-footer"
import { PublicSiteHeader } from "@/components/public-site-header" import { PublicSiteHeader } from "@/components/public-site-header"
@@ -52,6 +52,7 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
<NextIntlClientProvider locale={locale}> <NextIntlClientProvider locale={locale}>
<Providers> <Providers>
<PublicHeaderBanner banner={banner} /> <PublicHeaderBanner banner={banner} />
<PublicAnnouncements placement="global_top" />
<PublicSiteHeader /> <PublicSiteHeader />
<main>{children}</main> <main>{children}</main>
<PublicSiteFooter /> <PublicSiteFooter />

View File

@@ -0,0 +1,30 @@
import { getPostBySlug } from "@cms/db"
import { notFound } from "next/navigation"
export const dynamic = "force-dynamic"
type PageProps = {
params: Promise<{ slug: string }>
}
export default async function PublicNewsDetailPage({ params }: PageProps) {
const { slug } = await params
const post = await getPostBySlug(slug)
if (!post || post.status !== "published") {
notFound()
}
return (
<article className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">News</p>
<h1 className="text-4xl font-semibold tracking-tight">{post.title}</h1>
{post.excerpt ? <p className="text-neutral-600">{post.excerpt}</p> : null}
</header>
<section className="prose prose-neutral max-w-none whitespace-pre-wrap rounded-xl border border-neutral-200 bg-white p-6 text-neutral-800">
{post.body}
</section>
</article>
)
}

View File

@@ -0,0 +1,33 @@
import { listPosts } from "@cms/db"
import Link from "next/link"
export const dynamic = "force-dynamic"
export default async function PublicNewsIndexPage() {
const posts = await listPosts()
return (
<section className="mx-auto w-full max-w-4xl space-y-4 px-6 py-16">
<header className="space-y-2">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">News</p>
<h1 className="text-4xl font-semibold tracking-tight">Latest updates</h1>
</header>
<div className="space-y-3">
{posts.map((post) => (
<article key={post.id} className="rounded-lg border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
<h2 className="mt-1 text-lg font-medium">{post.title}</h2>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? "No excerpt"}</p>
<Link
href={`/news/${post.slug}`}
className="mt-2 inline-block text-sm underline underline-offset-2"
>
Read post
</Link>
</article>
))}
</div>
</section>
)
}

View File

@@ -1,35 +1,46 @@
import { listPosts } from "@cms/db" import { getPublishedPageBySlug, listPosts } from "@cms/db"
import { Button } from "@cms/ui/button" import { Button } from "@cms/ui/button"
import { getTranslations } from "next-intl/server" import { getTranslations } from "next-intl/server"
import { PublicAnnouncements } from "@/components/public-announcements"
import { PublicPageView } from "@/components/public-page-view"
export const dynamic = "force-dynamic" export const dynamic = "force-dynamic"
export default async function HomePage() { export default async function HomePage() {
const [posts, t] = await Promise.all([listPosts(), getTranslations("Home")]) const [homePage, posts, t] = await Promise.all([
getPublishedPageBySlug("home"),
listPosts(),
getTranslations("Home"),
])
return ( return (
<section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-16"> <section>
<header className="space-y-3"> {homePage ? <PublicPageView page={homePage} /> : null}
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p> <PublicAnnouncements placement="homepage" />
<h1 className="text-4xl font-semibold tracking-tight">{t("title")}</h1>
<p className="text-neutral-600">{t("description")}</p>
</header>
<section className="space-y-4 rounded-xl border border-neutral-200 p-6"> <section className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6 py-6 pb-16">
<div className="flex items-center justify-between"> <header className="space-y-3">
<h2 className="text-xl font-medium">{t("latestPosts")}</h2> <p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{t("badge")}</p>
<Button variant="secondary">{t("explore")}</Button> <h2 className="text-3xl font-semibold tracking-tight">{t("latestPosts")}</h2>
</div> <p className="text-neutral-600">{t("description")}</p>
</header>
<ul className="space-y-3"> <section className="space-y-4 rounded-xl border border-neutral-200 p-6">
{posts.map((post) => ( <div className="flex items-center justify-between">
<li key={post.id} className="rounded-lg border border-neutral-200 p-4"> <h3 className="text-xl font-medium">{t("latestPosts")}</h3>
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p> <Button variant="secondary">{t("explore")}</Button>
<h3 className="mt-1 text-lg font-medium">{post.title}</h3> </div>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
</li> <ul className="space-y-3">
))} {posts.map((post) => (
</ul> <li key={post.id} className="rounded-lg border border-neutral-200 p-4">
<p className="text-xs uppercase tracking-wide text-neutral-500">{post.status}</p>
<h4 className="mt-1 text-lg font-medium">{post.title}</h4>
<p className="mt-2 text-sm text-neutral-600">{post.excerpt ?? t("noExcerpt")}</p>
</li>
))}
</ul>
</section>
</section> </section>
</section> </section>
) )

View File

@@ -1,14 +1,13 @@
import { listPublishedPageSlugs } from "@cms/db"
import type { MetadataRoute } from "next" import type { MetadataRoute } from "next"
const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000" const baseUrl = process.env.CMS_WEB_ORIGIN ?? "http://localhost:3000"
const publicRoutes = ["/", "/about", "/contact"] export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const pages = await listPublishedPageSlugs()
export default function sitemap(): MetadataRoute.Sitemap { return pages.map((page) => ({
const now = new Date() url: page.slug === "home" ? `${baseUrl}/` : `${baseUrl}/${page.slug}`,
lastModified: page.updatedAt,
return publicRoutes.map((route) => ({
url: `${baseUrl}${route}`,
lastModified: now,
})) }))
} }

View File

@@ -0,0 +1,39 @@
import { listActiveAnnouncements, type PublicAnnouncement } from "@cms/db"
import Link from "next/link"
type PublicAnnouncementsProps = {
placement: "global_top" | "homepage"
}
function AnnouncementCard({ announcement }: { announcement: PublicAnnouncement }) {
return (
<article className="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-900">
<p className="text-xs uppercase tracking-wide text-blue-700">{announcement.title}</p>
<p className="mt-1">{announcement.message}</p>
{announcement.ctaLabel && announcement.ctaHref ? (
<Link
href={announcement.ctaHref}
className="mt-2 inline-block font-medium underline underline-offset-2"
>
{announcement.ctaLabel}
</Link>
) : null}
</article>
)
}
export async function PublicAnnouncements({ placement }: PublicAnnouncementsProps) {
const announcements = await listActiveAnnouncements(placement)
if (announcements.length === 0) {
return null
}
return (
<section className="mx-auto w-full max-w-6xl space-y-2 px-6 py-3">
{announcements.map((announcement) => (
<AnnouncementCard key={announcement.id} announcement={announcement} />
))}
</section>
)
}

View File

@@ -0,0 +1,26 @@
type PageEntity = {
title: string
status: string
summary: string | null
content: string
}
type PublicPageViewProps = {
page: PageEntity
}
export function PublicPageView({ page }: PublicPageViewProps) {
return (
<article className="mx-auto flex w-full max-w-4xl flex-col gap-6 px-6 py-16">
<header className="space-y-3">
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">{page.status}</p>
<h1 className="text-4xl font-semibold tracking-tight">{page.title}</h1>
{page.summary ? <p className="text-neutral-600">{page.summary}</p> : null}
</header>
<section className="prose prose-neutral max-w-none whitespace-pre-wrap rounded-xl border border-neutral-200 bg-white p-6 text-neutral-800">
{page.content}
</section>
</article>
)
}

View File

@@ -1,19 +1,11 @@
"use client" import { listPublicNavigation } from "@cms/db"
import { useTranslations } from "next-intl"
import { Link } from "@/i18n/navigation" import { Link } from "@/i18n/navigation"
import { LanguageSwitcher } from "./language-switcher" import { LanguageSwitcher } from "./language-switcher"
export function PublicSiteHeader() { export async function PublicSiteHeader() {
const t = useTranslations("Layout") const navItems = await listPublicNavigation("header")
const navItems = [
{ href: "/", label: t("nav.home") },
{ href: "/about", label: t("nav.about") },
{ href: "/contact", label: t("nav.contact") },
]
return ( return (
<header className="border-b border-neutral-200 bg-white/80 backdrop-blur"> <header className="border-b border-neutral-200 bg-white/80 backdrop-blur">
@@ -22,19 +14,28 @@ export function PublicSiteHeader() {
href="/" href="/"
className="text-sm font-semibold uppercase tracking-[0.2em] text-neutral-700" className="text-sm font-semibold uppercase tracking-[0.2em] text-neutral-700"
> >
{t("brand")} CMS Web
</Link> </Link>
<nav className="flex flex-wrap items-center gap-2"> <nav className="flex flex-wrap items-center gap-2">
{navItems.map((item) => ( {navItems.length === 0 ? (
<Link <Link
key={item.href} href="/"
href={item.href}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100" className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
> >
{item.label} Home
</Link> </Link>
))} ) : (
navItems.map((item) => (
<Link
key={item.id}
href={item.href}
className="rounded-md border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-700 hover:bg-neutral-100"
>
{item.label}
</Link>
))
)}
</nav> </nav>
<LanguageSwitcher /> <LanguageSwitcher />

View File

@@ -0,0 +1,32 @@
import { z } from "zod"
export const announcementPlacementSchema = z.enum(["global_top", "homepage"])
export const createAnnouncementInputSchema = z.object({
title: z.string().min(1).max(180),
message: z.string().min(1).max(500),
placement: announcementPlacementSchema.default("global_top"),
priority: z.number().int().min(0).default(100),
ctaLabel: z.string().max(120).nullable().optional(),
ctaHref: z.string().max(500).nullable().optional(),
startsAt: z.date().nullable().optional(),
endsAt: z.date().nullable().optional(),
isVisible: z.boolean().default(true),
})
export const updateAnnouncementInputSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(180).optional(),
message: z.string().min(1).max(500).optional(),
placement: announcementPlacementSchema.optional(),
priority: z.number().int().min(0).optional(),
ctaLabel: z.string().max(120).nullable().optional(),
ctaHref: z.string().max(500).nullable().optional(),
startsAt: z.date().nullable().optional(),
endsAt: z.date().nullable().optional(),
isVisible: z.boolean().optional(),
})
export type AnnouncementPlacement = z.infer<typeof announcementPlacementSchema>
export type CreateAnnouncementInput = z.infer<typeof createAnnouncementInputSchema>
export type UpdateAnnouncementInput = z.infer<typeof updateAnnouncementInputSchema>

View File

@@ -0,0 +1,40 @@
import { z } from "zod"
export const commissionStatusSchema = z.enum([
"new",
"scoped",
"in_progress",
"review",
"done",
"canceled",
])
export const createCustomerInputSchema = z.object({
name: z.string().min(1).max(180),
email: z.string().email().max(320).nullable().optional(),
phone: z.string().max(80).nullable().optional(),
instagram: z.string().max(120).nullable().optional(),
notes: z.string().max(4000).nullable().optional(),
isRecurring: z.boolean().default(false),
})
export const createCommissionInputSchema = z.object({
title: z.string().min(1).max(180),
description: z.string().max(4000).nullable().optional(),
status: commissionStatusSchema.default("new"),
customerId: z.string().uuid().nullable().optional(),
assignedUserId: z.string().max(120).nullable().optional(),
budgetMin: z.number().nonnegative().nullable().optional(),
budgetMax: z.number().nonnegative().nullable().optional(),
dueAt: z.date().nullable().optional(),
})
export const updateCommissionStatusInputSchema = z.object({
id: z.string().uuid(),
status: commissionStatusSchema,
})
export type CommissionStatus = z.infer<typeof commissionStatusSchema>
export type CreateCustomerInput = z.infer<typeof createCustomerInputSchema>
export type CreateCommissionInput = z.infer<typeof createCommissionInputSchema>
export type UpdateCommissionStatusInput = z.infer<typeof updateCommissionStatusInputSchema>

View File

@@ -1,5 +1,7 @@
import { z } from "zod" import { z } from "zod"
export * from "./announcements"
export * from "./commissions"
export * from "./media" export * from "./media"
export * from "./pages-navigation" export * from "./pages-navigation"
export * from "./rbac" export * from "./rbac"

View File

@@ -0,0 +1,52 @@
-- CreateTable
CREATE TABLE "Customer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"instagram" TEXT,
"notes" TEXT,
"isRecurring" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Commission" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"status" TEXT NOT NULL,
"customerId" TEXT,
"assignedUserId" TEXT,
"budgetMin" DOUBLE PRECISION,
"budgetMax" DOUBLE PRECISION,
"dueAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Commission_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Customer_email_idx" ON "Customer"("email");
-- CreateIndex
CREATE INDEX "Customer_isRecurring_idx" ON "Customer"("isRecurring");
-- CreateIndex
CREATE INDEX "Commission_status_idx" ON "Commission"("status");
-- CreateIndex
CREATE INDEX "Commission_customerId_idx" ON "Commission"("customerId");
-- CreateIndex
CREATE INDEX "Commission_assignedUserId_idx" ON "Commission"("assignedUserId");
-- AddForeignKey
ALTER TABLE "Commission" ADD CONSTRAINT "Commission_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Commission" ADD CONSTRAINT "Commission_assignedUserId_fkey" FOREIGN KEY ("assignedUserId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "Announcement" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"message" TEXT NOT NULL,
"placement" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 100,
"ctaLabel" TEXT,
"ctaHref" TEXT,
"startsAt" TIMESTAMP(3),
"endsAt" TIMESTAMP(3),
"isVisible" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Announcement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Announcement_placement_isVisible_idx" ON "Announcement"("placement", "isVisible");
-- CreateIndex
CREATE INDEX "Announcement_priority_idx" ON "Announcement"("priority");

View File

@@ -34,6 +34,7 @@ model User {
isProtected Boolean @default(false) isProtected Boolean @default(false)
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
commissions Commission[] @relation("CommissionAssignee")
@@unique([email]) @@unique([email])
@@index([role]) @@index([role])
@@ -302,3 +303,57 @@ model NavigationItem {
@@index([parentId]) @@index([parentId])
@@unique([menuId, parentId, sortOrder, label]) @@unique([menuId, parentId, sortOrder, label])
} }
model Customer {
id String @id @default(uuid())
name String
email String?
phone String?
instagram String?
notes String?
isRecurring Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
commissions Commission[]
@@index([email])
@@index([isRecurring])
}
model Commission {
id String @id @default(uuid())
title String
description String?
status String
customerId String?
assignedUserId String?
budgetMin Float?
budgetMax Float?
dueAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
assignedUser User? @relation("CommissionAssignee", fields: [assignedUserId], references: [id], onDelete: SetNull)
@@index([status])
@@index([customerId])
@@index([assignedUserId])
}
model Announcement {
id String @id @default(uuid())
title String
message String
placement String
priority Int @default(100)
ctaLabel String?
ctaHref String?
startsAt DateTime?
endsAt DateTime?
isVisible Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([placement, isVisible])
@@index([priority])
}

View File

@@ -158,6 +158,71 @@ async function main() {
}, },
}) })
} }
const existingCustomer = await db.customer.findFirst({
where: {
email: "collector@example.com",
},
select: {
id: true,
},
})
const seededCustomer = existingCustomer
? await db.customer.update({
where: {
id: existingCustomer.id,
},
data: {
name: "Collector One",
phone: "+1-555-0101",
isRecurring: true,
notes: "Interested in recurring portrait commissions.",
},
})
: await db.customer.create({
data: {
name: "Collector One",
email: "collector@example.com",
phone: "+1-555-0101",
isRecurring: true,
notes: "Interested in recurring portrait commissions.",
},
})
await db.commission.upsert({
where: {
id: "11111111-1111-1111-1111-111111111111",
},
update: {},
create: {
id: "11111111-1111-1111-1111-111111111111",
title: "Portrait Commission Baseline",
description: "Initial seeded commission request for MVP1 board validation.",
status: "new",
customerId: seededCustomer.id,
budgetMin: 400,
budgetMax: 900,
dueAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000),
},
})
await db.announcement.upsert({
where: {
id: "22222222-2222-2222-2222-222222222222",
},
update: {},
create: {
id: "22222222-2222-2222-2222-222222222222",
title: "Commission Slots",
message: "New commission slots are open for next month.",
placement: "global_top",
priority: 10,
ctaLabel: "Request now",
ctaHref: "/commissions",
isVisible: true,
},
})
} }
main() main()

View File

@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
announcement: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import { createAnnouncement, listActiveAnnouncements } from "./announcements"
describe("announcements service", () => {
beforeEach(() => {
for (const fn of Object.values(mockDb.announcement)) {
if (typeof fn === "function") {
fn.mockReset()
}
}
})
it("creates announcements through schema parsing", async () => {
mockDb.announcement.create.mockResolvedValue({ id: "announcement-1" })
await createAnnouncement({
title: "Scheduled Notice",
message: "Commission slots are open.",
placement: "global_top",
})
expect(mockDb.announcement.create).toHaveBeenCalledTimes(1)
})
it("queries only visible announcements in the given placement", async () => {
mockDb.announcement.findMany.mockResolvedValue([])
await listActiveAnnouncements("homepage")
expect(mockDb.announcement.findMany).toHaveBeenCalledTimes(1)
expect(mockDb.announcement.findMany.mock.calls[0]?.[0]).toMatchObject({
where: {
placement: "homepage",
isVisible: true,
},
})
})
})

View File

@@ -0,0 +1,74 @@
import {
type AnnouncementPlacement,
createAnnouncementInputSchema,
updateAnnouncementInputSchema,
} from "@cms/content"
import { db } from "./client"
export type PublicAnnouncement = {
id: string
title: string
message: string
ctaLabel: string | null
ctaHref: string | null
placement: string
priority: number
}
export async function listAnnouncements(limit = 200) {
return db.announcement.findMany({
orderBy: [{ priority: "asc" }, { updatedAt: "desc" }],
take: limit,
})
}
export async function createAnnouncement(input: unknown) {
const payload = createAnnouncementInputSchema.parse(input)
return db.announcement.create({
data: payload,
})
}
export async function updateAnnouncement(input: unknown) {
const payload = updateAnnouncementInputSchema.parse(input)
const { id, ...data } = payload
return db.announcement.update({
where: { id },
data,
})
}
export async function deleteAnnouncement(id: string) {
return db.announcement.delete({
where: { id },
})
}
export async function listActiveAnnouncements(
placement: AnnouncementPlacement,
now = new Date(),
): Promise<PublicAnnouncement[]> {
const announcements = await db.announcement.findMany({
where: {
placement,
isVisible: true,
OR: [{ startsAt: null }, { startsAt: { lte: now } }],
AND: [{ OR: [{ endsAt: null }, { endsAt: { gte: now } }] }],
},
orderBy: [{ priority: "asc" }, { createdAt: "desc" }],
select: {
id: true,
title: true,
message: true,
ctaLabel: true,
ctaHref: true,
placement: true,
priority: true,
},
})
return announcements
}

View File

@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
const { mockDb } = vi.hoisted(() => ({
mockDb: {
customer: {
create: vi.fn(),
findMany: vi.fn(),
},
commission: {
create: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock("./client", () => ({
db: mockDb,
}))
import { createCommission, createCustomer, updateCommissionStatus } from "./commissions"
describe("commissions service", () => {
beforeEach(() => {
for (const value of Object.values(mockDb)) {
for (const fn of Object.values(value)) {
if (typeof fn === "function") {
fn.mockReset()
}
}
}
})
it("creates customer and commission payloads", async () => {
mockDb.customer.create.mockResolvedValue({ id: "customer-1" })
mockDb.commission.create.mockResolvedValue({ id: "commission-1" })
await createCustomer({
name: "Ada Lovelace",
email: "ada@example.com",
isRecurring: true,
})
await createCommission({
title: "Portrait Request",
status: "new",
customerId: "550e8400-e29b-41d4-a716-446655440000",
})
expect(mockDb.customer.create).toHaveBeenCalledTimes(1)
expect(mockDb.commission.create).toHaveBeenCalledTimes(1)
})
it("updates commission status", async () => {
mockDb.commission.update.mockResolvedValue({ id: "commission-1", status: "done" })
await updateCommissionStatus({
id: "550e8400-e29b-41d4-a716-446655440001",
status: "done",
})
expect(mockDb.commission.update).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,66 @@
import {
commissionStatusSchema,
createCommissionInputSchema,
createCustomerInputSchema,
updateCommissionStatusInputSchema,
} from "@cms/content"
import { db } from "./client"
export const commissionKanbanOrder = commissionStatusSchema.options
export async function listCustomers(limit = 200) {
return db.customer.findMany({
orderBy: [{ updatedAt: "desc" }],
take: limit,
})
}
export async function createCustomer(input: unknown) {
const payload = createCustomerInputSchema.parse(input)
return db.customer.create({
data: payload,
})
}
export async function listCommissions(limit = 300) {
return db.commission.findMany({
orderBy: [{ updatedAt: "desc" }],
take: limit,
include: {
customer: {
select: {
id: true,
name: true,
email: true,
isRecurring: true,
},
},
assignedUser: {
select: {
id: true,
name: true,
username: true,
},
},
},
})
}
export async function createCommission(input: unknown) {
const payload = createCommissionInputSchema.parse(input)
return db.commission.create({
data: payload,
})
}
export async function updateCommissionStatus(input: unknown) {
const payload = updateCommissionStatusInputSchema.parse(input)
return db.commission.update({
where: { id: payload.id },
data: { status: payload.status },
})
}

View File

@@ -1,4 +1,20 @@
export type { PublicAnnouncement } from "./announcements"
export {
createAnnouncement,
deleteAnnouncement,
listActiveAnnouncements,
listAnnouncements,
updateAnnouncement,
} from "./announcements"
export { db } from "./client" export { db } from "./client"
export {
commissionKanbanOrder,
createCommission,
createCustomer,
listCommissions,
listCustomers,
updateCommissionStatus,
} from "./commissions"
export { export {
attachArtworkRendition, attachArtworkRendition,
createAlbum, createAlbum,
@@ -16,6 +32,7 @@ export {
listMediaFoundationGroups, listMediaFoundationGroups,
updateMediaAsset, updateMediaAsset,
} from "./media-foundation" } from "./media-foundation"
export type { PublicNavigationItem } from "./pages-navigation"
export { export {
createNavigationItem, createNavigationItem,
createNavigationMenu, createNavigationMenu,
@@ -23,8 +40,11 @@ export {
deleteNavigationItem, deleteNavigationItem,
deletePage, deletePage,
getPageById, getPageById,
getPublishedPageBySlug,
listNavigationMenus, listNavigationMenus,
listPages, listPages,
listPublicNavigation,
listPublishedPageSlugs,
updateNavigationItem, updateNavigationItem,
updatePage, updatePage,
} from "./pages-navigation" } from "./pages-navigation"
@@ -32,6 +52,7 @@ export {
createPost, createPost,
deletePost, deletePost,
getPostById, getPostById,
getPostBySlug,
listPosts, listPosts,
registerPostCrudAuditHook, registerPostCrudAuditHook,
updatePost, updatePost,

View File

@@ -12,6 +12,7 @@ const { mockDb } = vi.hoisted(() => ({
navigationMenu: { navigationMenu: {
create: vi.fn(), create: vi.fn(),
findMany: vi.fn(), findMany: vi.fn(),
findFirst: vi.fn(),
}, },
navigationItem: { navigationItem: {
create: vi.fn(), create: vi.fn(),
@@ -29,6 +30,7 @@ import {
createNavigationItem, createNavigationItem,
createNavigationMenu, createNavigationMenu,
createPage, createPage,
listPublicNavigation,
updatePage, updatePage,
} from "./pages-navigation" } from "./pages-navigation"
@@ -89,4 +91,33 @@ describe("pages-navigation service", () => {
expect(mockDb.navigationMenu.create).toHaveBeenCalledTimes(1) expect(mockDb.navigationMenu.create).toHaveBeenCalledTimes(1)
expect(mockDb.navigationItem.create).toHaveBeenCalledTimes(1) expect(mockDb.navigationItem.create).toHaveBeenCalledTimes(1)
}) })
it("maps public navigation href from linked pages", async () => {
mockDb.navigationMenu.findFirst.mockResolvedValue({
id: "menu-1",
items: [
{
id: "item-1",
label: "Home",
href: null,
parentId: null,
page: {
slug: "home",
status: "published",
},
},
],
})
const navigation = await listPublicNavigation("header")
expect(navigation).toEqual([
{
id: "item-1",
label: "Home",
href: "/",
children: [],
},
])
})
}) })

View File

@@ -8,6 +8,13 @@ import {
import { db } from "./client" import { db } from "./client"
export type PublicNavigationItem = {
id: string
label: string
href: string
children: PublicNavigationItem[]
}
function resolvePublishedAt(status: string): Date | null { function resolvePublishedAt(status: string): Date | null {
return status === "published" ? new Date() : null return status === "published" ? new Date() : null
} }
@@ -19,12 +26,34 @@ export async function listPages(limit = 50) {
}) })
} }
export async function listPublishedPageSlugs() {
const pages = await db.page.findMany({
where: { status: "published" },
orderBy: { updatedAt: "desc" },
select: {
slug: true,
updatedAt: true,
},
})
return pages
}
export async function getPageById(id: string) { export async function getPageById(id: string) {
return db.page.findUnique({ return db.page.findUnique({
where: { id }, where: { id },
}) })
} }
export async function getPublishedPageBySlug(slug: string) {
return db.page.findFirst({
where: {
slug,
status: "published",
},
})
}
export async function createPage(input: unknown) { export async function createPage(input: unknown) {
const payload = createPageInputSchema.parse(input) const payload = createPageInputSchema.parse(input)
@@ -76,6 +105,108 @@ export async function listNavigationMenus() {
}) })
} }
function resolveNavigationHref(item: {
href: string | null
page: {
slug: string
status: string
} | null
}): string | null {
if (item.href) {
return item.href
}
if (item.page?.status === "published") {
return item.page.slug === "home" ? "/" : `/${item.page.slug}`
}
return null
}
export async function listPublicNavigation(location = "header"): Promise<PublicNavigationItem[]> {
const menu = await db.navigationMenu.findFirst({
where: {
location,
isVisible: true,
},
orderBy: { updatedAt: "desc" },
include: {
items: {
where: {
isVisible: true,
},
orderBy: [{ sortOrder: "asc" }, { label: "asc" }],
include: {
page: {
select: {
slug: true,
status: true,
},
},
},
},
},
})
if (!menu) {
return []
}
const itemMap = new Map<
string,
{
id: string
label: string
href: string
parentId: string | null
children: PublicNavigationItem[]
}
>()
for (const item of menu.items) {
const href = resolveNavigationHref(item)
if (!href) {
continue
}
itemMap.set(item.id, {
id: item.id,
label: item.label,
href,
parentId: item.parentId,
children: [],
})
}
const roots: PublicNavigationItem[] = []
for (const entry of itemMap.values()) {
if (entry.parentId) {
const parent = itemMap.get(entry.parentId)
if (parent) {
parent.children.push({
id: entry.id,
label: entry.label,
href: entry.href,
children: entry.children,
})
continue
}
}
roots.push({
id: entry.id,
label: entry.label,
href: entry.href,
children: entry.children,
})
}
return roots
}
export async function createNavigationMenu(input: unknown) { export async function createNavigationMenu(input: unknown) {
const payload = createNavigationMenuInputSchema.parse(input) const payload = createNavigationMenuInputSchema.parse(input)

View File

@@ -67,6 +67,12 @@ export async function getPostById(id: string) {
return postCrudService.getById(id) return postCrudService.getById(id)
} }
export async function getPostBySlug(slug: string) {
return db.post.findUnique({
where: { slug },
})
}
export async function createPost(input: unknown, context?: CrudMutationContext) { export async function createPost(input: unknown, context?: CrudMutationContext) {
return postCrudService.create(input, context) return postCrudService.create(input, context)
} }