179 lines
5.6 KiB
TypeScript
179 lines
5.6 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useEffect, useState } from "react";
|
|||
|
|
import { ProductContextForm } from "@/components/product-context-form";
|
|||
|
|
import { Button } from "@/components/ui/button";
|
|||
|
|
import {
|
|||
|
|
Dialog,
|
|||
|
|
DialogContent,
|
|||
|
|
DialogDescription,
|
|||
|
|
DialogFooter,
|
|||
|
|
DialogHeader,
|
|||
|
|
DialogTitle,
|
|||
|
|
} from "@/components/ui/dialog";
|
|||
|
|
import { Input } from "@/components/ui/input";
|
|||
|
|
import { Label } from "@/components/ui/label";
|
|||
|
|
import { InlineAlert } from "@/components/ui/inline-alert";
|
|||
|
|
import type { ActionFeedback } from "@/lib/use-action-feedback";
|
|||
|
|
import { parseFetchJson } from "@/lib/utils";
|
|||
|
|
import { formatMatchTagsInput, parseMatchTagsInput } from "@/lib/types/product-match";
|
|||
|
|
import { hasProductContext, parseProductContext, serializeProductContext } from "@/lib/types/product-context";
|
|||
|
|
|
|||
|
|
export interface ProductItemRow {
|
|||
|
|
id: string;
|
|||
|
|
label: string;
|
|||
|
|
context: string;
|
|||
|
|
matchTags: string[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interface ProductItemFormDialogProps {
|
|||
|
|
open: boolean;
|
|||
|
|
onOpenChange: (open: boolean) => void;
|
|||
|
|
brandId: string;
|
|||
|
|
brandName: string;
|
|||
|
|
product?: ProductItemRow | null;
|
|||
|
|
onSaved?: (product: ProductItemRow) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function ProductItemFormDialog({
|
|||
|
|
open,
|
|||
|
|
onOpenChange,
|
|||
|
|
brandId,
|
|||
|
|
brandName,
|
|||
|
|
product,
|
|||
|
|
onSaved,
|
|||
|
|
}: ProductItemFormDialogProps) {
|
|||
|
|
const [label, setLabel] = useState("");
|
|||
|
|
const [context, setContext] = useState("");
|
|||
|
|
const [matchTagsInput, setMatchTagsInput] = useState("");
|
|||
|
|
const [submitting, setSubmitting] = useState(false);
|
|||
|
|
const [formFeedback, setFormFeedback] = useState<ActionFeedback | null>(null);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!open) return;
|
|||
|
|
setLabel(product?.label ?? "");
|
|||
|
|
setContext(product?.context ?? "");
|
|||
|
|
setMatchTagsInput(product ? formatMatchTagsInput(product.matchTags) : "");
|
|||
|
|
setFormFeedback(null);
|
|||
|
|
}, [open, product]);
|
|||
|
|
|
|||
|
|
async function handleSubmit(e: React.FormEvent) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
if (!label.trim()) {
|
|||
|
|
setFormFeedback({ type: "warning", title: "請填寫產品名稱", message: "產品名稱為必填。" });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fields = parseProductContext(context);
|
|||
|
|
const merged = serializeProductContext({
|
|||
|
|
...fields,
|
|||
|
|
brand: brandName,
|
|||
|
|
product: fields.product?.trim() || label.trim(),
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!hasProductContext(merged)) {
|
|||
|
|
setFormFeedback({ type: "warning", title: "請填寫產品特色", message: "至少需填寫一項產品特色。" });
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const matchTags = parseMatchTagsInput(matchTagsInput);
|
|||
|
|
|
|||
|
|
setFormFeedback(null);
|
|||
|
|
setSubmitting(true);
|
|||
|
|
const res = await fetch("/api/brands/products", {
|
|||
|
|
method: product ? "PATCH" : "POST",
|
|||
|
|
headers: { "Content-Type": "application/json" },
|
|||
|
|
body: JSON.stringify(
|
|||
|
|
product
|
|||
|
|
? { id: product.id, label: label.trim(), context: merged, matchTags }
|
|||
|
|
: { brandId, label: label.trim(), context: merged, matchTags }
|
|||
|
|
),
|
|||
|
|
});
|
|||
|
|
let data: {
|
|||
|
|
error?: string;
|
|||
|
|
product?: { id: string; label: string; context: string; matchTags?: string[] };
|
|||
|
|
} = {};
|
|||
|
|
try {
|
|||
|
|
data = await parseFetchJson(res);
|
|||
|
|
} catch (err) {
|
|||
|
|
setSubmitting(false);
|
|||
|
|
setFormFeedback({
|
|||
|
|
type: "error",
|
|||
|
|
title: "儲存失敗",
|
|||
|
|
message: err instanceof Error ? err.message : "伺服器回應異常",
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setSubmitting(false);
|
|||
|
|
|
|||
|
|
if (!res.ok || !data.product) {
|
|||
|
|
setFormFeedback({
|
|||
|
|
type: "error",
|
|||
|
|
title: "儲存失敗",
|
|||
|
|
message: data.error ?? "伺服器未回傳產品資料",
|
|||
|
|
});
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const saved: ProductItemRow = {
|
|||
|
|
id: data.product.id,
|
|||
|
|
label: data.product.label,
|
|||
|
|
context: data.product.context,
|
|||
|
|
matchTags: data.product.matchTags ?? matchTags,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
onSaved?.(saved);
|
|||
|
|
onOpenChange(false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|||
|
|
<DialogContent className="w-[min(100vw-2rem,36rem)]">
|
|||
|
|
<DialogHeader>
|
|||
|
|
<DialogTitle>{product ? "編輯產品" : "新增產品"}</DialogTitle>
|
|||
|
|
<DialogDescription>
|
|||
|
|
品牌:{brandName} · 填寫適用標籤後,海巡生成回覆時會自動推薦對應產品。
|
|||
|
|
</DialogDescription>
|
|||
|
|
</DialogHeader>
|
|||
|
|
|
|||
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|||
|
|
{formFeedback && (
|
|||
|
|
<InlineAlert
|
|||
|
|
type={formFeedback.type}
|
|||
|
|
title={formFeedback.title}
|
|||
|
|
message={formFeedback.message}
|
|||
|
|
onDismiss={() => setFormFeedback(null)}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
<div className="space-y-1.5">
|
|||
|
|
<Label>產品名稱</Label>
|
|||
|
|
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="溫和洗毛精" autoFocus />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="space-y-1.5">
|
|||
|
|
<Label>適用標籤(AI 推薦用)</Label>
|
|||
|
|
<Input
|
|||
|
|
value={matchTagsInput}
|
|||
|
|
onChange={(e) => setMatchTagsInput(e.target.value)}
|
|||
|
|
placeholder="洗毛精、敏感肌、狗洗澡(頓號或逗號分隔)"
|
|||
|
|
/>
|
|||
|
|
<p className="text-[11px] text-muted-foreground">
|
|||
|
|
與海巡標籤越接近,越優先推薦此產品置入。
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ProductContextForm value={context} onChange={setContext} compact productOnly />
|
|||
|
|
|
|||
|
|
<DialogFooter>
|
|||
|
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
|||
|
|
取消
|
|||
|
|
</Button>
|
|||
|
|
<Button type="submit" disabled={submitting}>
|
|||
|
|
{submitting ? "儲存中…" : "儲存"}
|
|||
|
|
</Button>
|
|||
|
|
</DialogFooter>
|
|||
|
|
</form>
|
|||
|
|
</DialogContent>
|
|||
|
|
</Dialog>
|
|||
|
|
);
|
|||
|
|
}
|