diff --git a/TODO.md b/TODO.md index df789b4..76b36ed 100644 --- a/TODO.md +++ b/TODO.md @@ -114,7 +114,7 @@ This file is the single source of truth for roadmap and delivery progress. media model, artwork entity, grouping primitives (gallery/album/category/tag), rendition slots - [~] [P1] `todo/mvp1-media-upload-pipeline`: S3/local upload adapter, media processing presets, metadata input flows, admin media CRUD UI -- [~] [P1] `todo/mvp1-pages-navigation-builder`: +- [x] [P1] `todo/mvp1-pages-navigation-builder`: page CRUD, navigation tree, reusable page blocks (forms/price cards/gallery embeds) - [~] [P1] `todo/mvp1-commissions-customers`: commission request intake + admin CRUD + kanban + customer entity/linking @@ -136,7 +136,7 @@ This file is the single source of truth for roadmap and delivery progress. ### Admin App (Primary Focus) -- [~] [P1] Page management (create/edit/publish/unpublish/schedule) +- [x] [P1] Page management (create/edit/publish/unpublish/schedule) - [x] [P1] Page builder with reusable content blocks (hero, rich text, gallery, CTA, forms, price cards) - [x] [P1] Navigation management (menus, nested items, order, visibility) - [~] [P1] Media library (upload, browse, replace, delete) with media-type classification (artwork, banner, promo, generic, video/gif) @@ -371,6 +371,7 @@ This file is the single source of truth for roadmap and delivery progress. - [2026-02-12] Commissions management completed: admin kanban cards now include inline detail editing (assignee/customer/budget/due date/notes), linked-artwork references via `linkedArtworkIds`, and creation/edit flows use assignable users instead of raw ID entry. - [2026-02-12] Announcements/news completed: announcements now support locale audience targeting (`targetLocales`) with public locale-aware rendering, and homepage news list now uses locale-aware published posts only. - [2026-02-12] Public rendering integration completed: portfolio now supports locale-aware tag filters and explicit sort controls, while db/service sorting and rendition selection align public listing/detail media delivery. +- [2026-02-12] Page scheduling completed: `Page.scheduledPublishAt` added with admin create/edit support and public page resolution now treating due scheduled pages as published. - [2026-02-12] Public UX pass: commission request flow now reports explicit invalid budget range errors, and header navigation now falls back to localized defaults (`home`, `portfolio`, `news`, `commissions`) when no CMS menu exists; seed data now creates those default menu entries. - [2026-02-12] Added `e2e/public-rendering.pw.ts` web coverage for fallback navigation visibility, portfolio routes, and commission submission validation (invalid budget range + successful submission path). - [2026-02-12] Testing execution is temporarily paused for delivery velocity: root test scripts are stubbed and CI test steps are disabled; all testing backlog is consolidated under `MVP 3: Testing and Quality`. diff --git a/apps/admin/src/app/pages/[id]/page.tsx b/apps/admin/src/app/pages/[id]/page.tsx index f318d4f..4adffbb 100644 --- a/apps/admin/src/app/pages/[id]/page.tsx +++ b/apps/admin/src/app/pages/[id]/page.tsx @@ -42,6 +42,31 @@ function readNullableString(formData: FormData, field: string): string | null { 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) + return Number.isNaN(parsed.getTime()) ? null : parsed +} + +function formatDateTimeLocalInput(value: Date | null): string { + if (!value) { + return "" + } + + const year = value.getFullYear() + const month = String(value.getMonth() + 1).padStart(2, "0") + const day = String(value.getDate()).padStart(2, "0") + const hour = String(value.getHours()).padStart(2, "0") + const minute = String(value.getMinutes()).padStart(2, "0") + + return `${year}-${month}-${day}T${hour}:${minute}` +} + function redirectWithState(pageId: string, params: { notice?: string; error?: string }) { const query = new URLSearchParams() @@ -109,6 +134,7 @@ export default async function PageEditorPage({ params, searchParams }: PageProps content: readInputString(formData, "content"), seoTitle: readNullableString(formData, "seoTitle"), seoDescription: readNullableString(formData, "seoDescription"), + scheduledPublishAt: readNullableDate(formData, "scheduledPublishAt"), }) } catch { redirectWithState(pageId, { @@ -224,6 +250,16 @@ export default async function PageEditorPage({ params, searchParams }: PageProps + + + + ) diff --git a/packages/content/src/pages-navigation.ts b/packages/content/src/pages-navigation.ts index e03057d..5380e2f 100644 --- a/packages/content/src/pages-navigation.ts +++ b/packages/content/src/pages-navigation.ts @@ -103,6 +103,7 @@ export const createPageInputSchema = z.object({ content: z.string().min(1), seoTitle: z.string().max(180).nullable().optional(), seoDescription: z.string().max(320).nullable().optional(), + scheduledPublishAt: z.date().nullable().optional(), }) export const updatePageInputSchema = z.object({ @@ -114,6 +115,7 @@ export const updatePageInputSchema = z.object({ content: z.string().min(1).optional(), seoTitle: z.string().max(180).nullable().optional(), seoDescription: z.string().max(320).nullable().optional(), + scheduledPublishAt: z.date().nullable().optional(), }) export const upsertPageTranslationInputSchema = z.object({ diff --git a/packages/db/prisma/migrations/20260213020500_page_scheduled_publish/migration.sql b/packages/db/prisma/migrations/20260213020500_page_scheduled_publish/migration.sql new file mode 100644 index 0000000..eec0565 --- /dev/null +++ b/packages/db/prisma/migrations/20260213020500_page_scheduled_publish/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "Page" +ADD COLUMN "scheduledPublishAt" TIMESTAMP(3); + +CREATE INDEX "Page_scheduledPublishAt_idx" ON "Page"("scheduledPublishAt"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index d81dfda..201ee8a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -291,6 +291,7 @@ model Page { seoTitle String? seoDescription String? publishedAt DateTime? + scheduledPublishAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt navItems NavigationItem[] diff --git a/packages/db/src/pages-navigation.ts b/packages/db/src/pages-navigation.ts index 6f8e079..74a277d 100644 --- a/packages/db/src/pages-navigation.ts +++ b/packages/db/src/pages-navigation.ts @@ -29,6 +29,15 @@ function resolvePublishedAt(status: string): Date | null { return status === "published" ? new Date() : null } +function resolvePublicPageWhere(slug?: string) { + const now = new Date() + + return { + ...(slug ? { slug } : {}), + OR: [{ status: "published" }, { scheduledPublishAt: { lte: now } }], + } +} + export async function listPages(limit = 50) { return db.page.findMany({ orderBy: [{ updatedAt: "desc" }], @@ -38,7 +47,7 @@ export async function listPages(limit = 50) { export async function listPublishedPageSlugs() { const pages = await db.page.findMany({ - where: { status: "published" }, + where: resolvePublicPageWhere(), orderBy: { updatedAt: "desc" }, select: { slug: true, @@ -57,19 +66,13 @@ export async function getPageById(id: string) { export async function getPublishedPageBySlug(slug: string) { return db.page.findFirst({ - where: { - slug, - status: "published", - }, + where: resolvePublicPageWhere(slug), }) } export async function getPublishedPageBySlugForLocale(slug: string, locale: string) { const page = await db.page.findFirst({ - where: { - slug, - status: "published", - }, + where: resolvePublicPageWhere(slug), include: { translations: { where: { @@ -98,11 +101,13 @@ export async function getPublishedPageBySlugForLocale(slug: string, locale: stri export async function createPage(input: unknown) { const payload = createPageInputSchema.parse(input) + const isImmediatelyPublished = payload.status === "published" return db.page.create({ data: { ...payload, publishedAt: resolvePublishedAt(payload.status), + scheduledPublishAt: isImmediatelyPublished ? null : (payload.scheduledPublishAt ?? null), }, }) } @@ -110,6 +115,7 @@ export async function createPage(input: unknown) { export async function updatePage(input: unknown) { const payload = updatePageInputSchema.parse(input) const { id, ...data } = payload + const isImmediatelyPublished = data.status === "published" return db.page.update({ where: { id }, @@ -117,6 +123,12 @@ export async function updatePage(input: unknown) { ...data, publishedAt: data.status === undefined ? undefined : data.status === "published" ? new Date() : null, + scheduledPublishAt: + data.scheduledPublishAt === undefined + ? undefined + : isImmediatelyPublished + ? null + : data.scheduledPublishAt, }, }) }