實習通 工程規格 介面示意 Mockup
給工程同事

工程規格與改動清單

以下對應現有 internx-me/frontend 的檔案。這是建議的最小改動路徑,不是要求重寫。

1 · 票券資料模型(核心)

目前票券只有名稱與價格,缺少「販售時間」與「數量」。建議把 feeItems{ name, price } 擴充為完整票券物件。

現況 · lib/form-schema/activity-form-schema.ts:328
feeItems: Array<{ name: string; price: number }> — 無時間、無數量,故早鳥過期仍可選、無法限量。

// 建議的票券型別(新增 data/ticket.ts,或擴充 activity.ts)
interface Ticket {
  id: string;
  name: string;            // 票種名稱
  price: number;           // 0 = 免費
  quantity: number | null;  // + 數量上限,null = 不限
  sold: number;             // + 已售(後端維護,前端唯讀)
  saleStart: Timestamp;     // + 販售開始
  saleEnd: Timestamp;       // + 販售結束
  description?: string;
  order: number;           // 排序用
}

狀態由系統計算,不另存欄位

報名端與編輯端共用同一個函式,避免主辦方手動上下架。

function ticketStatus(t: Ticket, now = new Date()) {
  if (t.quantity != null && t.sold >= t.quantity) return 'soldout';  // 已售完
  if (now < t.saleStart) return 'soon';                            // 尚未開賣
  if (now > t.saleEnd)   return 'ended';                           // 販售已截止
  return 'live';                                                  // 販售中(唯一可購買)
}

報名送出時後端需再驗一次 ticketStatus === 'live'sold < quantity,避免前端被繞過或併發超賣。

2 · 表單驗證調整

activity-form-schema.tsfeeItems.validation 需加上時間與數量檢查:

規則訊息
saleStart < saleEnd販售開始需早於結束時間
quantity == null || quantity >= 1數量上限需 ≥ 1,或留空表示不限
price >= 0(現有)價格不可為負
付費活動至少 1 張票請至少新增一個票種

原本獨立的 feeAmount(單一費用)與 參加名額上限(schema:371)可由票券模型取代;保留與否視既有報名資料相容性決定。

3 · 報名表單排序:用拖曳(@dnd-kit)

現況 · components/Activities/FormBuilder/FormBuilder.tsx:88
已有 handleReorderFields(fromIndex, toIndex),但拖曳是自製的、不穩、手機難用,所以才難實作。

改法:保留既有的 handleReorderFields,改用成熟套件 @dnd-kit 來驅動拖曳。內建指標 / 觸控 / 鍵盤支援,工程端不用自己處理拖曳事件,抓把手就能順暢拖曳

npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
// FormPreview:用 SortableContext 包欄位,handleReorderFields 不用改
<DndContext onDragEnd={({ active, over }) => {
  if (over && active.id !== over.id) {
    const from = fields.findIndex(f => f.id === active.id);
    const to   = fields.findIndex(f => f.id === over.id);
    handleReorderFields(from, to);   // 既有函式直接重用
  }
}}>
  <SortableContext items={fields.map(f => f.id)}>
    {fields.map(f => <SortableFieldRow key={f.id} field={f} />)}
  </SortableContext>
</DndContext>
// SortableFieldRow 用 useSortable({id}) 取得把手 listeners,套到 grip 圖示上。

系統必填欄位(姓名 / Email)標記 locked:不給拖曳把手、固定最上方,並用 dnd-kit 的 modifiers 限制不可被拖到其上方。完整步驟見 INTEGRATION.md

4 · 改動檔案清單

檔案動作說明
data/ticket.ts新增Ticket 型別 + ticketStatus(),編輯/報名端共用
data/activity.ts擴充活動關聯 tickets[](取代或相容 feeItems)
lib/form-schema/activity-form-schema.tsfeeItems 改為票券物件、新增驗證(:328、:371)
components/Activities/AddActivity.tsx沿用維持步驟式結構;票券步驟換成新的編輯器卡片
components/Activities/FormBuilder/FormPreview.tsx每列加上 ↑ ↓ 按鈕(呼叫既有 reorder)
components/Payments/Payments.tsx沿用金流邏輯不變,僅金額來源改自所選票券
data/registration.ts擴充報名記錄需存 ticketId 與張數

5 · 驗收標準

早鳥票販售結束時間過後,報名端自動鎖定、不可選。
每種票可獨立設數量,售完自動標記,且後端防超賣。
表單欄位可用拖曳排序(@dnd-kit),系統必填欄位固定最上方。
編輯器分四步、欄位固定寬度,輸入時版面不跳動。
視覺沿用既有 InternX token(#0182fd / Poppins + Noto Sans TC)。

本交接檔為獨立 repo,與 internx.me 程式碼分離;可作為 PR 描述與設計依據附在工程任務上。