+ )
+}
diff --git a/src/components/seed/SeedProjectStatusesButton.tsx b/src/components/seed/SeedProjectStatusesButton.tsx
new file mode 100644
index 0000000..f81f68f
--- /dev/null
+++ b/src/components/seed/SeedProjectStatusesButton.tsx
@@ -0,0 +1,150 @@
+'use client'
+
+import React, { useState } from 'react'
+import { Button, useConfig, useDocumentInfo } from '@payloadcms/ui'
+
+// ─── helpers ──────────────────────────────────────────────────────────────────
+
+/** Wrap plain text in minimal Lexical JSON so richText fields accept it */
+function lexicalParagraph(text: string) {
+ return {
+ root: {
+ type: 'root',
+ format: '',
+ indent: 0,
+ version: 1,
+ children: [
+ {
+ type: 'paragraph',
+ format: '',
+ indent: 0,
+ version: 1,
+ children: [{ type: 'text', format: 0, text, version: 1 }],
+ },
+ ],
+ },
+ }
+}
+
+// ─── sample data ──────────────────────────────────────────────────────────────
+
+const SAMPLE_STATUSES = [
+ {
+ title: 'In Development',
+ badge: 'Dev',
+ description: lexicalParagraph(
+ 'Hardware prototyping and firmware development are actively in progress.',
+ ),
+ order: 1,
+ },
+ {
+ title: 'Crowdfunding Live',
+ badge: 'Live',
+ description: lexicalParagraph(
+ 'The crowdfunding campaign is now live and open to backers.',
+ ),
+ order: 2,
+ },
+ {
+ title: 'Mass Production',
+ badge: 'Producing',
+ description: lexicalParagraph(
+ 'Funding goal reached. Manufacturing has begun and is on schedule.',
+ ),
+ order: 3,
+ },
+ {
+ title: 'Shipping',
+ badge: 'Shipping',
+ description: lexicalParagraph(
+ 'Orders are being fulfilled and packages are on their way to backers.',
+ ),
+ order: 4,
+ },
+]
+
+// ─── component ────────────────────────────────────────────────────────────────
+
+/**
+ * Displayed as a UI field above the projectStatuses array in the product form.
+ * Appends sample statuses to whatever already exists, then reloads the page.
+ */
+export function SeedProjectStatusesButton() {
+ const { id: docId, collectionSlug } = useDocumentInfo()
+ const { config } = useConfig()
+ const apiBase: string = (config as any)?.routes?.api ?? '/api'
+
+ const [loading, setLoading] = useState(false)
+ const [result, setResult] = useState('')
+
+ const handleSeed = async () => {
+ if (!docId) {
+ setResult('Save the document first before adding sample data.')
+ return
+ }
+ if (
+ !window.confirm(
+ `Append ${SAMPLE_STATUSES.length} sample project statuses to this product?\n\n` +
+ SAMPLE_STATUSES.map((s) => `• ${s.title} [${s.badge}]`).join('\n'),
+ )
+ )
+ return
+
+ setLoading(true)
+ setResult('')
+
+ try {
+ // Fetch current doc to get existing statuses so we don't overwrite them
+ const getRes = await fetch(`${apiBase}/${collectionSlug}/${docId}?depth=0`, {
+ credentials: 'include',
+ })
+ const doc = await getRes.json()
+ const existing: any[] = doc.projectStatuses ?? []
+ const baseOrder = existing.length
+
+ const merged = [
+ ...existing,
+ ...SAMPLE_STATUSES.map((s, i) => ({ ...s, order: baseOrder + i + 1 })),
+ ]
+
+ const patchRes = await fetch(`${apiBase}/${collectionSlug}/${docId}`, {
+ method: 'PATCH',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ projectStatuses: merged }),
+ })
+
+ if (patchRes.ok) {
+ setResult(`✓ Added ${SAMPLE_STATUSES.length} sample statuses`)
+ setTimeout(() => window.location.reload(), 700)
+ } else {
+ const err = await patchRes.json().catch(() => ({}))
+ setResult(`✗ ${err?.errors?.[0]?.message ?? patchRes.status}`)
+ }
+ } catch (e: any) {
+ setResult(`✗ ${e?.message ?? 'Network error'}`)
+ }
+
+ setLoading(false)
+ }
+
+ return (
+
+
+ {result && (
+
+ {result}
+
+ )}
+
+ )
+}
diff --git a/src/migrations/cleanup_precautions.ts b/src/migrations/cleanup_precautions.ts
new file mode 100644
index 0000000..f7f26e1
--- /dev/null
+++ b/src/migrations/cleanup_precautions.ts
@@ -0,0 +1,43 @@
+import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
+
+/**
+ * Final precautions cleanup:
+ * 1. Remove orphan relationship entries pointing to non-existent precaution IDs
+ * 2. Drop the leftover `type` and `content` columns — simplified schema now just
+ * uses PrecautionItemFields (title / summary / order), same as the inline array
+ */
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ -- Remove stale sharedPrecautions refs from products
+ DELETE FROM "products_rels"
+ WHERE "precautions_id" IS NOT NULL
+ AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
+
+ -- Remove stale sharedPrecautions refs from preorder-products
+ DELETE FROM "preorder_products_rels"
+ WHERE "precautions_id" IS NOT NULL
+ AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
+
+ -- Remove stale locked-document refs
+ DELETE FROM "payload_locked_documents_rels"
+ WHERE "precautions_id" IS NOT NULL
+ AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
+
+ -- Drop leftover columns from previous complex schema (no longer needed)
+ ALTER TABLE "precautions" DROP COLUMN IF EXISTS "type";
+ ALTER TABLE "precautions" DROP COLUMN IF EXISTS "content";
+
+ -- Drop the now-unused enum type
+ DROP TYPE IF EXISTS "public"."enum_precautions_type";
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ // Re-add the enum and columns if rolling back
+ await db.execute(sql`
+ CREATE TYPE IF NOT EXISTS "public"."enum_precautions_type"
+ AS ENUM('general','installation','usage','purchase','aftersale','safety');
+ ALTER TABLE "precautions" ADD COLUMN IF NOT EXISTS "type" "enum_precautions_type" DEFAULT 'general';
+ ALTER TABLE "precautions" ADD COLUMN IF NOT EXISTS "content" jsonb;
+ `)
+}
diff --git a/src/migrations/fix_precautions_id_type.ts b/src/migrations/fix_precautions_id_type.ts
deleted file mode 100644
index 184e6f0..0000000
--- a/src/migrations/fix_precautions_id_type.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
-
-/**
- * Fix precautions ID type mismatch.
- *
- * The `precautions` table was created by a manual migration with `serial`
- * (integer) PKs. Payload v3 / @payloadcms/db-postgres defaults to `text`
- * (varchar) IDs. On dev-mode startup, Payload's schema-push tries to ALTER
- * the `precautions_id` relationship columns to varchar, but the FK constraint
- * pointing to an integer PK blocks it.
- *
- * Fix: drop the precautions table and all `precautions_id` rels columns so
- * Payload's dev-mode push can recreate everything with the correct types.
- */
-export async function up({ db }: MigrateUpArgs): Promise {
- await db.execute(sql`
- -- Drop FK constraints first (payload_locked_documents_rels)
- ALTER TABLE "payload_locked_documents_rels"
- DROP CONSTRAINT IF EXISTS "payload_locked_documents_rels_precautions_fk";
-
- -- Drop the precautions_id column from payload_locked_documents_rels
- ALTER TABLE "payload_locked_documents_rels"
- DROP COLUMN IF EXISTS "precautions_id";
-
- -- Drop from products_rels (sharedPrecautions relationship)
- ALTER TABLE "products_rels"
- DROP CONSTRAINT IF EXISTS "products_rels_precautions_fk";
- ALTER TABLE "products_rels"
- DROP COLUMN IF EXISTS "precautions_id";
-
- -- Drop from preorder_products_rels (sharedPrecautions relationship)
- ALTER TABLE "preorder_products_rels"
- DROP CONSTRAINT IF EXISTS "preorder_products_rels_precautions_fk";
- ALTER TABLE "preorder_products_rels"
- DROP COLUMN IF EXISTS "precautions_id";
-
- -- Drop the precautions table itself (Payload will recreate with varchar PK)
- DROP TABLE IF EXISTS "precautions" CASCADE;
- `)
-}
-
-export async function down({ db }: MigrateDownArgs): Promise {
- // Recreating the integer-based schema is not worth reverting to since
- // the whole point is to migrate to the correct varchar-based schema.
-}
diff --git a/src/migrations/fix_stale_precaution_rels.ts b/src/migrations/fix_stale_precaution_rels.ts
new file mode 100644
index 0000000..3f32579
--- /dev/null
+++ b/src/migrations/fix_stale_precaution_rels.ts
@@ -0,0 +1,49 @@
+import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
+
+/**
+ * Nuclear cleanup: remove ALL precautions_id relationship entries from rels tables,
+ * then verify precautions table is healthy.
+ *
+ * Why: after multiple DROP+CREATE migrations, any previously-saved sharedPrecautions
+ * values in products_rels / preorder_products_rels may reference IDs from an
+ * earlier incarnation of the table. Rather than keep patching, clear the slate so
+ * users can re-link from the admin UI with the current valid IDs.
+ */
+export async function up({ db, payload }: MigrateUpArgs): Promise {
+ // Log current state before cleaning
+ const before = await db.execute(sql`
+ SELECT
+ (SELECT count(*) FROM products_rels WHERE precautions_id IS NOT NULL) AS prod_refs,
+ (SELECT count(*) FROM preorder_products_rels WHERE precautions_id IS NOT NULL) AS pre_refs,
+ (SELECT count(*) FROM precautions) AS precaution_count
+ `)
+ payload.logger.info({ msg: 'Before cleanup', state: (before as any).rows?.[0] ?? before })
+
+ // Remove all stale precautions refs (any ID not present in precautions table)
+ await db.execute(sql`
+ DELETE FROM "products_rels"
+ WHERE "precautions_id" IS NOT NULL
+ AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
+
+ DELETE FROM "preorder_products_rels"
+ WHERE "precautions_id" IS NOT NULL
+ AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
+
+ DELETE FROM "payload_locked_documents_rels"
+ WHERE "precautions_id" IS NOT NULL
+ AND "precautions_id" NOT IN (SELECT "id" FROM "precautions");
+ `)
+
+ // Log what's left
+ const after = await db.execute(sql`
+ SELECT
+ (SELECT count(*) FROM products_rels WHERE precautions_id IS NOT NULL) AS prod_refs,
+ (SELECT count(*) FROM preorder_products_rels WHERE precautions_id IS NOT NULL) AS pre_refs,
+ (SELECT count(*) FROM precautions) AS precaution_count
+ `)
+ payload.logger.info({ msg: 'After cleanup', state: (after as any).rows?.[0] ?? after })
+}
+
+export async function down(_args: MigrateDownArgs): Promise {
+ // Nothing to undo — deleted relationship rows cannot be restored
+}
diff --git a/src/migrations/index.ts b/src/migrations/index.ts
index d63efae..f95e466 100644
--- a/src/migrations/index.ts
+++ b/src/migrations/index.ts
@@ -1,5 +1,7 @@
-import * as migration_baseline from './baseline'
-import * as migration_fix_precautions_id_type from './fix_precautions_id_type'
+import * as migration_baseline from './baseline';
+import * as migration_project_statuses_description_to_jsonb from './project_statuses_description_to_jsonb';
+import * as migration_cleanup_precautions from './cleanup_precautions';
+import * as migration_fix_stale_precaution_rels from './fix_stale_precaution_rels';
export const migrations = [
{
@@ -8,9 +10,18 @@ export const migrations = [
name: 'baseline',
},
{
- up: migration_fix_precautions_id_type.up,
- down: migration_fix_precautions_id_type.down,
- name: 'fix_precautions_id_type',
+ up: migration_project_statuses_description_to_jsonb.up,
+ down: migration_project_statuses_description_to_jsonb.down,
+ name: 'project_statuses_description_to_jsonb',
},
-]
-
+ {
+ up: migration_cleanup_precautions.up,
+ down: migration_cleanup_precautions.down,
+ name: 'cleanup_precautions',
+ },
+ {
+ up: migration_fix_stale_precaution_rels.up,
+ down: migration_fix_stale_precaution_rels.down,
+ name: 'fix_stale_precaution_rels',
+ },
+];
diff --git a/src/migrations/project_statuses_description_to_jsonb.ts b/src/migrations/project_statuses_description_to_jsonb.ts
new file mode 100644
index 0000000..f0a9fe6
--- /dev/null
+++ b/src/migrations/project_statuses_description_to_jsonb.ts
@@ -0,0 +1,99 @@
+import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
+
+/**
+ * Convert projectStatuses.description from text → jsonb.
+ *
+ * Payload's schema-push cannot ALTER text → jsonb without an explicit USING
+ * clause because Postgres has no implicit cast between the two types.
+ *
+ * Strategy for existing rows:
+ * - NULL / empty → NULL
+ * - Already JSON → cast directly (description::jsonb)
+ * - Plain text → wrap in a minimal Lexical paragraph node so the
+ * content is preserved and renderable in the admin editor
+ */
+export async function up({ db }: MigrateUpArgs): Promise {
+ await db.execute(sql`
+ -- ── products_project_statuses ─────────────────────────────────────────────
+ ALTER TABLE "products_project_statuses"
+ ALTER COLUMN "description" SET DATA TYPE jsonb
+ USING CASE
+ WHEN description IS NULL OR trim(description) = ''
+ THEN NULL
+ WHEN left(trim(description), 1) = '{'
+ THEN description::jsonb
+ ELSE
+ jsonb_build_object(
+ 'root', jsonb_build_object(
+ 'type', 'root',
+ 'version', 1,
+ 'format', '',
+ 'indent', 0,
+ 'children', jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'paragraph',
+ 'version', 1,
+ 'format', '',
+ 'indent', 0,
+ 'children', jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'text',
+ 'text', description,
+ 'version', 1,
+ 'format', 0
+ )
+ )
+ )
+ )
+ )
+ )
+ END;
+
+ -- ── preorder_products_project_statuses ────────────────────────────────────
+ ALTER TABLE "preorder_products_project_statuses"
+ ALTER COLUMN "description" SET DATA TYPE jsonb
+ USING CASE
+ WHEN description IS NULL OR trim(description) = ''
+ THEN NULL
+ WHEN left(trim(description), 1) = '{'
+ THEN description::jsonb
+ ELSE
+ jsonb_build_object(
+ 'root', jsonb_build_object(
+ 'type', 'root',
+ 'version', 1,
+ 'format', '',
+ 'indent', 0,
+ 'children', jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'paragraph',
+ 'version', 1,
+ 'format', '',
+ 'indent', 0,
+ 'children', jsonb_build_array(
+ jsonb_build_object(
+ 'type', 'text',
+ 'text', description,
+ 'version', 1,
+ 'format', 0
+ )
+ )
+ )
+ )
+ )
+ )
+ END;
+ `)
+}
+
+export async function down({ db }: MigrateDownArgs): Promise {
+ await db.execute(sql`
+ ALTER TABLE "products_project_statuses"
+ ALTER COLUMN "description" SET DATA TYPE text
+ USING (description->>'root')::text;
+
+ ALTER TABLE "preorder_products_project_statuses"
+ ALTER COLUMN "description" SET DATA TYPE text
+ USING (description->>'root')::text;
+ `)
+}
diff --git a/src/payload-types.ts b/src/payload-types.ts
index 9cc9525..b1bc2c4 100644
--- a/src/payload-types.ts
+++ b/src/payload-types.ts
@@ -338,9 +338,23 @@ export interface Product {
*/
badge?: string | null;
/**
- * 状态简介
+ * 状态简介(富文本)
*/
- description?: string | null;
+ description?: {
+ root: {
+ type: string;
+ children: {
+ type: any;
+ version: number;
+ [k: string]: unknown;
+ }[];
+ direction: ('ltr' | 'rtl') | null;
+ format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
+ indent: number;
+ version: number;
+ };
+ [k: string]: unknown;
+ } | null;
/**
* 排序权重(数值越小越靠前)
*/
@@ -560,9 +574,23 @@ export interface PreorderProduct {
*/
badge?: string | null;
/**
- * 状态简介
+ * 状态简介(富文本)
*/
- description?: string | null;
+ description?: {
+ root: {
+ type: string;
+ children: {
+ type: any;
+ version: number;
+ [k: string]: unknown;
+ }[];
+ direction: ('ltr' | 'rtl') | null;
+ format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
+ indent: number;
+ version: number;
+ };
+ [k: string]: unknown;
+ } | null;
/**
* 排序权重(数值越小越靠前)
*/
diff --git a/src/payload.config.ts b/src/payload.config.ts
index a943f10..73abef2 100644
--- a/src/payload.config.ts
+++ b/src/payload.config.ts
@@ -16,7 +16,7 @@ import { DisassemblyPages } from './collections/disassembly/DisassemblyPages'
import { DisassemblyAreas } from './collections/disassembly/DisassemblyAreas'
import { DisassemblyComponents } from './collections/disassembly/DisassemblyComponents'
import { DisassemblyLinkedProducts } from './collections/disassembly/DisassemblyLinkedProducts'
-import { Precautions } from './collections/Precautions'
+import { Precautions } from './collections/project/Precautions'
import { AdminSettings } from './globals/AdminSettings'
import { LogsManager } from './globals/LogsManager'
import { HeroSlider } from './globals/HeroSlider'