feat(products): implement JSON import with auto-provisioning, UI modal, and documentation schema
This commit is contained in:
1 parent
63e3a2895d
commit
101143f26a
5 files changed
+761
-180
No files matched your search
@@ -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) ให้ทันที ทำให้หน้าบ้านแสดงผลได้สมบูรณ์แบบโดยไม่ต้องเข้าไปตั้งค่าทีละชิ้น
|
||||||
@@ -12,12 +12,16 @@ import {
|
|||||||
X,
|
X,
|
||||||
UploadCloud,
|
UploadCloud,
|
||||||
Loader2,
|
Loader2,
|
||||||
LayoutTemplate
|
LayoutTemplate,
|
||||||
|
FileJson,
|
||||||
|
Download,
|
||||||
|
CheckCircle2
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types'
|
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types'
|
||||||
|
|
||||||
|
|
||||||
interface PaginationMeta {
|
interface PaginationMeta {
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -47,6 +51,146 @@ export default function ProductsPage() {
|
|||||||
const limit = validLimits.includes(limitParam) ? limitParam : 10
|
const limit = validLimits.includes(limitParam) ? limitParam : 10
|
||||||
const [meta, setMeta] = useState<PaginationMeta | null>(null)
|
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) => {
|
const fixUrl = (url: string) => {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
// Fix legacy URLs that might have /public prefix which is now served at /
|
// Fix legacy URLs that might have /public prefix which is now served at /
|
||||||
@@ -317,6 +461,15 @@ export default function ProductsPage() {
|
|||||||
{meta ? `Displaying ${products.length} of ${meta.total} products` : t('common.loading', 'Loading products...')}
|
{meta ? `Displaying ${products.length} of ${meta.total} products` : t('common.loading', 'Loading products...')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<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
|
<button
|
||||||
className="btn-primary flex items-center gap-2 cursor-pointer"
|
className="btn-primary flex items-center gap-2 cursor-pointer"
|
||||||
onClick={() => navigate('/products/new')}
|
onClick={() => navigate('/products/new')}
|
||||||
@@ -325,6 +478,8 @@ export default function ProductsPage() {
|
|||||||
{t('products.addNew', 'Add Product')}
|
{t('products.addNew', 'Add Product')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Filters Bar */}
|
{/* Filters Bar */}
|
||||||
<div className="flex flex-col md:flex-row gap-4 items-center">
|
<div className="flex flex-col md:flex-row gap-4 items-center">
|
||||||
@@ -751,6 +906,125 @@ export default function ProductsPage() {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
+167
-179
@@ -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" })
|
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||||
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
|
.decorate("validateVariants", (options: any[] = [], variants: any[] = []) => {
|
||||||
if (options.length > 3) throw new Error("Options array length cannot exceed 3.");
|
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 }) => {
|
async ({ body, status, validateVariants }) => {
|
||||||
const payload = body as any;
|
const payload = body as any;
|
||||||
try {
|
try {
|
||||||
if (payload.variants && Array.isArray(payload.variants)) {
|
|
||||||
validateVariants(payload.options || [], payload.variants);
|
|
||||||
}
|
|
||||||
const product = await prisma.$transaction(async (tx) => {
|
const product = await prisma.$transaction(async (tx) => {
|
||||||
let categoryId = payload.categoryId;
|
return await createSingleProductTx(tx, payload, validateVariants);
|
||||||
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 { success: true, product };
|
return { success: true, product };
|
||||||
@@ -831,102 +907,14 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
try {
|
try {
|
||||||
const createdProducts = await prisma.$transaction(async (tx) => {
|
const createdProducts = await prisma.$transaction(async (tx) => {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
for (const payload of payloadArray) {
|
for (const payload of payloadArray) {
|
||||||
if (payload.variants && Array.isArray(payload.variants)) {
|
const prod = await createSingleProductTx(tx, payload, validateVariants);
|
||||||
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,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results.push(prod);
|
results.push(prod);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, products: createdProducts };
|
return { success: true, count: createdProducts.length, products: createdProducts };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === "CATEGORY_NOT_FOUND") {
|
if (err?.code === "CATEGORY_NOT_FOUND") {
|
||||||
return status(400, { success: false, message: "Category not found" });
|
return status(400, { success: false, message: "Category not found" });
|
||||||
|
|||||||
+110
@@ -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 ""
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in new issue
Block a user