x
This commit is contained in:
1 parent
c23a4723ed
commit
07e9413a6b
13 files changed
+487
-84
No files matched your search
@@ -40,8 +40,7 @@ export default function ProductEditPage() {
|
|||||||
const [name, setName] = useState<Record<string, string>>({})
|
const [name, setName] = useState<Record<string, string>>({})
|
||||||
const [description, setDescription] = useState<Record<string, string>>({})
|
const [description, setDescription] = useState<Record<string, string>>({})
|
||||||
const [details, setDetails] = useState<Record<string, string>>({})
|
const [details, setDetails] = useState<Record<string, string>>({})
|
||||||
const [options, setOptions] = useState<{name: string, values: string[]}[]>([])
|
const [options, setOptions] = useState<{name: Record<string, string>, values: Record<string, string>[]}[]>([])
|
||||||
const [optionInputs, setOptionInputs] = useState<string[]>(['', '', ''])
|
|
||||||
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
const [variants, setVariants] = useState<Partial<Variant>[]>([])
|
||||||
const [images, setImages] = useState<Partial<ProductImage>[]>([])
|
const [images, setImages] = useState<Partial<ProductImage>[]>([])
|
||||||
|
|
||||||
@@ -105,7 +104,15 @@ export default function ProductEditPage() {
|
|||||||
setDescription(parseTranslatable(prodData.description))
|
setDescription(parseTranslatable(prodData.description))
|
||||||
setDetails(parseTranslatable(prodData.details))
|
setDetails(parseTranslatable(prodData.details))
|
||||||
|
|
||||||
const parseOptions = (val: any) => Array.isArray(val) ? val : []
|
const parseOptions = (val: any) => {
|
||||||
|
if (!Array.isArray(val)) return []
|
||||||
|
return val.map(opt => ({
|
||||||
|
name: typeof opt.name === 'string' ? { th: opt.name } : (opt.name || { th: '' }),
|
||||||
|
values: Array.isArray(opt.values)
|
||||||
|
? opt.values.map((v: any) => typeof v === 'string' ? { th: v } : (v || { th: '' }))
|
||||||
|
: []
|
||||||
|
}))
|
||||||
|
}
|
||||||
setOptions(parseOptions(prodData.options))
|
setOptions(parseOptions(prodData.options))
|
||||||
setVariants(prodData.variants || [])
|
setVariants(prodData.variants || [])
|
||||||
setImages(prodData.images || [])
|
setImages(prodData.images || [])
|
||||||
@@ -138,16 +145,19 @@ export default function ProductEditPage() {
|
|||||||
setVariants(updated)
|
setVariants(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
const regenerateVariants = (newOptions: {name: string, values: string[]}[]) => {
|
const regenerateVariants = (newOptions: {name: Record<string, string>, values: Record<string, string>[]}[]) => {
|
||||||
setVariants(prevVariants => {
|
setVariants(prevVariants => {
|
||||||
const validOptions = newOptions.filter(o => o.name && o.values.length > 0)
|
const validOptions = newOptions.filter(o => o.name && (o.name['th'] || o.name['en']) && o.values.length > 0)
|
||||||
if (validOptions.length === 0) {
|
if (validOptions.length === 0) {
|
||||||
const defaultVar = prevVariants.find(v => !v.option1 || v.option1 === 'Default')
|
const defaultVar = prevVariants.find(v => !v.option1 || v.option1 === 'Default')
|
||||||
return defaultVar ? [defaultVar] : []
|
return defaultVar ? [defaultVar] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
const arrays = validOptions.map(o => o.values)
|
const arrays = validOptions.map(o => o.values.map(v => v['th'] || v['en'] || Object.values(v)[0] || ''))
|
||||||
const cartesian = arrays.reduce((acc, curr) =>
|
const filteredArrays = arrays.filter(arr => arr.length > 0 && arr.some(v => v !== ''))
|
||||||
|
if (filteredArrays.length === 0) return []
|
||||||
|
|
||||||
|
const cartesian = filteredArrays.reduce((acc, curr) =>
|
||||||
acc.flatMap(c => curr.map(n => [...c, n])),
|
acc.flatMap(c => curr.map(n => [...c, n])),
|
||||||
[[]] as string[][]
|
[[]] as string[][]
|
||||||
)
|
)
|
||||||
@@ -450,7 +460,7 @@ export default function ProductEditPage() {
|
|||||||
</div>
|
</div>
|
||||||
{options.length < 3 && (
|
{options.length < 3 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setOptions([...options, { name: '', values: [] }])}
|
onClick={() => setOptions([...options, { name: {}, values: [] }])}
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-widest hover:bg-cyan-glow/20 transition-all"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
@@ -464,13 +474,13 @@ export default function ProductEditPage() {
|
|||||||
<div key={idx} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl animate-fade-in space-y-4 relative group">
|
<div key={idx} className="p-4 bg-white/[0.02] border border-white/5 rounded-2xl animate-fade-in space-y-4 relative group">
|
||||||
<div className="flex flex-col sm:flex-row gap-4 items-start">
|
<div className="flex flex-col sm:flex-row gap-4 items-start">
|
||||||
<div className="w-full sm:w-1/3 space-y-2">
|
<div className="w-full sm:w-1/3 space-y-2">
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Name</label>
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Name ({activeLang.toUpperCase()})</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={opt.name}
|
value={opt.name[activeLang] || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newOpts = [...options]
|
const newOpts = [...options]
|
||||||
newOpts[idx].name = e.target.value
|
newOpts[idx].name = { ...newOpts[idx].name, [activeLang]: e.target.value }
|
||||||
setOptions(newOpts)
|
setOptions(newOpts)
|
||||||
regenerateVariants(newOpts)
|
regenerateVariants(newOpts)
|
||||||
}}
|
}}
|
||||||
@@ -479,11 +489,22 @@ export default function ProductEditPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 space-y-2 w-full">
|
<div className="flex-1 space-y-2 w-full">
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Values (Press Enter to add)</label>
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-600 ml-1">Option Values ({activeLang.toUpperCase()})</label>
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
<div className="flex flex-col gap-2 mb-2">
|
||||||
{opt.values.map((val, vIdx) => (
|
{opt.values.map((val, vIdx) => (
|
||||||
<div key={vIdx} className="flex items-center gap-1 px-3 py-1 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-sm font-medium">
|
<div key={vIdx} className="flex items-center gap-2">
|
||||||
{val}
|
<input
|
||||||
|
type="text"
|
||||||
|
value={val[activeLang] || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newOpts = [...options]
|
||||||
|
newOpts[idx].values[vIdx] = { ...newOpts[idx].values[vIdx], [activeLang]: e.target.value }
|
||||||
|
setOptions(newOpts)
|
||||||
|
regenerateVariants(newOpts)
|
||||||
|
}}
|
||||||
|
className="w-full px-4 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
||||||
|
placeholder={`Value ${vIdx + 1}`}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newOpts = [...options]
|
const newOpts = [...options]
|
||||||
@@ -491,52 +512,28 @@ export default function ProductEditPage() {
|
|||||||
setOptions(newOpts)
|
setOptions(newOpts)
|
||||||
regenerateVariants(newOpts)
|
regenerateVariants(newOpts)
|
||||||
}}
|
}}
|
||||||
className="text-cyan-glow/50 hover:text-cyan-glow transition-colors ml-1"
|
className="p-2 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-xl transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<button
|
||||||
type="text"
|
onClick={() => {
|
||||||
value={optionInputs[idx]}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newInputs = [...optionInputs]
|
|
||||||
newInputs[idx] = e.target.value
|
|
||||||
setOptionInputs(newInputs)
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ',') {
|
|
||||||
e.preventDefault()
|
|
||||||
const val = optionInputs[idx].trim()
|
|
||||||
if (val && !opt.values.includes(val)) {
|
|
||||||
const newOpts = [...options]
|
const newOpts = [...options]
|
||||||
newOpts[idx].values.push(val)
|
newOpts[idx].values.push({ [activeLang]: '' })
|
||||||
setOptions(newOpts)
|
setOptions(newOpts)
|
||||||
|
|
||||||
const newInputs = [...optionInputs]
|
|
||||||
newInputs[idx] = ''
|
|
||||||
setOptionInputs(newInputs)
|
|
||||||
|
|
||||||
regenerateVariants(newOpts)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
placeholder="Add value..."
|
className="text-xs font-bold text-cyan-glow hover:text-cyan-glow/80 mt-2 flex items-center gap-1"
|
||||||
className="w-full px-4 py-2 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
|
>
|
||||||
/>
|
<Plus className="w-3 h-3" /> Add Value
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newOpts = options.filter((_, i) => i !== idx)
|
const newOpts = options.filter((_, i) => i !== idx)
|
||||||
setOptions(newOpts)
|
setOptions(newOpts)
|
||||||
|
|
||||||
const newInputs = [...optionInputs]
|
|
||||||
newInputs.splice(idx, 1)
|
|
||||||
newInputs.push('')
|
|
||||||
setOptionInputs(newInputs)
|
|
||||||
|
|
||||||
regenerateVariants(newOpts)
|
regenerateVariants(newOpts)
|
||||||
}}
|
}}
|
||||||
className="p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all sm:mt-6 self-end sm:self-auto"
|
className="p-2 rounded-xl text-slate-600 hover:text-rose-500 hover:bg-rose-500/10 transition-all sm:mt-6 self-end sm:self-auto"
|
||||||
@@ -570,7 +567,7 @@ export default function ProductEditPage() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-white/10 text-[10px] uppercase tracking-widest text-slate-500">
|
<tr className="border-b border-white/10 text-[10px] uppercase tracking-widest text-slate-500">
|
||||||
{options.length > 0 ? options.map((opt, i) => (
|
{options.length > 0 ? options.map((opt, i) => (
|
||||||
<th key={i} className="py-3 px-4 font-bold">{opt.name || `Option ${i + 1}`}</th>
|
<th key={i} className="py-3 px-4 font-bold">{opt.name[activeLang] || opt.name['th'] || opt.name['en'] || `Option ${i + 1}`}</th>
|
||||||
)) : (
|
)) : (
|
||||||
<th className="py-3 px-4 font-bold">Variant</th>
|
<th className="py-3 px-4 font-bold">Variant</th>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1 +1,9 @@
|
|||||||
|
DATABASE_URL="postgresql://postgres:postgres@localhost:5550/homaroma_db?schema=public"
|
||||||
|
PORT=3051
|
||||||
|
OMISE_SECRET_KEY="skey_test_67xsxzvrku2w9bc11rq"
|
||||||
|
APP_URL="https://api.homaroma.shop"
|
||||||
|
APP_URL="https://api.homaroma.shop"
|
||||||
BACKEND_PUBLIC_URL="https://api.homaroma.shop"
|
BACKEND_PUBLIC_URL="https://api.homaroma.shop"
|
||||||
|
LINE_CHANNEL_SECRET="f003b6def9f33e44c7ef27c60823c392"
|
||||||
|
LINE_CHANNEL_ACCESS_TOKEN="HdKPnnebRILkn9L73CwssVKL7sPI5MeAFP7rI6eEtY6tm7SVD34KaYZPCPy7sn1Wj4NY0yq2d9nqSYqaIqBZMP/bPc8ZqT861sgk9dt2l9k9KvvlbDI7eISP5y/1f4xI6+l5uLHK08koxD0djiUCxQdB04t89/1O/w1cDnyilFU="
|
||||||
|
ti@pc-server:~/projects/homaroma-api
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import prisma from './src/lib/prisma';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Starting deletion process...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete all product variants first to avoid foreign key constraint errors
|
||||||
|
const deletedVariants = await prisma.productVariant.deleteMany({});
|
||||||
|
console.log(`Deleted ${deletedVariants.count} ProductVariants.`);
|
||||||
|
|
||||||
|
// Delete all products
|
||||||
|
const deletedProducts = await prisma.product.deleteMany({});
|
||||||
|
console.log(`Deleted ${deletedProducts.count} Products.`);
|
||||||
|
|
||||||
|
console.log('Deletion completed successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting products:', error);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import prisma from './src/lib/prisma';
|
||||||
|
async function main() {
|
||||||
|
const brand = await prisma.productBrand.findFirst({ where: { name: 'DDC' } });
|
||||||
|
let cat = await prisma.category.findFirst({ where: { name: 'Sprockets' } });
|
||||||
|
if (!cat) {
|
||||||
|
cat = await prisma.category.create({ data: { name: 'Sprockets' } });
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({brandId: brand?.id, catId: cat.id}));
|
||||||
|
}
|
||||||
|
main().catch(console.error).finally(() => process.exit(0));
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
prisma.product.findMany({
|
||||||
|
include: {
|
||||||
|
category: { include: { translations: true } },
|
||||||
|
brand: true,
|
||||||
|
translations: true,
|
||||||
|
variants: {
|
||||||
|
include: {
|
||||||
|
bomItems: { include: { rawMaterial: true } }
|
||||||
|
},
|
||||||
|
orderBy: { price: "asc" }
|
||||||
|
},
|
||||||
|
images: { orderBy: { order: "asc" } },
|
||||||
|
}
|
||||||
|
}).then(console.log).catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import prisma from './src/lib/prisma';
|
||||||
|
prisma.storeSettingsTranslation.findMany({ select: { langCode: true } })
|
||||||
|
.then(x => console.log([...new Set(x.map(r => r.langCode))]))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import prisma from './src/lib/prisma';
|
||||||
|
prisma.menu.findMany().then(console.log).catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
prisma.page.findUnique({
|
||||||
|
where: { slug: 'home' },
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
sections: {
|
||||||
|
include: {
|
||||||
|
translations: true
|
||||||
|
},
|
||||||
|
orderBy: { order: "asc" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).then(console.log).catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import prisma from './src/lib/prisma';
|
||||||
|
prisma.page.findUnique({
|
||||||
|
where: { slug: 'home' },
|
||||||
|
include: {
|
||||||
|
translations: true,
|
||||||
|
sections: {
|
||||||
|
orderBy: { order: "asc" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).then(console.log).catch(console.error).finally(() => prisma.$disconnect());
|
||||||
@@ -4,15 +4,35 @@ import prisma from "../lib/prisma";
|
|||||||
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.");
|
||||||
|
|
||||||
|
const getPrimaryValues = (values: any[]) => {
|
||||||
|
return values.map(val => {
|
||||||
|
if (typeof val === 'string') return val;
|
||||||
|
if (typeof val === 'object' && val !== null) {
|
||||||
|
return val.th || Object.values(val)[0];
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
for (const v of variants) {
|
for (const v of variants) {
|
||||||
if (v.option1 && options[0] && !options[0].values.includes(v.option1)) {
|
if (v.option1 && options[0]) {
|
||||||
throw new Error(`Variant option1 '${v.option1}' is not found in options[0].values [${options[0].values.join(', ')}]`);
|
const validValues = getPrimaryValues(options[0].values);
|
||||||
|
if (!validValues.includes(v.option1)) {
|
||||||
|
throw new Error(`Variant option1 '${v.option1}' is not found in options[0].values [${validValues.join(', ')}]`);
|
||||||
}
|
}
|
||||||
if (v.option2 && options[1] && !options[1].values.includes(v.option2)) {
|
|
||||||
throw new Error(`Variant option2 '${v.option2}' is not found in options[1].values [${options[1].values.join(', ')}]`);
|
|
||||||
}
|
}
|
||||||
if (v.option3 && options[2] && !options[2].values.includes(v.option3)) {
|
if (v.option2 && options[1]) {
|
||||||
throw new Error(`Variant option3 '${v.option3}' is not found in options[2].values [${options[2].values.join(', ')}]`);
|
const validValues = getPrimaryValues(options[1].values);
|
||||||
|
if (!validValues.includes(v.option2)) {
|
||||||
|
throw new Error(`Variant option2 '${v.option2}' is not found in options[1].values [${validValues.join(', ')}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (v.option3 && options[2]) {
|
||||||
|
const validValues = getPrimaryValues(options[2].values);
|
||||||
|
if (!validValues.includes(v.option3)) {
|
||||||
|
throw new Error(`Variant option3 '${v.option3}' is not found in options[2].values [${validValues.join(', ')}]`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -369,10 +389,28 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
validateVariants(payload.options || [], 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;
|
||||||
|
if (!categoryId && payload.categoryName) {
|
||||||
|
let cat = await tx.category.findUnique({ where: { name: payload.categoryName } });
|
||||||
|
if (!cat) {
|
||||||
|
cat = await tx.category.create({ data: { name: payload.categoryName } });
|
||||||
|
}
|
||||||
|
categoryId = cat.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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({
|
const prod = await tx.product.create({
|
||||||
data: {
|
data: {
|
||||||
categoryId: payload.categoryId,
|
categoryId: categoryId,
|
||||||
brandId: payload.brandId,
|
brandId: brandId,
|
||||||
name: payload.name || "New Product",
|
name: payload.name || "New Product",
|
||||||
price: payload.price || 0,
|
price: payload.price || 0,
|
||||||
description: payload.description || "",
|
description: payload.description || "",
|
||||||
@@ -381,6 +419,19 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
await tx.productTranslation.create({
|
||||||
data: {
|
data: {
|
||||||
productId: prod.id,
|
productId: prod.id,
|
||||||
@@ -390,6 +441,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
details: payload.details || "",
|
details: payload.details || "",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.variants && payload.variants.length > 0) {
|
if (payload.variants && payload.variants.length > 0) {
|
||||||
for (const v of payload.variants) {
|
for (const v of payload.variants) {
|
||||||
@@ -437,6 +489,124 @@ export const productsRoute = new Elysia({ prefix: "/products" })
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.post(
|
||||||
|
"/bulk",
|
||||||
|
async ({ body, status, validateVariants }) => {
|
||||||
|
const payloadArray = body as any[];
|
||||||
|
if (!Array.isArray(payloadArray)) {
|
||||||
|
return status(400, { success: false, message: "Payload must be an array of 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.findUnique({ where: { name: payload.categoryName } });
|
||||||
|
if (!cat) {
|
||||||
|
cat = await tx.category.create({ data: { name: payload.categoryName } });
|
||||||
|
}
|
||||||
|
categoryId = cat.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
price: payload.price || 0,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, products: createdProducts };
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Bulk create products error:", err);
|
||||||
|
return status(500, { success: false, message: err?.message || "Failed to bulk create products" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Array(t.Any()),
|
||||||
|
detail: {
|
||||||
|
summary: "Bulk Create Products",
|
||||||
|
tags: ["products"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
.patch(
|
.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
async ({ params, body, status }) => {
|
async ({ params, body, status }) => {
|
||||||
|
|||||||
+119
-9
@@ -253,24 +253,49 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create_product",
|
name: "create_product",
|
||||||
description: "Create a new product, optionally including its options and variant matrix. Minimum required fields are categoryId and brandId.",
|
description: "Create a new product, optionally including its options and variant matrix. Provide categoryId/brandId OR categoryName/brandName.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
categoryId: { type: "number" },
|
categoryId: { type: "number" },
|
||||||
|
categoryName: { type: "string" },
|
||||||
brandId: { type: "number" },
|
brandId: { type: "number" },
|
||||||
|
brandName: { type: "string" },
|
||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
description: { type: "string" },
|
description: { type: "string" },
|
||||||
details: { type: "string" },
|
details: { type: "string" },
|
||||||
price: { type: "number", description: "Base price if no variants are provided" },
|
price: { type: "number", description: "Base price if no variants are provided" },
|
||||||
options: {
|
translations: {
|
||||||
type: "array",
|
type: "object",
|
||||||
description: "Array of option definitions (max 3). E.g., [{'name': 'Size', 'values': ['S', 'M']}]",
|
description: "Object with lang codes as keys (e.g. 'th', 'en') and {name, description, details} as values",
|
||||||
items: {
|
additionalProperties: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string" },
|
name: { type: "string" },
|
||||||
values: { type: "array", items: { type: "string" } }
|
description: { type: "string" },
|
||||||
|
details: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: "array",
|
||||||
|
description: "Array of option definitions (max 3). E.g., [{'name': {'th': 'ขนาด'}, 'values': [{'th': 'เล็ก'}, {'en': 'Small'}]}]",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "object",
|
||||||
|
description: "Object with lang codes as keys, e.g. {'th': 'ขนาด', 'en': 'Size'}",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
description: "Object with lang codes as keys",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
required: ["name", "values"]
|
required: ["name", "values"]
|
||||||
}
|
}
|
||||||
@@ -292,8 +317,80 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["option1"]
|
required: ["option1"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
required: ["categoryId", "brandId"]
|
{
|
||||||
|
name: "bulk_create_products",
|
||||||
|
description: "Create multiple products at once. Each object in the array has the same schema as create_product.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
products: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
categoryId: { type: "number" },
|
||||||
|
categoryName: { type: "string" },
|
||||||
|
brandId: { type: "number" },
|
||||||
|
brandName: { type: "string" },
|
||||||
|
name: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
details: { type: "string" },
|
||||||
|
price: { type: "number" },
|
||||||
|
translations: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
description: { type: "string" },
|
||||||
|
details: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["name", "values"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
sku: { type: "string" },
|
||||||
|
barcode: { type: "string" },
|
||||||
|
price: { type: "number" },
|
||||||
|
stock: { type: "number", default: 0 },
|
||||||
|
option1: { type: "string" },
|
||||||
|
option2: { type: "string" },
|
||||||
|
option3: { type: "string" }
|
||||||
|
},
|
||||||
|
required: ["option1"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["products"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -309,8 +406,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
items: {
|
items: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string" },
|
name: {
|
||||||
values: { type: "array", items: { type: "string" } }
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
},
|
||||||
|
values: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
required: ["name", "values"]
|
required: ["name", "values"]
|
||||||
}
|
}
|
||||||
@@ -585,6 +691,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
const res = await api.post(`/products`, params);
|
const res = await api.post(`/products`, params);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
}
|
}
|
||||||
|
case "bulk_create_products": {
|
||||||
|
const res = await api.post(`/products/bulk`, params.products);
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
|
}
|
||||||
case "sync_product_variants": {
|
case "sync_product_variants": {
|
||||||
const payload = {
|
const payload = {
|
||||||
options: params.options,
|
options: params.options,
|
||||||
|
|||||||
@@ -203,14 +203,33 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
}
|
}
|
||||||
case "upsert_menu": {
|
case "upsert_menu": {
|
||||||
const dryRun = !!params.dryRun;
|
const dryRun = !!params.dryRun;
|
||||||
const items = Array.isArray(params.items) ? params.items : [];
|
let existingMenu: any = null;
|
||||||
|
try {
|
||||||
|
const getRes = await api.get(`/menus/${params.name}`);
|
||||||
|
existingMenu = getRes.data;
|
||||||
|
} catch (e) {
|
||||||
|
// Menu might not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = params.items !== undefined ? (Array.isArray(params.items) ? params.items : []) : (existingMenu?.items || []);
|
||||||
const body: any = { items };
|
const body: any = { items };
|
||||||
const itemCheck = validateMenuItems(items);
|
const itemCheck = validateMenuItems(items);
|
||||||
if (itemCheck.issues.length > 0) {
|
if (itemCheck.issues.length > 0) {
|
||||||
throw new Error(`Invalid menu items: ${itemCheck.issues.join("; ")}`);
|
throw new Error(`Invalid menu items: ${itemCheck.issues.join("; ")}`);
|
||||||
}
|
}
|
||||||
if (params.payload !== undefined) body.payload = params.payload;
|
|
||||||
if (params.isActive !== undefined) body.isActive = params.isActive;
|
if (params.payload !== undefined) {
|
||||||
|
body.payload = params.payload;
|
||||||
|
} else if (existingMenu?.payload !== undefined) {
|
||||||
|
body.payload = existingMenu.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.isActive !== undefined) {
|
||||||
|
body.isActive = params.isActive;
|
||||||
|
} else if (existingMenu?.isActive !== undefined) {
|
||||||
|
body.isActive = existingMenu.isActive;
|
||||||
|
}
|
||||||
|
|
||||||
if (params.payload !== undefined && !isPlainObject(params.payload) && !Array.isArray(params.payload)) {
|
if (params.payload !== undefined && !isPlainObject(params.payload) && !Array.isArray(params.payload)) {
|
||||||
throw new Error("Invalid menu payload: payload must be an object or array");
|
throw new Error("Invalid menu payload: payload must be an object or array");
|
||||||
}
|
}
|
||||||
@@ -228,9 +247,9 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
menuName: params.name,
|
menuName: params.name,
|
||||||
preview: {
|
preview: {
|
||||||
name: params.name,
|
name: params.name,
|
||||||
items: cloneMenuValue(items),
|
itemCount: items.length,
|
||||||
payload: params.payload !== undefined ? cloneMenuValue(params.payload) : undefined,
|
hasPayload: !!body.payload,
|
||||||
isActive: params.isActive
|
isActive: body.isActive
|
||||||
},
|
},
|
||||||
warnings: itemCheck.warnings
|
warnings: itemCheck.warnings
|
||||||
},
|
},
|
||||||
@@ -243,7 +262,25 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const res = await api.put(`/menus/${params.name}`, body);
|
const res = await api.put(`/menus/${params.name}`, body);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
operation: "upsert_menu",
|
||||||
|
name: res.data.name,
|
||||||
|
isActive: res.data.isActive,
|
||||||
|
itemCount: res.data.items?.length || 0,
|
||||||
|
hasPayload: !!res.data.payload
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case "audit_missing_pages": {
|
case "audit_missing_pages": {
|
||||||
const menusRes = await api.get("/menus");
|
const menusRes = await api.get("/menus");
|
||||||
@@ -373,7 +410,7 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify(
|
text: JSON.stringify(
|
||||||
{ success: true, dryRun: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, preview: { payload: result.payload } },
|
{ success: true, dryRun: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, preview: { nodeCount: result.payload.length } },
|
||||||
null,
|
null,
|
||||||
2
|
2
|
||||||
)
|
)
|
||||||
@@ -392,7 +429,7 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, menu: writeRes.data }, null, 2)
|
text: JSON.stringify({ success: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, nodeCount: normalized.payload.length }, null, 2)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -413,14 +450,14 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, dryRun: true, operation: "add_menu_node", nodeId: result.nodeId, warnings: validation.warnings, preview: result.payload }, null, 2)
|
text: JSON.stringify({ success: true, dryRun: true, operation: "add_menu_node", nodeId: result.nodeId, warnings: validation.warnings, preview: { nodeCount: result.payload.length } }, null, 2)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId: result.nodeId, payload: result.payload }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId: result.nodeId, nodeCount: result.payload.length }, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "update_menu_translation": {
|
case "update_menu_translation": {
|
||||||
const { nodeId, translations } = params;
|
const { nodeId, translations } = params;
|
||||||
@@ -438,14 +475,14 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, dryRun: true, operation: "update_menu_translation", warnings: validation.warnings, preview: result.payload }, null, 2)
|
text: JSON.stringify({ success: true, dryRun: true, operation: "update_menu_translation", warnings: validation.warnings, preview: { nodeCount: result.payload.length } }, null, 2)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId, payload: result.payload }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId, nodeCount: result.payload.length }, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "move_menu_node": {
|
case "move_menu_node": {
|
||||||
const { nodeId, newParentId, orderIndex } = params;
|
const { nodeId, newParentId, orderIndex } = params;
|
||||||
@@ -459,14 +496,14 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, dryRun: true, operation: "move_menu_node", preview: result.payload }, null, 2)
|
text: JSON.stringify({ success: true, dryRun: true, operation: "move_menu_node", preview: { nodeCount: result.payload.length } }, null, 2)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: result.payload }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeCount: result.payload.length }, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "remove_menu_node": {
|
case "remove_menu_node": {
|
||||||
const { nodeId } = params;
|
const { nodeId } = params;
|
||||||
@@ -480,14 +517,14 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
|||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: JSON.stringify({ success: true, dryRun: true, operation: "remove_menu_node", removedNode: result.removedNode, preview: result.payload }, null, 2)
|
text: JSON.stringify({ success: true, dryRun: true, operation: "remove_menu_node", removedNode: result.removedNode, preview: { nodeCount: result.payload.length } }, null, 2)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] });
|
||||||
return { content: [{ type: "text", text: JSON.stringify({ success: true, payload: result.payload }, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeCount: result.payload.length }, null, 2) }] };
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
# Production - ชี้ไป Backend จริงบน server
|
# Production - ชี้ไป Backend จริงบน server
|
||||||
NEXT_PUBLIC_API_URL=https://api.homaroma.shop/api
|
NEXT_PUBLIC_API_URL=http://localhost:3051/api
|
||||||
Reference in new issue
Block a user