diff --git a/PRODUCT_JSON_IMPORT_SCHEMA.md b/PRODUCT_JSON_IMPORT_SCHEMA.md
new file mode 100644
index 0000000..f6b5e5a
--- /dev/null
+++ b/PRODUCT_JSON_IMPORT_SCHEMA.md
@@ -0,0 +1,134 @@
+# คู่มือโครงสร้าง JSON สำหรับนำเข้าสินค้า (Product JSON Import Specification)
+
+ระบบนำเข้าสินค้าของ HomAroma รองรับการ Import ผ่านไฟล์ `.json` หรือวาง Payload JSON Array ได้โดยตรงผ่าน:
+1. **หน้าจอ Admin:** เมนู `/products` -> กดปุ่ม **"Import JSON"**
+2. **API Endpoint:** `POST /api/products/bulk` หรือ `POST /api/products`
+
+---
+
+## 1. รูปแบบโครงสร้าง JSON (JSON Schema)
+
+Payload หลักจะต้องเป็น **JSON Array** ของสินค้า โดยรองรับสินค้า 2 รูปแบบหลัก:
+
+### แบบที่ 1: สินค้าเดี่ยว (Simple Product - ไม่มีตัวเลือกย่อย)
+เหมาะสำหรับสินค้าที่มีขนาดเดียว ราคาเดียว ไม่ต้องแตก Variant:
+
+```json
+{
+ "name": "ก้านไม้หอมปรับอากาศ กลิ่นมะลิไทย (Reed Diffuser Jasmine)",
+ "categoryName": "Reed Diffuser",
+ "brandName": "HomAroma",
+ "price": 490,
+ "stock": 100,
+ "sku": "HA-RD-JAS-100",
+ "barcode": "8859123456801",
+ "description": "ก้านไม้กระจายความหอมกลิ่นดอกมะลิธรรมชาติ หอมละมุน 4-6 สัปดาห์",
+ "details": "ปริมาตร: 100ml\nรวมก้านไม้ไฟเบอร์ 6 ก้าน",
+ "images": [
+ "https://example.com/uploads/diffuser-1.jpg",
+ "https://example.com/uploads/diffuser-2.jpg"
+ ]
+}
+```
+
+> **ระบบ Auto-Handling:** เมื่อใส่ `price`, `stock`, `sku`, `barcode` ไว้ที่ระดับบนสุด ระบบจะสร้าง Default Base Variant ให้โดยอัตโนมัติ
+
+---
+
+### แบบที่ 2: สินค้าที่มีหลายตัวเลือกย่อย (Variable Product)
+เหมาะสำหรับสินค้าที่มี ขนาด, กลิ่น, หรือสี ให้เลือก:
+
+```json
+{
+ "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://example.com/uploads/lav-1.jpg"
+ ]
+}
+```
+
+---
+
+### แบบที่ 3: รองรับหลายภาษา (Multilingual)
+สามารถใส่ข้อความหลายภาษาใน `translations` หรือใส่เป็น Object ที่ฟิลด์ `name`, `description`, `details` ได้โดยตรง:
+
+```json
+{
+ "name": "น้ำมันหอมระเหย ยูคาลิปตัส",
+ "categoryName": "Aroma Oil",
+ "brandName": "HomAroma",
+ "price": 320,
+ "stock": 60,
+ "sku": "HA-EUC-10ML",
+ "translations": {
+ "th": {
+ "name": "น้ำมันหอมระเหย ยูคาลิปตัส 100%",
+ "description": "ช่วยให้หายใจโล่ง สดชื่น ปลอดโปร่ง",
+ "details": "ขนาด: 10ml"
+ },
+ "en": {
+ "name": "Eucalyptus Pure Essential Oil 100%",
+ "description": "Refreshes breathing and clarifies the mind.",
+ "details": "Size: 10ml"
+ }
+ }
+}
+```
+
+---
+
+## 2. พจนานุกรมฟิลด์ (Data Dictionary)
+
+| ฟิลด์ | ชนิดข้อมูล | จำเป็น? | คำอธิบายและข้อกำหนด |
+| :--- | :--- | :---: | :--- |
+| `name` | `string` \| `object` | **ใช่** | ชื่อสินค้า (เช่น `"Aroma Oil"` หรือ `{"th": "...", "en": "..."}`) |
+| `categoryName` | `string` | ทางเลือก | ชื่อหมวดหมู่ (หากยังไม่มีในระบบ ระบบจะ **Auto-Create** ให้ทันที) |
+| `categoryId` | `number` | ทางเลือก | รหัสหมวดหมู่เดิมในระบบ (ใช้แทน `categoryName` ได้) |
+| `brandName` | `string` | ทางเลือก | ชื่อแบรนด์ (หากยังไม่มีในระบบ ระบบจะ **Auto-Create** ให้ทันที) |
+| `brandId` | `number` | ทางเลือก | รหัสแบรนด์เดิมในระบบ (ใช้แทน `brandName` ได้) |
+| `description` | `string` \| `object` | ไม่ | คำอธิบายสินค้าโดยย่อ |
+| `details` | `string` \| `object` | ไม่ | รายละเอียดสินค้า วิธีการใช้งาน คุณสมบัติ |
+| `price` | `number` | ไม่* | ราคาจำหน่าย (กรณีสินค้าเดี่ยวไม่มี Variants) |
+| `stock` | `number` | ไม่* | จำนวนสต็อกคงเหลือ (กรณีสินค้าเดี่ยวไม่มี Variants) |
+| `sku` | `string` | ไม่ | รหัสสินค้า SKU |
+| `barcode` | `string` | ไม่ | รหัสบาร์โค้ดสินค้า (สำหรับยิงขายที่ POS) |
+| `images` | `array` | ไม่ | รายการรูปภาพ (รองรับทั้ง Array URL `["https://..."]` หรือ `[{"url": "...", "order": 0}]`) |
+| `options` | `array` | ไม่* | รายการตัวเลือก สูงสุด 3 ตัวเลือก เช่น `[{"name": "ขนาด", "values": ["10ml", "30ml"]}]` |
+| `variants` | `array` | ไม่* | รายการสินค้าย่อย โดย `option1`, `option2`, `option3` จะแมปตามลำดับของ `options` |
+| `translations` | `object` | ไม่ | แผนที่ภาษา เช่น `{"th": {...}, "en": {...}, "ja": {...}}` |
+
+---
+
+## 3. กลไกอัตโนมัติของ Backend (Auto-Provisioning Engine)
+
+เมื่อส่ง JSON เข้าสู่ `POST /api/products/bulk`:
+1. **Auto-Category & Auto-Brand:** หากส่ง `categoryName` หรือ `brandName` ที่ยังไม่มีในระบบ ระบบจะสร้าง Category/Brand ใหม่ให้โดยอัตโนมัติ ไม่เกิด Error
+2. **Auto-Default Base Variant:** หากไม่ส่ง `variants` มา แต่ระบุ `price` หรือ `stock` หรือ `sku` ที่ระดับบนสุด ระบบจะแปลงเป็น Single Base Variant ให้อัตโนมัติ ทำให้สินค้าพร้อมขายและตัดสต็อกใน POS ได้ทันที
+3. **Auto-Normalize Images:** แปลงทั้ง String URLs และ Image Objects ให้อยู่ในฟอร์แมตฐานข้อมูลพร้อมกำหนด Order ให้อัตโนมัติ
+4. **Auto-Seed CMS Detail Sections:** ระบบจะสร้าง Section มาตรฐานสำหรับหน้าเว็บ Next.js SSR (Product Main Card, Description, Specifications, Reviews, Related Products) ให้ทันที ทำให้หน้าบ้านแสดงผลได้สมบูรณ์แบบโดยไม่ต้องเข้าไปตั้งค่าทีละชิ้น
diff --git a/apps/admin/src/pages/ProductsPage.tsx b/apps/admin/src/pages/ProductsPage.tsx
index b70e50b..b23e133 100644
--- a/apps/admin/src/pages/ProductsPage.tsx
+++ b/apps/admin/src/pages/ProductsPage.tsx
@@ -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(null)
+
+ // JSON Import State
+ const [isImportModalOpen, setIsImportModalOpen] = useState(false)
+ const [importJsonText, setImportJsonText] = useState('')
+ const [importLoading, setImportLoading] = useState(false)
+ const [importError, setImportError] = useState(null)
+ const [importSuccessMessage, setImportSuccessMessage] = useState(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) => {
+ 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...')}
-
+
+
+
+
+
{/* Filters Bar */}
@@ -751,6 +906,125 @@ export default function ProductsPage() {
)}
+ {/* JSON Import Modal */}
+
+ {isImportModalOpen && (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
นำเข้าสินค้าผ่าน JSON (JSON Import)
+
รองรับทั้งสินค้าเดี่ยว (Simple) และสินค้าหลายตัวเลือก (Variants)
+
+
+
+
+
+ {/* Actions Bar */}
+
+
+
+
+
+
+ {/* JSON Textarea */}
+
+
+ วางโค้ด JSON ที่นี่ (JSON Array):
+ {importJsonText && (
+
+ )}
+
+
+
+ {/* Error / Success Feedback */}
+ {importError && (
+
+
+ {importError}
+
+ )}
+ {importSuccessMessage && (
+
+
+ {importSuccessMessage}
+
+ )}
+
+ {/* Modal Footer */}
+
+
+
+
+
+
+ )}
+
)
}
+
diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts
index e940607..9ec7d9c 100644
--- a/apps/backend/src/routes/products.ts
+++ b/apps/backend/src/routes/products.ts
@@ -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" });
diff --git a/push_all.sh b/push_all.sh
new file mode 100644
index 0000000..279ffdb
--- /dev/null
+++ b/push_all.sh
@@ -0,0 +1,110 @@
+#!/bin/bash
+
+echo "========================================================"
+echo " HomAroma Omnichannel - Git Push All Remotes"
+echo "========================================================"
+
+if [[ "$1" == "--help" || "$1" == "-h" ]]; then
+ echo ""
+ echo "Usage:"
+ echo " ./push_all.sh [OPTIONS] [BRANCH]"
+ echo ""
+ echo "Options:"
+ echo " demo, --demo Push only to showcase demo remotes (demo-*)"
+ echo " --dry-run, -n Simulate push without modifying remotes"
+ echo " --help, -h Show this help message"
+ echo ""
+ echo "Examples:"
+ echo " ./push_all.sh Push current branch to ALL remotes"
+ echo " ./push_all.sh demo Push current branch only to demo-* remotes"
+ echo " ./push_all.sh master Push 'master' branch to ALL remotes"
+ echo " ./push_all.sh --dry-run Test push to all remotes without changing anything"
+ echo ""
+ exit 0
+fi
+
+CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "master")
+TARGET_BRANCH="$CURRENT_BRANCH"
+FILTER_PREFIX=""
+DRY_RUN=0
+
+for arg in "$@"; do
+ case "$arg" in
+ demo|--demo)
+ FILTER_PREFIX="demo-"
+ ;;
+ --dry-run|-n)
+ DRY_RUN=1
+ ;;
+ *)
+ TARGET_BRANCH="$arg"
+ ;;
+ esac
+done
+
+echo "Target Branch : [$TARGET_BRANCH]"
+if [[ -n "$FILTER_PREFIX" ]]; then
+ echo "Filter Mode : Only remotes starting with '$FILTER_PREFIX'"
+fi
+if [[ $DRY_RUN -eq 1 ]]; then
+ echo "Mode : [DRY-RUN] (Will not actually push)"
+fi
+echo ""
+
+ALL_REMOTES=$(git remote)
+REMOTES=()
+
+for r in $ALL_REMOTES; do
+ if [[ -z "$FILTER_PREFIX" || "$r" == "$FILTER_PREFIX"* ]]; then
+ REMOTES+=("$r")
+ fi
+done
+
+if [[ ${#REMOTES[@]} -eq 0 ]]; then
+ echo "[ERROR] No matching git remotes found."
+ exit 1
+fi
+
+echo "Remotes queued for push:"
+for r in "${REMOTES[@]}"; do
+ echo " * $r"
+done
+echo "========================================================"
+echo ""
+
+SUCCESS_LIST=()
+FAIL_LIST=()
+
+for r in "${REMOTES[@]}"; do
+ echo "--------------------------------------------------------"
+ echo "[>] Pushing branch '$TARGET_BRANCH' to remote '$r'..."
+ echo "--------------------------------------------------------"
+ if [[ $DRY_RUN -eq 1 ]]; then
+ git push --dry-run "$r" "$TARGET_BRANCH"
+ else
+ git push "$r" "$TARGET_BRANCH"
+ fi
+ if [[ $? -eq 0 ]]; then
+ echo "[OK] Successfully pushed to $r"
+ SUCCESS_LIST+=("$r")
+ else
+ echo "[FAILED] Push failed for $r"
+ FAIL_LIST+=("$r")
+ fi
+ echo ""
+done
+
+echo "========================================================"
+echo " PUSH SUMMARY"
+echo "========================================================"
+echo "Total: ${#SUCCESS_LIST[@]} succeeded, ${#FAIL_LIST[@]} failed"
+echo ""
+if [[ ${#SUCCESS_LIST[@]} -gt 0 ]]; then
+ echo "[SUCCESS]: ${SUCCESS_LIST[*]}"
+fi
+if [[ ${#FAIL_LIST[@]} -gt 0 ]]; then
+ echo "[FAILED] : ${FAIL_LIST[*]}"
+fi
+echo "========================================================"
+echo "Done."
+echo ""
diff --git a/sample-products-import.json b/sample-products-import.json
new file mode 100644
index 0000000..4053927
--- /dev/null
+++ b/sample-products-import.json
@@ -0,0 +1,75 @@
+[
+ {
+ "name": "น้ำมันหอมระเหย ลาเวนเดอร์ 100% Pure Essential Oil",
+ "categoryName": "Aroma Oil",
+ "brandName": "HomAroma",
+ "description": "น้ำมันหอมระเหยบริสุทธิ์แท้ 100% สกัดจากดอกลาเวนเดอร์ประเทศฝรั่งเศส ช่วยผ่อนคลายความเครียดและหลับสบาย",
+ "details": "วิธีใช้: หยด 3-5 หยดลงในเตาอโรมา หรือเครื่องพ่นไอน้ำ\nขนาดบรรจุ: 10ml, 30ml\nประเทศที่ผลิต: ฝรั่งเศส",
+ "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",
+ "https://images.unsplash.com/photo-1617897903246-719242758050?w=800"
+ ],
+ "translations": {
+ "th": {
+ "name": "น้ำมันหอมระเหย ลาเวนเดอร์ 100% Pure Essential Oil",
+ "description": "น้ำมันหอมระเหยบริสุทธิ์แท้ 100% สกัดจากดอกลาเวนเดอร์ประเทศฝรั่งเศส ช่วยผ่อนคลายความเครียดและหลับสบาย",
+ "details": "วิธีใช้: หยด 3-5 หยดลงในเตาอโรมา หรือเครื่องพ่นไอน้ำ\nขนาดบรรจุ: 10ml, 30ml"
+ },
+ "en": {
+ "name": "Lavender Pure Essential Oil 100%",
+ "description": "100% pure natural French lavender essential oil for relaxation and restful sleep.",
+ "details": "How to use: Add 3-5 drops into aroma burner or ultrasonic diffuser.\nSizes: 10ml, 30ml"
+ }
+ }
+ },
+ {
+ "name": "ก้านไม้หอมปรับอากาศ กลิ่นมะลิไทย (Reed Diffuser Jasmine)",
+ "categoryName": "Reed Diffuser",
+ "brandName": "HomAroma",
+ "description": "ก้านไม้กระจายความหอมกลิ่นดอกมะลิธรรมชาติ หอมละมุน สร้างบรรยากาศผ่อนคลายยาวนาน 4-6 สัปดาห์",
+ "details": "ปริมาตร: 100ml\nรวมก้านไม้ไฟเบอร์สีดำ 6 ก้าน\nเหมาะสำหรับห้องขนาด 15-25 ตารางเมตร",
+ "price": 490,
+ "stock": 100,
+ "sku": "HA-RD-JAS-100",
+ "barcode": "8859123456801",
+ "images": [
+ "https://images.unsplash.com/photo-1617897903246-719242758050?w=800"
+ ]
+ },
+ {
+ "name": "สเปรย์ฉีดหมอน ผ่อนคลายก่อนนอน (Deep Sleep Pillow Mist)",
+ "categoryName": "Room Spray",
+ "brandName": "HomAroma",
+ "description": "สเปรย์อโรมาฉีดหมอนและที่นอน ผสมผสานน้ำมันหอมระเหยคาโมมายล์และลาเวนเดอร์ อ่อนโยน ปลอดภัย ไม่ทิ้งคราบ",
+ "details": "ปริมาตร: 50ml\nวิธีใช้: ฉีดพ่นหมอนหรือผ้าห่ม 2-3 ครั้ง ก่อนเข้านอน 10 นาที",
+ "price": 320,
+ "stock": 80,
+ "sku": "HA-PM-50ML",
+ "barcode": "8859123456901",
+ "images": [
+ "https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?w=800"
+ ]
+ }
+]