feat(products): implement JSON import with auto-provisioning, UI modal, and documentation schema

This commit is contained in:
Your Name committed 2026-09-24 20:01:24 +07:00
1 parent 63e3a2895d
commit 101143f26a
5 files changed
+768 -187

No files matched your search

+282 -8
View File
@@ -12,12 +12,16 @@ import {
X,
UploadCloud,
Loader2,
LayoutTemplate
LayoutTemplate,
FileJson,
Download,
CheckCircle2
} from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types'
interface PaginationMeta {
total: number;
page: number;
@@ -46,6 +50,146 @@ export default function ProductsPage() {
const limitParam = Number(searchParams.get('limit'))
const limit = validLimits.includes(limitParam) ? limitParam : 10
const [meta, setMeta] = useState<PaginationMeta | null>(null)
// JSON Import State
const [isImportModalOpen, setIsImportModalOpen] = useState(false)
const [importJsonText, setImportJsonText] = useState('')
const [importLoading, setImportLoading] = useState(false)
const [importError, setImportError] = useState<string | null>(null)
const [importSuccessMessage, setImportSuccessMessage] = useState<string | null>(null)
const sampleProductsJson = [
{
"name": "น้ำมันหอมระเหย ลาเวนเดอร์ 100% Pure Essential Oil",
"categoryName": "Aroma Oil",
"brandName": "HomAroma",
"description": "น้ำมันหอมระเหยบริสุทธิ์แท้ 100% สกัดจากดอกลาเวนเดอร์ประเทศฝรั่งเศส ช่วยผ่อนคลายและหลับสบาย",
"details": "วิธีใช้: หยด 3-5 หยดลงในเครื่องพ่นอโรมา\nขนาด: 10ml, 30ml",
"options": [
{
"name": "ขนาด",
"values": ["10ml", "30ml"]
}
],
"variants": [
{
"option1": "10ml",
"price": 390,
"stock": 50,
"sku": "HA-LAV-10ML",
"barcode": "8859123456701"
},
{
"option1": "30ml",
"price": 890,
"stock": 30,
"sku": "HA-LAV-30ML",
"barcode": "8859123456702"
}
],
"images": [
"https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?w=800"
],
"translations": {
"th": {
"name": "น้ำมันหอมระเหย ลาเวนเดอร์ 100% Pure Essential Oil",
"description": "น้ำมันหอมระเหยบริสุทธิ์แท้ 100% สกัดจากดอกลาเวนเดอร์ประเทศฝรั่งเศส ช่วยผ่อนคลายและหลับสบาย"
},
"en": {
"name": "Lavender Pure Essential Oil 100%",
"description": "100% pure natural French lavender essential oil for relaxation and sleep."
}
}
},
{
"name": "ก้านไม้หอมปรับอากาศ กลิ่นมะลิ (Reed Diffuser Jasmine)",
"categoryName": "Reed Diffuser",
"brandName": "HomAroma",
"description": "ก้านไม้กระจายความหอมกลิ่นมะลิไทยแท้ หอมละมุนยาวนาน 4-6 สัปดาห์",
"details": "ปริมาตร: 100ml\nรวมก้านไม้ไฟเบอร์ 6 ก้าน",
"price": 490,
"stock": 100,
"sku": "HA-RD-JAS-100",
"barcode": "8859123456801",
"images": [
"https://images.unsplash.com/photo-1617897903246-719242758050?w=800"
]
}
]
const handleDownloadSampleJson = () => {
const jsonStr = JSON.stringify(sampleProductsJson, null, 2)
const blob = new Blob([jsonStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'products-template.json'
link.click()
URL.revokeObjectURL(url)
}
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const content = event.target?.result as string
setImportJsonText(content)
setImportError(null)
}
reader.readAsText(file)
}
const handleImportSubmit = async () => {
setImportError(null)
setImportSuccessMessage(null)
if (!importJsonText.trim()) {
setImportError('กรุณากรอกหรือเลือกไฟล์ JSON')
return
}
let parsedData: any
try {
parsedData = JSON.parse(importJsonText)
} catch (err: any) {
setImportError(`รูปแบบ JSON ไม่ถูกต้อง (Syntax Error): ${err.message}`)
return
}
const payloadArray = Array.isArray(parsedData) ? parsedData : [parsedData]
if (payloadArray.length === 0) {
setImportError('ไม่พบรายการสินค้าใน JSON')
return
}
setImportLoading(true)
try {
const res = await fetch(`${API_BASE}/products/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payloadArray)
})
const data = await res.json()
if (res.ok && data.success) {
setImportSuccessMessage(`นำเข้าสินค้าสำเร็จทั้งหมด ${data.count || payloadArray.length} รายการ!`)
fetchData()
setTimeout(() => {
setIsImportModalOpen(false)
setImportJsonText('')
setImportSuccessMessage(null)
}, 1500)
} else {
setImportError(data.message || 'เกิดข้อผิดพลาดในการนำเข้าสินค้า')
}
} catch (err: any) {
console.error(err)
setImportError(err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อเซิร์ฟเวอร์')
} finally {
setImportLoading(false)
}
}
const fixUrl = (url: string) => {
if (!url) return ''
@@ -317,15 +461,26 @@ export default function ProductsPage() {
{meta ? `Displaying ${products.length} of ${meta.total} products` : t('common.loading', 'Loading products...')}
</p>
</div>
<button
className="btn-primary flex items-center gap-2 cursor-pointer"
onClick={() => navigate('/products/new')}
>
<Plus className="w-4 h-4" />
{t('products.addNew', 'Add Product')}
</button>
<div className="flex items-center gap-3">
<button
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 font-semibold text-sm transition-all cursor-pointer shadow-sm group"
onClick={() => setIsImportModalOpen(true)}
title="นำเข้าสินค้าผ่าน JSON"
>
<FileJson className="w-4 h-4 text-emerald-400 group-hover:scale-110 transition-transform" />
<span>Import JSON</span>
</button>
<button
className="btn-primary flex items-center gap-2 cursor-pointer"
onClick={() => navigate('/products/new')}
>
<Plus className="w-4 h-4" />
{t('products.addNew', 'Add Product')}
</button>
</div>
</div>
{/* Filters Bar */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex-1 w-full relative group">
@@ -751,6 +906,125 @@ export default function ProductsPage() {
</div>
)}
{/* JSON Import Modal */}
{isImportModalOpen && (
<div className="fixed inset-0 z-50 bg-night-950/80 backdrop-blur-md flex items-center justify-center p-4">
<div className="w-full max-w-2xl bg-night-900 border border-white/10 rounded-3xl shadow-2xl p-6 space-y-5 animate-scale-up">
{/* Header */}
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-emerald-500/20 border border-emerald-500/30 flex items-center justify-center text-emerald-400">
<FileJson className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-base text-white">นำเข้าสินค้าผ่าน JSON (JSON Import)</h3>
<p className="text-xs text-slate-400">รองรับทั้งสินค้าเดี่ยว (Simple) และสินค้าหลายตัวเลือก (Variants)</p>
</div>
</div>
<button
onClick={() => {
setIsImportModalOpen(false)
setImportError(null)
setImportSuccessMessage(null)
}}
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-white/10 cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Actions Bar */}
<div className="flex items-center justify-between gap-3 text-xs">
<label className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 cursor-pointer transition-colors">
<UploadCloud className="w-4 h-4 text-cyan-glow" />
<span>อัปโหลดไฟล์ .json</span>
<input type="file" accept=".json,application/json" onChange={handleFileUpload} className="hidden" />
</label>
<button
onClick={handleDownloadSampleJson}
className="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-emerald-400 border border-emerald-500/20 hover:border-emerald-500/40 cursor-pointer transition-colors"
title="ดาวน์โหลดโครงสร้าง JSON ตัวอย่างสำหรับเป็นแบบพิมพ์"
>
<Download className="w-4 h-4" />
<span>ดาวน์โหลด Template JSON</span>
</button>
</div>
{/* JSON Textarea */}
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs text-slate-400">
<span>วางโค้ด JSON ที่นี่ (JSON Array):</span>
{importJsonText && (
<button
onClick={() => setImportJsonText('')}
className="text-slate-500 hover:text-rose-400 text-[11px] cursor-pointer"
>
ล้างข้อความ
</button>
)}
</div>
<textarea
value={importJsonText}
onChange={e => {
setImportJsonText(e.target.value)
setImportError(null)
}}
rows={10}
placeholder={`[\n {\n "name": "สินค้าตัวอย่าง",\n "categoryName": "Aroma Oil",\n "price": 290,\n "stock": 50,\n "sku": "SKU-001"\n }\n]`}
className="w-full bg-night-950 border border-white/10 rounded-2xl p-4 font-mono text-xs text-slate-200 outline-none focus:border-emerald-500/50 leading-relaxed resize-none"
/>
</div>
{/* Error / Success Feedback */}
{importError && (
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs flex items-start gap-2">
<X className="w-4 h-4 shrink-0 mt-0.5" />
<span>{importError}</span>
</div>
)}
{importSuccessMessage && (
<div className="p-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs flex items-center gap-2 font-medium">
<CheckCircle2 className="w-4 h-4 shrink-0" />
<span>{importSuccessMessage}</span>
</div>
)}
{/* Modal Footer */}
<div className="flex items-center justify-end gap-3 pt-2 border-t border-white/10">
<button
onClick={() => {
setIsImportModalOpen(false)
setImportError(null)
}}
className="px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white text-xs font-semibold cursor-pointer"
>
ยกเลิก
</button>
<button
onClick={handleImportSubmit}
disabled={importLoading || !importJsonText.trim()}
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-400 hover:to-teal-400 text-night-950 font-bold text-xs shadow-lg shadow-emerald-500/20 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer transition-all"
>
{importLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>กำลังนำเข้าสินค้า...</span>
</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
<span>ยืนยันนำเข้าสินค้า</span>
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
)
}
+167 -179
View File
@@ -33,6 +33,170 @@ async function assertActiveCategory(categoryId: number, client: any = prisma) {
}
}
async function createSingleProductTx(tx: any, payload: any, validateVariants: any) {
// 1. Resolve Category (by categoryId, categoryName, or fallback to first available)
let categoryId = payload.categoryId ? Number(payload.categoryId) : undefined;
if (!categoryId && payload.categoryName) {
let cat = await tx.category.findFirst({
where: { name: payload.categoryName, deletedAt: null },
});
if (!cat) {
cat = await tx.category.create({ data: { name: payload.categoryName } });
}
categoryId = cat.id;
}
if (!categoryId) {
const firstCat = await tx.category.findFirst({ where: { deletedAt: null } });
if (firstCat) {
categoryId = firstCat.id;
} else {
const defCat = await tx.category.create({ data: { name: "General" } });
categoryId = defCat.id;
}
}
await assertActiveCategory(categoryId, tx);
// 2. Resolve Brand (by brandId, brandName, or fallback to first available)
let brandId = payload.brandId ? Number(payload.brandId) : undefined;
if (!brandId && payload.brandName) {
let brand = await tx.productBrand.findUnique({ where: { name: payload.brandName } });
if (!brand) {
brand = await tx.productBrand.create({ data: { name: payload.brandName } });
}
brandId = brand.id;
}
if (!brandId) {
const firstBrand = await tx.productBrand.findFirst();
if (firstBrand) {
brandId = firstBrand.id;
} else {
const defBrand = await tx.productBrand.create({ data: { name: "HomAroma" } });
brandId = defBrand.id;
}
}
// 3. Normalize Name, Description, Details
const rawName = payload.name;
const pName = typeof rawName === 'string' ? rawName : (rawName?.th || rawName?.en || "New Product");
const pDesc = typeof payload.description === 'string' ? payload.description : (payload.description?.th || payload.description?.en || "");
const pDetails = typeof payload.details === 'string' ? payload.details : (payload.details?.th || payload.details?.en || "");
// 4. Resolve Variants and Options
let variants = Array.isArray(payload.variants) ? payload.variants : [];
let options = Array.isArray(payload.options) ? payload.options : [];
// Simple Product fallback: if no variants provided, but root price/stock/sku are given
if (variants.length === 0) {
variants = [{
option1: "",
option2: "",
option3: "",
price: payload.price != null ? Number(payload.price) : 0,
stock: payload.stock != null ? Number(payload.stock) : 0,
sku: payload.sku || null,
barcode: payload.barcode || null,
}];
} else if (options.length > 0) {
validateVariants(options, variants);
}
// 5. Create Product record
const prod = await tx.product.create({
data: {
categoryId,
brandId,
name: pName,
description: pDesc,
details: pDetails,
options,
}
});
// 6. Handle Translations
const transObj = payload.translations && typeof payload.translations === 'object' ? { ...payload.translations } : {};
if (typeof rawName === 'object' && rawName !== null) {
for (const [lang, val] of Object.entries(rawName)) {
if (!transObj[lang]) transObj[lang] = {};
transObj[lang].name = val;
}
}
if (typeof payload.description === 'object' && payload.description !== null) {
for (const [lang, val] of Object.entries(payload.description)) {
if (!transObj[lang]) transObj[lang] = {};
transObj[lang].description = val;
}
}
if (typeof payload.details === 'object' && payload.details !== null) {
for (const [lang, val] of Object.entries(payload.details)) {
if (!transObj[lang]) transObj[lang] = {};
transObj[lang].details = val;
}
}
if (Object.keys(transObj).length > 0) {
for (const [lang, trans] of Object.entries(transObj)) {
await tx.productTranslation.create({
data: {
productId: prod.id,
langCode: lang,
name: (trans as any).name || pName,
description: (trans as any).description || pDesc,
details: (trans as any).details || pDetails,
}
});
}
} else {
await tx.productTranslation.create({
data: {
productId: prod.id,
langCode: 'th',
name: pName,
description: pDesc,
details: pDetails,
}
});
}
// 7. Create Variants
for (const v of variants) {
await tx.productVariant.create({
data: {
productId: prod.id,
sku: v.sku || null,
barcode: v.barcode || null,
stock: v.stock != null ? Number(v.stock) : 0,
option1: v.option1 || "",
option2: v.option2 || "",
option3: v.option3 || "",
price: v.price != null ? Number(v.price) : 0,
}
});
}
// 8. Create Images (support both ["http..."] and [{ url: "http..." }])
if (payload.images && Array.isArray(payload.images)) {
for (const [idx, img] of payload.images.entries()) {
const url = typeof img === 'string' ? img : img?.url;
if (url) {
await tx.productImage.create({
data: {
productId: prod.id,
url,
order: typeof img === 'object' && typeof img?.order === 'number' ? img.order : idx,
}
});
}
}
}
// 9. Auto-seed Product Detail Sections for CMS / Page Builder
await tx.productDetailSection.createMany({
data: defaultProductDetailSections(prod.id),
});
return prod;
}
export const productsRoute = new Elysia({ prefix: "/products" })
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
if (options.length > 3) throw new Error("Options array length cannot exceed 3.");
@@ -711,96 +875,8 @@ export const productsRoute = new Elysia({ prefix: "/products" })
async ({ body, status, validateVariants }) => {
const payload = body as any;
try {
if (payload.variants && Array.isArray(payload.variants)) {
validateVariants(payload.options || [], payload.variants);
}
const product = await prisma.$transaction(async (tx) => {
let categoryId = payload.categoryId;
if (!categoryId && payload.categoryName) {
let cat = await tx.category.findFirst({
where: { name: payload.categoryName, deletedAt: null },
});
if (!cat) {
cat = await tx.category.create({ data: { name: payload.categoryName } });
}
categoryId = cat.id;
}
if (categoryId) await assertActiveCategory(categoryId, tx);
let brandId = payload.brandId;
if (!brandId && payload.brandName) {
let brand = await tx.productBrand.findUnique({ where: { name: payload.brandName } });
if (!brand) {
brand = await tx.productBrand.create({ data: { name: payload.brandName } });
}
brandId = brand.id;
}
const prod = await tx.product.create({
data: {
categoryId: categoryId,
brandId: brandId,
name: payload.name || "New Product",
description: payload.description || "",
details: payload.details || "",
options: payload.options || [],
}
});
if (payload.translations && typeof payload.translations === 'object') {
for (const [lang, trans] of Object.entries(payload.translations)) {
await tx.productTranslation.create({
data: {
productId: prod.id,
langCode: lang,
name: (trans as any).name || payload.name || "New Product",
description: (trans as any).description || payload.description || "",
details: (trans as any).details || payload.details || "",
}
});
}
} else {
await tx.productTranslation.create({
data: {
productId: prod.id,
langCode: 'th',
name: payload.name || "New Product",
description: payload.description || "",
details: payload.details || "",
}
});
}
if (payload.variants && payload.variants.length > 0) {
for (const v of payload.variants) {
await tx.productVariant.create({
data: {
productId: prod.id,
sku: v.sku || null,
barcode: v.barcode || null,
stock: v.stock || 0,
option1: v.option1 || "",
option2: v.option2 || "",
option3: v.option3 || "",
price: v.price || 0,
}
});
}
}
if (payload.images && payload.images.length > 0) {
for (const [idx, img] of payload.images.entries()) {
await tx.productImage.create({
data: {
productId: prod.id,
url: img.url,
order: img.order ?? idx,
}
});
}
}
return prod;
return await createSingleProductTx(tx, payload, validateVariants);
});
return { success: true, product };
@@ -831,102 +907,14 @@ export const productsRoute = new Elysia({ prefix: "/products" })
try {
const createdProducts = await prisma.$transaction(async (tx) => {
const results = [];
for (const payload of payloadArray) {
if (payload.variants && Array.isArray(payload.variants)) {
validateVariants(payload.options || [], payload.variants);
}
let categoryId = payload.categoryId;
if (!categoryId && payload.categoryName) {
let cat = await tx.category.findFirst({
where: { name: payload.categoryName, deletedAt: null },
});
if (!cat) {
cat = await tx.category.create({ data: { name: payload.categoryName } });
}
categoryId = cat.id;
}
if (categoryId) await assertActiveCategory(categoryId, tx);
let brandId = payload.brandId;
if (!brandId && payload.brandName) {
let brand = await tx.productBrand.findUnique({ where: { name: payload.brandName } });
if (!brand) {
brand = await tx.productBrand.create({ data: { name: payload.brandName } });
}
brandId = brand.id;
}
const prod = await tx.product.create({
data: {
categoryId: categoryId,
brandId: brandId,
name: payload.name || "New Product",
description: payload.description || "",
details: payload.details || "",
options: payload.options || [],
}
});
if (payload.translations && typeof payload.translations === 'object') {
for (const [lang, trans] of Object.entries(payload.translations)) {
await tx.productTranslation.create({
data: {
productId: prod.id,
langCode: lang,
name: (trans as any).name || payload.name || "New Product",
description: (trans as any).description || payload.description || "",
details: (trans as any).details || payload.details || "",
}
});
}
} else {
await tx.productTranslation.create({
data: {
productId: prod.id,
langCode: 'th',
name: payload.name || "New Product",
description: payload.description || "",
details: payload.details || "",
}
});
}
if (payload.variants && payload.variants.length > 0) {
for (const v of payload.variants) {
await tx.productVariant.create({
data: {
productId: prod.id,
sku: v.sku || null,
barcode: v.barcode || null,
stock: v.stock || 0,
option1: v.option1 || "",
option2: v.option2 || "",
option3: v.option3 || "",
price: v.price || 0,
}
});
}
}
if (payload.images && payload.images.length > 0) {
for (const [idx, img] of payload.images.entries()) {
await tx.productImage.create({
data: {
productId: prod.id,
url: img.url,
order: img.order ?? idx,
}
});
}
}
const prod = await createSingleProductTx(tx, payload, validateVariants);
results.push(prod);
}
return results;
});
return { success: true, products: createdProducts };
return { success: true, count: createdProducts.length, products: createdProducts };
} catch (err: any) {
if (err?.code === "CATEGORY_NOT_FOUND") {
return status(400, { success: false, message: "Category not found" });