feat(cms): implement multi-language support across frontend and admin UI
This commit is contained in:
1 parent
730e2a7b48
commit
eac45dd4a7
62 files changed
+1377
-5116
No files matched your search
@@ -18,6 +18,7 @@ import {
|
||||
ExternalLink
|
||||
} from 'lucide-react';
|
||||
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
|
||||
import { getAdminDisplayString } from '../utils/localization';
|
||||
|
||||
interface Scent {
|
||||
id: number;
|
||||
@@ -37,6 +38,103 @@ export default function PageBuilderPage() {
|
||||
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
const [editingLang, setEditingLang] = useState('th');
|
||||
|
||||
// Helper to read localized string safely
|
||||
const getLocalizedValue = (payload: any, field: string) => {
|
||||
if (!payload || payload[field] === undefined) return '';
|
||||
const val = payload[field];
|
||||
if (typeof val === 'string') {
|
||||
return editingLang === 'th' ? val : '';
|
||||
}
|
||||
return val[editingLang] || '';
|
||||
};
|
||||
|
||||
// Helper to update localized string
|
||||
const updateLocalizedValue = (field: string, value: string) => {
|
||||
setEditingSection(prev => {
|
||||
if (!prev) return null;
|
||||
const payload = prev.payload || {};
|
||||
const currentVal = payload[field];
|
||||
|
||||
let newVal;
|
||||
if (typeof currentVal === 'string') {
|
||||
newVal = { th: currentVal, [editingLang]: value };
|
||||
} else {
|
||||
newVal = { ...(currentVal || {}), [editingLang]: value };
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
payload: {
|
||||
...payload,
|
||||
[field]: newVal
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getLocalizedSlideValue = (slide: any, field: string) => {
|
||||
if (!slide || slide[field] === undefined) return '';
|
||||
const val = slide[field];
|
||||
if (typeof val === 'string') {
|
||||
return editingLang === 'th' ? val : '';
|
||||
}
|
||||
return val[editingLang] || '';
|
||||
};
|
||||
|
||||
const updateLocalizedSlideValue = (idx: number, field: string, value: string) => {
|
||||
setEditingSection(prev => {
|
||||
if (!prev) return null;
|
||||
const currentSlides = [...(prev.payload?.slides || [])];
|
||||
const slide = currentSlides[idx];
|
||||
const currentVal = slide[field];
|
||||
|
||||
let newVal;
|
||||
if (typeof currentVal === 'string') {
|
||||
newVal = { th: currentVal, [editingLang]: value };
|
||||
} else {
|
||||
newVal = { ...(currentVal || {}), [editingLang]: value };
|
||||
}
|
||||
|
||||
currentSlides[idx] = { ...slide, [field]: newVal };
|
||||
|
||||
return {
|
||||
...prev,
|
||||
payload: { ...(prev.payload || {}), slides: currentSlides }
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const getLocalizedAnnouncementSlide = (slide: any) => {
|
||||
if (typeof slide === 'string') {
|
||||
return editingLang === 'th' ? slide : '';
|
||||
}
|
||||
return slide?.[editingLang] || '';
|
||||
};
|
||||
|
||||
const updateLocalizedAnnouncementSlide = (idx: number, value: string) => {
|
||||
setEditingSection(prev => {
|
||||
if (!prev) return null;
|
||||
const currentSlides = [...(prev.payload?.slides || [])];
|
||||
const currentVal = currentSlides[idx];
|
||||
|
||||
let newVal;
|
||||
if (typeof currentVal === 'string') {
|
||||
newVal = { th: currentVal, [editingLang]: value };
|
||||
} else {
|
||||
newVal = { ...(currentVal || {}), [editingLang]: value };
|
||||
}
|
||||
|
||||
currentSlides[idx] = newVal;
|
||||
return {
|
||||
...prev,
|
||||
payload: { ...(prev.payload || {}), slides: currentSlides }
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const [pages, setPages] = useState<Page[]>([]);
|
||||
const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
|
||||
const [isPageModalOpen, setIsPageModalOpen] = useState(false);
|
||||
@@ -640,7 +738,7 @@ export default function PageBuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-bold mt-1">
|
||||
{section.payload?.title || section.payload?.tagline || (section.type === 'ANNOUNCEMENT_BAR' && section.payload?.slides?.[0]) || `Block #${section.id}`}
|
||||
{getAdminDisplayString(section.payload?.title || section.payload?.tagline || (section.type === 'ANNOUNCEMENT_BAR' && section.payload?.slides?.[0])) || `Block #${section.id}`}
|
||||
</h3>
|
||||
{section.type === 'PRODUCTS' && (
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
@@ -693,13 +791,14 @@ export default function PageBuilderPage() {
|
||||
{/* Editor Modal */}
|
||||
{isModalOpen && editingSection && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden animate-pop-in text-slate-200">
|
||||
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-5xl md:max-w-6xl max-h-[90vh] flex flex-col overflow-hidden animate-pop-in text-slate-200">
|
||||
<div className="p-6 border-b border-white/5 flex items-center justify-between bg-white/[0.02]">
|
||||
<h3 className="text-xl font-bold flex items-center gap-2">
|
||||
<Settings2 className="w-5 h-5 text-cyan-glow" />
|
||||
{editingSection.id ? `Edit ${editingSection.type} Block` : 'Create Layout Block'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="p-2 rounded-xl hover:bg-white/5 text-slate-400 hover:text-white transition-all"
|
||||
>
|
||||
@@ -707,6 +806,24 @@ export default function PageBuilderPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sub-header for Language Tabs */}
|
||||
<div className="px-8 pt-4 pb-0 border-b border-white/5 bg-night-900/90 backdrop-blur-md flex gap-6 overflow-x-auto hide-scrollbar">
|
||||
{['th', 'en', 'ja', 'zh', 'vi', 'ko'].map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setEditingLang(lang)}
|
||||
className={`relative pb-3 text-xs font-bold uppercase tracking-widest border-b-2 transition-all whitespace-nowrap ${
|
||||
editingLang === lang
|
||||
? 'border-cyan-glow text-cyan-glow'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-300 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleFormSubmit} className="flex-1 overflow-y-auto p-8 space-y-6">
|
||||
{!editingSection.id && (
|
||||
<div className="space-y-2">
|
||||
@@ -769,13 +886,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.title || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), title: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
<p className="text-[9px] text-slate-500 font-medium">Use [brackets] for golden accent serif text.</p>
|
||||
@@ -828,13 +940,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.buttonText || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), buttonText: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'buttonText')}
|
||||
onChange={(e) => updateLocalizedValue('buttonText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -842,13 +949,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Link</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.buttonLink || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), buttonLink: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'buttonLink')}
|
||||
onChange={(e) => updateLocalizedValue('buttonLink', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -859,13 +961,8 @@ export default function PageBuilderPage() {
|
||||
<textarea
|
||||
rows={4}
|
||||
required
|
||||
value={editingSection.payload?.description || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), description: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'description')}
|
||||
onChange={(e) => updateLocalizedValue('description', e.target.value)}
|
||||
className="admin-input w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -879,13 +976,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.title || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), title: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
<p className="text-[9px] text-slate-500 font-medium">Use [brackets] for golden accent serif text.</p>
|
||||
@@ -977,15 +1069,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={slide.category || ''}
|
||||
onChange={(e) => {
|
||||
const currentSlides = [...(editingSection.payload?.slides || [])];
|
||||
currentSlides[idx] = { ...currentSlides[idx], category: e.target.value };
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), slides: currentSlides }
|
||||
});
|
||||
}}
|
||||
value={getLocalizedSlideValue(slide, 'category')}
|
||||
onChange={(e) => updateLocalizedSlideValue(idx, 'category', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -994,15 +1079,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={slide.title || ''}
|
||||
onChange={(e) => {
|
||||
const currentSlides = [...(editingSection.payload?.slides || [])];
|
||||
currentSlides[idx] = { ...currentSlides[idx], title: e.target.value };
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), slides: currentSlides }
|
||||
});
|
||||
}}
|
||||
value={getLocalizedSlideValue(slide, 'title')}
|
||||
onChange={(e) => updateLocalizedSlideValue(idx, 'title', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -1013,15 +1091,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slide.buttonText || ''}
|
||||
onChange={(e) => {
|
||||
const currentSlides = [...(editingSection.payload?.slides || [])];
|
||||
currentSlides[idx] = { ...currentSlides[idx], buttonText: e.target.value };
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), slides: currentSlides }
|
||||
});
|
||||
}}
|
||||
value={getLocalizedSlideValue(slide, 'buttonText')}
|
||||
onChange={(e) => updateLocalizedSlideValue(idx, 'buttonText', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -1029,15 +1100,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Button Link</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slide.buttonLink || ''}
|
||||
onChange={(e) => {
|
||||
const currentSlides = [...(editingSection.payload?.slides || [])];
|
||||
currentSlides[idx] = { ...currentSlides[idx], buttonLink: e.target.value };
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), buttonLink: e.target.value }
|
||||
});
|
||||
}}
|
||||
value={getLocalizedSlideValue(slide, 'buttonLink')}
|
||||
onChange={(e) => updateLocalizedSlideValue(idx, 'buttonLink', e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
@@ -1050,15 +1114,8 @@ export default function PageBuilderPage() {
|
||||
<textarea
|
||||
rows={2}
|
||||
required
|
||||
value={slide.description || ''}
|
||||
onChange={(e) => {
|
||||
const currentSlides = [...(editingSection.payload?.slides || [])];
|
||||
currentSlides[idx] = { ...currentSlides[idx], description: e.target.value };
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), slides: currentSlides }
|
||||
});
|
||||
}}
|
||||
value={getLocalizedSlideValue(slide, 'description')}
|
||||
onChange={(e) => updateLocalizedSlideValue(idx, 'description', e.target.value)}
|
||||
className="admin-input w-full text-xs resize-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -1150,15 +1207,8 @@ export default function PageBuilderPage() {
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={slide}
|
||||
onChange={(e) => {
|
||||
const newSlides = [...(editingSection.payload?.slides || [])];
|
||||
newSlides[idx] = e.target.value;
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), slides: newSlides }
|
||||
});
|
||||
}}
|
||||
value={getLocalizedAnnouncementSlide(slide)}
|
||||
onChange={(e) => updateLocalizedAnnouncementSlide(idx, e.target.value)}
|
||||
className="admin-input flex-1"
|
||||
placeholder="Slide message text..."
|
||||
/>
|
||||
@@ -1193,13 +1243,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.title || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), title: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1208,13 +1253,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.buttonText || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), buttonText: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'buttonText')}
|
||||
onChange={(e) => updateLocalizedValue('buttonText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1222,13 +1262,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Placeholder Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.placeholderText || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), placeholderText: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'placeholderText')}
|
||||
onChange={(e) => updateLocalizedValue('placeholderText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1255,13 +1290,8 @@ export default function PageBuilderPage() {
|
||||
<textarea
|
||||
rows={3}
|
||||
required
|
||||
value={editingSection.payload?.description || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), description: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'description')}
|
||||
onChange={(e) => updateLocalizedValue('description', e.target.value)}
|
||||
className="admin-input w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -1271,13 +1301,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Privacy Checkbox Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.privacyText || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), privacyText: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'privacyText')}
|
||||
onChange={(e) => updateLocalizedValue('privacyText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1330,13 +1355,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.tagline || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), tagline: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'tagline')}
|
||||
onChange={(e) => updateLocalizedValue('tagline', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1346,13 +1366,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.title || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), title: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
<p className="text-[9px] text-slate-500 font-medium">Use [brackets] for golden accent serif text (e.g. Inhale [Serenity]).</p>
|
||||
@@ -1363,13 +1378,8 @@ export default function PageBuilderPage() {
|
||||
<textarea
|
||||
rows={3}
|
||||
required
|
||||
value={editingSection.payload?.description || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), description: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'description')}
|
||||
onChange={(e) => updateLocalizedValue('description', e.target.value)}
|
||||
className="admin-input w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -1379,13 +1389,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.buttonText || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), buttonText: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'buttonText')}
|
||||
onChange={(e) => updateLocalizedValue('buttonText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1395,13 +1400,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.buttonLink || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), buttonLink: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'buttonLink')}
|
||||
onChange={(e) => updateLocalizedValue('buttonLink', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1416,13 +1416,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.title || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), title: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1430,13 +1425,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.subtitle || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), subtitle: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'subtitle')}
|
||||
onChange={(e) => updateLocalizedValue('subtitle', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1488,13 +1478,8 @@ export default function PageBuilderPage() {
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={editingSection.payload?.title || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), title: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1502,13 +1487,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtitle</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.subtitle || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), subtitle: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'subtitle')}
|
||||
onChange={(e) => updateLocalizedValue('subtitle', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1681,13 +1661,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Banner Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.title || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), title: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1696,13 +1671,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Button Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.buttonText || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), buttonText: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'buttonText')}
|
||||
onChange={(e) => updateLocalizedValue('buttonText', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1711,13 +1681,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Link URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.linkUrl || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), linkUrl: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'linkUrl')}
|
||||
onChange={(e) => updateLocalizedValue('linkUrl', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1726,13 +1691,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Description</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={editingSection.payload?.description || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), description: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'description')}
|
||||
onChange={(e) => updateLocalizedValue('description', e.target.value)}
|
||||
className="admin-input w-full resize-none"
|
||||
/>
|
||||
</div>
|
||||
@@ -1746,13 +1706,8 @@ export default function PageBuilderPage() {
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingSection.payload?.title || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), title: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -1780,13 +1735,8 @@ export default function PageBuilderPage() {
|
||||
<textarea
|
||||
rows={6}
|
||||
required
|
||||
value={editingSection.payload?.content || ''}
|
||||
onChange={(e) =>
|
||||
setEditingSection({
|
||||
...editingSection,
|
||||
payload: { ...(editingSection.payload || {}), content: e.target.value }
|
||||
})
|
||||
}
|
||||
value={getLocalizedValue(editingSection.payload, 'content')}
|
||||
onChange={(e) => updateLocalizedValue('content', e.target.value)}
|
||||
className="admin-input w-full resize-none"
|
||||
placeholder="Enter details about this text section..."
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function getAdminDisplayString(field: any): string {
|
||||
if (field === null || field === undefined) return '';
|
||||
if (typeof field === 'string') return field;
|
||||
if (typeof field === 'object') {
|
||||
if (typeof field.th === 'string') return field.th;
|
||||
if (typeof field.en === 'string') return field.en;
|
||||
try {
|
||||
return JSON.stringify(field);
|
||||
} catch {
|
||||
return '[Object]';
|
||||
}
|
||||
}
|
||||
return String(field);
|
||||
}
|
||||
|
||||
export function getRawOrTh(field: any): string {
|
||||
if (field === null || field === undefined) return '';
|
||||
if (typeof field === 'string') return field;
|
||||
if (typeof field === 'object' && typeof field.th === 'string') return field.th;
|
||||
if (typeof field === 'object' && typeof field.en === 'string') return field.en;
|
||||
return '';
|
||||
}
|
||||
+258
-172
@@ -10,11 +10,24 @@ datasource db {
|
||||
}
|
||||
|
||||
model Scent {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
products Product[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
products Product[]
|
||||
translations ScentTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ScentTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
scentId Int
|
||||
scent Scent @relation(fields: [scentId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scentId, langCode])
|
||||
}
|
||||
|
||||
model Category {
|
||||
@@ -40,21 +53,21 @@ model CategoryTranslation {
|
||||
}
|
||||
|
||||
model Product {
|
||||
id Int @id @default(autoincrement())
|
||||
price Float? @default(0)
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
name String?
|
||||
description String?
|
||||
details String?
|
||||
scentId Int
|
||||
scent Scent @relation(fields: [scentId], references: [id])
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
reviews Review[]
|
||||
id Int @id @default(autoincrement())
|
||||
price Float? @default(0)
|
||||
categoryId Int
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
name String?
|
||||
description String?
|
||||
details String?
|
||||
scentId Int
|
||||
scent Scent @relation(fields: [scentId], references: [id])
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
reviews Review[]
|
||||
translations ProductTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([scentId, categoryId])
|
||||
}
|
||||
@@ -74,11 +87,11 @@ model ProductTranslation {
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
url String
|
||||
order Int @default(0)
|
||||
order Int @default(0)
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ProductVariant {
|
||||
@@ -124,33 +137,49 @@ model CustomerAddress {
|
||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||
recipientName String
|
||||
phone String
|
||||
addressLine String // บ้านเลขที่ หมู่บ้าน ถนน ซอย
|
||||
subdistrict String // แขวง/ตำบล
|
||||
district String // เขต/อำเภอ
|
||||
province String // จังหวัด
|
||||
zipcode String // รหัสไปรษณีย์
|
||||
addressLine String // บ้านเลขที่ หมู่บ้าน ถนน ซอย
|
||||
subdistrict String // แขวง/ตำบล
|
||||
district String // เขต/อำเภอ
|
||||
province String // จังหวัด
|
||||
zipcode String // รหัสไปรษณีย์
|
||||
isDefault Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Hero {
|
||||
id Int @id @default(autoincrement())
|
||||
tagline String
|
||||
title String
|
||||
description String
|
||||
imageUrl String
|
||||
buttonText String @default("Explore Collection")
|
||||
buttonLink String @default("/shop")
|
||||
isActive Boolean @default(true)
|
||||
translations HeroTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model HeroTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
tagline String
|
||||
title String
|
||||
description String
|
||||
imageUrl String
|
||||
buttonText String @default("Explore Collection")
|
||||
buttonLink String @default("/shop")
|
||||
isActive Boolean @default(true)
|
||||
heroId Int
|
||||
hero Hero @relation(fields: [heroId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
tagline String?
|
||||
title String?
|
||||
description String?
|
||||
buttonText String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([heroId, langCode])
|
||||
}
|
||||
|
||||
model Coupon {
|
||||
id Int @id @default(autoincrement())
|
||||
code String @unique
|
||||
type String // "PERCENTAGE", "FIXED_AMOUNT", "FREE_SHIPPING"
|
||||
type String // "PERCENTAGE", "FIXED_AMOUNT", "FREE_SHIPPING"
|
||||
value Float
|
||||
minPurchase Float @default(0)
|
||||
maxDiscount Float?
|
||||
@@ -177,21 +206,38 @@ model Promotion {
|
||||
}
|
||||
|
||||
model StoreSettings {
|
||||
id Int @id @default(1)
|
||||
shippingFee Float @default(35)
|
||||
bankName String @default("KASIKORNBANK (KBANK)")
|
||||
bankAccountName String @default("HomAroma Co., Ltd.")
|
||||
bankAccountNumber String @default("012-3-45678-9")
|
||||
pointsEarnRate Float @default(1.0)
|
||||
facebookUrl String?
|
||||
footerDescription String?
|
||||
id Int @id @default(1)
|
||||
shippingFee Float @default(35)
|
||||
bankName String @default("KASIKORNBANK (KBANK)")
|
||||
bankAccountName String @default("HomAroma Co., Ltd.")
|
||||
bankAccountNumber String @default("012-3-45678-9")
|
||||
pointsEarnRate Float @default(1.0)
|
||||
facebookUrl String?
|
||||
footerDescription String?
|
||||
instagramUrl String?
|
||||
tiktokUrl String?
|
||||
newsletterTitle String?
|
||||
newsletterDescription String?
|
||||
newsletterPlaceholder String?
|
||||
newsletterButtonText String?
|
||||
updatedAt DateTime @updatedAt
|
||||
translations StoreSettingsTranslation[]
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model StoreSettingsTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
storeSettingsId Int
|
||||
storeSettings StoreSettings @relation(fields: [storeSettingsId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
footerDescription String?
|
||||
newsletterTitle String?
|
||||
newsletterDescription String?
|
||||
newsletterPlaceholder String?
|
||||
newsletterButtonText String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([storeSettingsId, langCode])
|
||||
}
|
||||
|
||||
model NewsletterSubscriber {
|
||||
@@ -202,43 +248,43 @@ model NewsletterSubscriber {
|
||||
}
|
||||
|
||||
model Order {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
customerName String
|
||||
customerEmail String
|
||||
customerPhone String
|
||||
shippingAddress String
|
||||
|
||||
subtotal Float @default(0)
|
||||
|
||||
subtotal Float @default(0)
|
||||
promoLevel1Name String?
|
||||
promoLevel1Disc Float @default(0)
|
||||
promoLevel1Disc Float @default(0)
|
||||
promoLevel2Name String?
|
||||
promoLevel2Disc Float @default(0)
|
||||
|
||||
couponCode String?
|
||||
discountAmount Float @default(0)
|
||||
|
||||
pointsUsed Int @default(0)
|
||||
pointsDiscount Float @default(0)
|
||||
pointsEarned Int @default(0)
|
||||
|
||||
shippingFee Float @default(35)
|
||||
totalAmount Float
|
||||
status String @default("PENDING_PAYMENT")
|
||||
paymentSlipUrl String?
|
||||
trackingNo String?
|
||||
|
||||
customerId Int?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
|
||||
items OrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
promoLevel2Disc Float @default(0)
|
||||
|
||||
couponCode String?
|
||||
discountAmount Float @default(0)
|
||||
|
||||
pointsUsed Int @default(0)
|
||||
pointsDiscount Float @default(0)
|
||||
pointsEarned Int @default(0)
|
||||
|
||||
shippingFee Float @default(35)
|
||||
totalAmount Float
|
||||
status String @default("PENDING_PAYMENT")
|
||||
paymentSlipUrl String?
|
||||
trackingNo String?
|
||||
|
||||
customerId Int?
|
||||
customer Customer? @relation(fields: [customerId], references: [id])
|
||||
|
||||
items OrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
orderId Int
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
productId Int
|
||||
variantId Int?
|
||||
quantity Int
|
||||
@@ -249,14 +295,28 @@ model OrderItem {
|
||||
}
|
||||
|
||||
model Article {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
title String
|
||||
coverImage String?
|
||||
content String
|
||||
isPublished Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique
|
||||
title String
|
||||
coverImage String?
|
||||
content String
|
||||
isPublished Boolean @default(false)
|
||||
translations ArticleTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ArticleTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
articleId Int
|
||||
article Article @relation(fields: [articleId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
title String?
|
||||
content String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([articleId, langCode])
|
||||
}
|
||||
|
||||
model TiktokIncome {
|
||||
@@ -268,40 +328,40 @@ model TiktokIncome {
|
||||
totalSettlementAmount Float
|
||||
totalRevenue Float
|
||||
totalFees Float
|
||||
rawData Json // Stores all other 50+ columns
|
||||
rawData Json // Stores all other 50+ columns
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model TiktokProduct {
|
||||
id String @id // Product ID
|
||||
productName String
|
||||
status String
|
||||
gmv Float
|
||||
itemsSold Int
|
||||
orders Int
|
||||
rawData Json // Stores remaining tab/live/video metrics
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id // Product ID
|
||||
productName String
|
||||
status String
|
||||
gmv Float
|
||||
itemsSold Int
|
||||
orders Int
|
||||
rawData Json // Stores remaining tab/live/video metrics
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model TiktokProductSku {
|
||||
id String @id // SKU ID
|
||||
productId String
|
||||
skuName String
|
||||
status String
|
||||
gmv Float
|
||||
skuOrders Int
|
||||
itemsSold Int
|
||||
rawData Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id // SKU ID
|
||||
productId String
|
||||
skuName String
|
||||
status String
|
||||
gmv Float
|
||||
skuOrders Int
|
||||
itemsSold Int
|
||||
rawData Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model RawMaterial {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
unit String // e.g., 'kg', 'liter', 'piece'
|
||||
unit String // e.g., 'kg', 'liter', 'piece'
|
||||
pricePerUnit Float
|
||||
bomItems BomItem[]
|
||||
createdAt DateTime @default(now())
|
||||
@@ -357,12 +417,12 @@ model ShopeeOrderItem {
|
||||
|
||||
// ── TikTok Orders ──────────────────────────────────────────────────────────────
|
||||
model TiktokOrder {
|
||||
id Int @id @default(autoincrement())
|
||||
orderId String @unique
|
||||
shop String @default("hom-aroma")
|
||||
status String // Order Status (Thai)
|
||||
subStatus String? // Order Substatus
|
||||
orderType String? // Normal / Pre-order
|
||||
id Int @id @default(autoincrement())
|
||||
orderId String @unique
|
||||
shop String @default("hom-aroma")
|
||||
status String // Order Status (Thai)
|
||||
subStatus String? // Order Substatus
|
||||
orderType String? // Normal / Pre-order
|
||||
cancelBy String?
|
||||
cancelReason String?
|
||||
fulfillmentType String?
|
||||
@@ -373,11 +433,11 @@ model TiktokOrder {
|
||||
buyerUsername String?
|
||||
paymentMethod String?
|
||||
// Financials (order-level)
|
||||
orderAmount Float @default(0) // Total paid by buyer
|
||||
refundAmount Float @default(0)
|
||||
shippingFee Float @default(0) // Shipping Fee After Discount
|
||||
originalShipping Float @default(0)
|
||||
taxes Float @default(0)
|
||||
orderAmount Float @default(0) // Total paid by buyer
|
||||
refundAmount Float @default(0)
|
||||
shippingFee Float @default(0) // Shipping Fee After Discount
|
||||
originalShipping Float @default(0)
|
||||
taxes Float @default(0)
|
||||
// Geography
|
||||
province String?
|
||||
district String?
|
||||
@@ -390,9 +450,9 @@ model TiktokOrder {
|
||||
deliveredTime String?
|
||||
cancelledTime String?
|
||||
|
||||
items TiktokOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
items TiktokOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([shop])
|
||||
@@index([status])
|
||||
@@ -400,29 +460,29 @@ model TiktokOrder {
|
||||
}
|
||||
|
||||
model TiktokOrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
orderId String
|
||||
order TiktokOrder @relation(fields: [orderId], references: [orderId], onDelete: Cascade)
|
||||
skuId String?
|
||||
sellerSku String?
|
||||
productName String
|
||||
variation String?
|
||||
quantity Int @default(1)
|
||||
skuQtyReturn Int @default(0)
|
||||
unitOriginalPrice Float @default(0)
|
||||
subtotalBeforeDiscount Float @default(0)
|
||||
platformDiscount Float @default(0)
|
||||
sellerDiscount Float @default(0)
|
||||
subtotalAfterDiscount Float @default(0) // Revenue ระดับ SKU
|
||||
productCategory String?
|
||||
weight Float @default(0)
|
||||
id Int @id @default(autoincrement())
|
||||
orderId String
|
||||
order TiktokOrder @relation(fields: [orderId], references: [orderId], onDelete: Cascade)
|
||||
skuId String?
|
||||
sellerSku String?
|
||||
productName String
|
||||
variation String?
|
||||
quantity Int @default(1)
|
||||
skuQtyReturn Int @default(0)
|
||||
unitOriginalPrice Float @default(0)
|
||||
subtotalBeforeDiscount Float @default(0)
|
||||
platformDiscount Float @default(0)
|
||||
sellerDiscount Float @default(0)
|
||||
subtotalAfterDiscount Float @default(0) // Revenue ระดับ SKU
|
||||
productCategory String?
|
||||
weight Float @default(0)
|
||||
}
|
||||
|
||||
// ── Lazada Orders ──────────────────────────────────────────────────────────────
|
||||
model LazadaOrder {
|
||||
id Int @id @default(autoincrement())
|
||||
orderNumber String @unique
|
||||
shop String @default("hom-aroma")
|
||||
id Int @id @default(autoincrement())
|
||||
orderNumber String @unique
|
||||
shop String @default("hom-aroma")
|
||||
createTime String?
|
||||
updateTime String?
|
||||
deliveredDate String?
|
||||
@@ -431,16 +491,16 @@ model LazadaOrder {
|
||||
shippingPostCode String?
|
||||
shippingRegion String?
|
||||
payMethod String?
|
||||
status String? // Based on items or first item
|
||||
status String? // Based on items or first item
|
||||
trackingCode String?
|
||||
shippingProvider String?
|
||||
// Aggregate from items
|
||||
orderAmount Float @default(0) // sum(paidPrice)
|
||||
shippingFee Float @default(0) // sum(shippingFee)
|
||||
|
||||
items LazadaOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
orderAmount Float @default(0) // sum(paidPrice)
|
||||
shippingFee Float @default(0) // sum(shippingFee)
|
||||
|
||||
items LazadaOrderItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([shop])
|
||||
@@index([status])
|
||||
@@ -448,57 +508,70 @@ model LazadaOrder {
|
||||
}
|
||||
|
||||
model LazadaOrderItem {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
orderNumber String
|
||||
order LazadaOrder @relation(fields: [orderNumber], references: [orderNumber], onDelete: Cascade)
|
||||
orderItemId String @unique
|
||||
order LazadaOrder @relation(fields: [orderNumber], references: [orderNumber], onDelete: Cascade)
|
||||
orderItemId String @unique
|
||||
sellerSku String?
|
||||
lazadaSku String?
|
||||
itemName String
|
||||
variation String?
|
||||
unitPrice Float @default(0)
|
||||
paidPrice Float @default(0)
|
||||
sellerDiscountTotal Float @default(0)
|
||||
platformDiscountTotal Float @default(0)
|
||||
shippingFee Float @default(0)
|
||||
unitPrice Float @default(0)
|
||||
paidPrice Float @default(0)
|
||||
sellerDiscountTotal Float @default(0)
|
||||
platformDiscountTotal Float @default(0)
|
||||
shippingFee Float @default(0)
|
||||
status String?
|
||||
}
|
||||
|
||||
// ── Reviews ────────────────────────────────────────────────────────────────
|
||||
model Review {
|
||||
id Int @id @default(autoincrement())
|
||||
rating Int @default(5)
|
||||
id Int @id @default(autoincrement())
|
||||
rating Int @default(5)
|
||||
comment String?
|
||||
productId Int
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
customerId Int?
|
||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
customerName String?
|
||||
isPublished Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isPublished Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([productId])
|
||||
@@index([customerId])
|
||||
}
|
||||
|
||||
model Page {
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique // e.g., "home", "about-us"
|
||||
title String
|
||||
sections PageSection[]
|
||||
isPublished Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
slug String @unique // e.g., "home", "about-us"
|
||||
title String
|
||||
sections PageSection[]
|
||||
translations PageTranslation[]
|
||||
isPublished Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model PageTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
pageId Int
|
||||
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
title String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([pageId, langCode])
|
||||
}
|
||||
|
||||
model PageSection {
|
||||
id Int @id @default(autoincrement())
|
||||
pageId Int
|
||||
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||
type String // e.g., "HERO", "CATEGORIES", "PRODUCTS", "BANNER", "TEXT"
|
||||
order Int // Display order
|
||||
payload Json // Dynamic JSON payload
|
||||
type String // e.g., "HERO", "CATEGORIES", "PRODUCTS", "BANNER", "TEXT"
|
||||
order Int // Display order
|
||||
payload Json // Dynamic JSON payload
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -520,12 +593,25 @@ model Menu {
|
||||
}
|
||||
|
||||
model MenuItem {
|
||||
id Int @id @default(autoincrement())
|
||||
menuId Int
|
||||
menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
url String
|
||||
order Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id Int @id @default(autoincrement())
|
||||
menuId Int
|
||||
menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade)
|
||||
label String
|
||||
url String
|
||||
order Int @default(0)
|
||||
translations MenuItemTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model MenuItemTranslation {
|
||||
id Int @id @default(autoincrement())
|
||||
menuItemId Int
|
||||
menuItem MenuItem @relation(fields: [menuItemId], references: [id], onDelete: Cascade)
|
||||
langCode String
|
||||
label String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([menuItemId, langCode])
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import { staticPlugin } from "@elysiajs/static";
|
||||
import { categoriesRoute } from "./routes/categories";
|
||||
import { productsRoute } from "./routes/products";
|
||||
import { authRoute } from "./routes/auth";
|
||||
import { uploadRoute } from "./routes/upload";
|
||||
import { heroRoute } from "./routes/hero";
|
||||
import { ordersRoute } from "./routes/orders";
|
||||
import { settingsRoute } from "./routes/settings";
|
||||
import { articlesRoute } from "./routes/articles";
|
||||
import { tiktokRoute } from "./routes/tiktok";
|
||||
import { rawMaterialsRoute } from "./routes/raw-materials";
|
||||
import { shopeeRoute } from "./routes/shopee";
|
||||
import { tiktokOrderRoute } from "./routes/tiktokOrder";
|
||||
import { lazadaOrderRoute } from "./routes/lazadaOrder";
|
||||
import { customersRoute } from "./routes/customers";
|
||||
import { couponsRoute } from "./routes/coupons";
|
||||
import { reviewsRoute } from "./routes/reviews";
|
||||
import { promotionsRoute } from "./routes/promotions";
|
||||
import { pagesRoute } from "./routes/pages";
|
||||
import { paymentsRoute } from "./routes/payments";
|
||||
import { profileRoute } from "./routes/profile";
|
||||
import { lineRoutes } from "./routes/line";
|
||||
import { menusRoute } from "./routes/menus";
|
||||
import { newsletterRoute } from "./routes/newsletter";
|
||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
||||
const app = new Elysia()
|
||||
.onParse(async ({ request }) => {
|
||||
console.log('[onParse] Incoming request URL:', request.url);
|
||||
if (request.url.includes('/api/line/webhook')) {
|
||||
const text = await request.text();
|
||||
console.log('[onParse] Intercepted LINE webhook raw body length:', text.length);
|
||||
return text;
|
||||
}
|
||||
})
|
||||
.use(cors({
|
||||
origin: [
|
||||
...CORS_ORIGIN.split(",").map((o) => o.trim()),
|
||||
"https://admin.homaroma.shop",
|
||||
"https://spa.homaroma.shop", // LIFF (SPA)
|
||||
"https://homaroma.shop", // Main Website
|
||||
"http://localhost:5174", // Admin
|
||||
"http://localhost:4173",
|
||||
"http://localhost:3001", // SSR Next.js
|
||||
"https://react5173.kitizone.com",
|
||||
"https://elysia3051.kitizone.com"
|
||||
],
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
credentials: true,
|
||||
}))
|
||||
.use(jwt({
|
||||
name: "jwt",
|
||||
secret: JWT_SECRET,
|
||||
exp: "24h",
|
||||
}))
|
||||
.use(swagger({
|
||||
documentation: {
|
||||
info: {
|
||||
title: "HomAroma API",
|
||||
version: "1.0.0",
|
||||
description: "API for HomAroma & Essential Oil Products",
|
||||
},
|
||||
tags: [
|
||||
{ name: "categories", description: "Product categories" },
|
||||
{ name: "products", description: "Products and variants" },
|
||||
{ name: "auth", description: "Authentication for Admin" },
|
||||
],
|
||||
},
|
||||
}))
|
||||
.use(staticPlugin({
|
||||
assets: "public",
|
||||
prefix: "", // use empty string for root
|
||||
}))
|
||||
.get("/", () => ({
|
||||
name: "HomAroma API",
|
||||
version: "1.0.0",
|
||||
status: "running",
|
||||
docs: `/swagger`,
|
||||
}))
|
||||
// Explicit route for uploads to guarantee it works even if staticPlugin fails
|
||||
.get("/uploads/:filename", ({ params: { filename }, set }) => {
|
||||
const file = Bun.file(`public/uploads/${filename}`);
|
||||
if (file.size === 0) {
|
||||
set.status = 404;
|
||||
return "File not found";
|
||||
}
|
||||
return file;
|
||||
})
|
||||
.group("/api", (app) => app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute))
|
||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||
console.log(`🌿 HomAroma API is running at http://${app.server?.hostname}:${app.server?.port}`);
|
||||
console.log(`📖 Swagger docs: http://${app.server?.hostname}:${app.server?.port}/swagger`);
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -1,11 +0,0 @@
|
||||
import * as line from '@line/bot-sdk';
|
||||
const channelAccessToken = process.env.LINE_CHANNEL_ACCESS_TOKEN || '';
|
||||
const channelSecret = process.env.LINE_CHANNEL_SECRET || '';
|
||||
export const lineConfig = {
|
||||
channelAccessToken,
|
||||
channelSecret,
|
||||
};
|
||||
export const lineClient = new line.messagingApi.MessagingApiClient({
|
||||
channelAccessToken,
|
||||
});
|
||||
//# sourceMappingURL=lineClient.js.map
|
||||
@@ -1,554 +0,0 @@
|
||||
import prisma from "./prisma";
|
||||
import { lineClient } from "./lineClient";
|
||||
export async function sendOrderCreatedNotification(orderId) {
|
||||
try {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { items: true, customer: true }
|
||||
});
|
||||
if (!order || !order.customer?.lineUserId) {
|
||||
console.log(`[Notification] Order #${orderId} has no associated lineUserId or does not exist.`);
|
||||
return;
|
||||
}
|
||||
const settings = await prisma.storeSettings.findFirst();
|
||||
const bankName = settings?.bankName || "KASIKORNBANK (KBANK)";
|
||||
const bankAccountNumber = settings?.bankAccountNumber || "012-3-45678-9";
|
||||
const bankAccountName = settings?.bankAccountName || "HomAroma Co., Ltd.";
|
||||
const itemContents = order.items.map(item => ({
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${item.productName}${item.variantSize ? ` (${item.variantSize})` : ""} x ${item.quantity}`,
|
||||
size: "sm",
|
||||
color: "#333333",
|
||||
flex: 3,
|
||||
wrap: true
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `฿${item.priceAtPurchase * item.quantity}`,
|
||||
size: "sm",
|
||||
color: "#1C3F3A",
|
||||
weight: "bold",
|
||||
align: "end",
|
||||
flex: 1
|
||||
}
|
||||
]
|
||||
}));
|
||||
const flexMessage = {
|
||||
type: "flex",
|
||||
altText: `HomAroma: คำสั่งซื้อ #${order.id} ได้รับแล้ว กรุณาชำระเงิน`,
|
||||
contents: {
|
||||
type: "bubble",
|
||||
header: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
backgroundColor: "#1C3F3A",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "HomAroma Official",
|
||||
color: "#D4B16A",
|
||||
weight: "bold",
|
||||
size: "xs"
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "ได้รับคำสั่งซื้อของคุณแล้ว",
|
||||
color: "#FFFFFF",
|
||||
weight: "bold",
|
||||
size: "md",
|
||||
margin: "sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: `หมายเลขออเดอร์: #HM-${order.id}`,
|
||||
weight: "bold",
|
||||
size: "sm",
|
||||
color: "#333333"
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
margin: "md"
|
||||
},
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
margin: "md",
|
||||
spacing: "sm",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "รายการสินค้า",
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
flex: 3
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "ยอดรวม",
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
align: "end",
|
||||
flex: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
...itemContents
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
margin: "md"
|
||||
},
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
margin: "md",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "ยอดชำระสุทธิ (รวมค่าส่ง)",
|
||||
size: "sm",
|
||||
color: "#555555",
|
||||
weight: "bold",
|
||||
flex: 3
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `฿${order.totalAmount}`,
|
||||
size: "sm",
|
||||
color: "#1C3F3A",
|
||||
weight: "bold",
|
||||
align: "end",
|
||||
flex: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
margin: "md"
|
||||
},
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
margin: "md",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "🏦 ช่องทางการชำระเงินโอนเงิน:",
|
||||
size: "xs",
|
||||
color: "#555555",
|
||||
weight: "bold"
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `${bankName} (${bankAccountNumber})`,
|
||||
size: "xs",
|
||||
color: "#1C3F3A",
|
||||
margin: "xs",
|
||||
weight: "bold"
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `ชื่อบัญชี: ${bankAccountName}`,
|
||||
size: "xs",
|
||||
color: "#555555"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
spacing: "sm",
|
||||
contents: [
|
||||
{
|
||||
type: "button",
|
||||
style: "primary",
|
||||
color: "#1C3F3A",
|
||||
action: {
|
||||
"type": "uri",
|
||||
"label": "แนบสลิปชำระเงิน",
|
||||
"uri": `https://spa.homaroma.shop/order-success?id=${order.id}`
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
style: "link",
|
||||
height: "sm",
|
||||
action: {
|
||||
"type": "uri",
|
||||
"label": "ติดตามสถานะออเดอร์",
|
||||
"uri": "https://spa.homaroma.shop/track-order"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
await lineClient.pushMessage({
|
||||
to: order.customer.lineUserId,
|
||||
messages: [flexMessage]
|
||||
});
|
||||
console.log(`[Notification] OrderCreated notification sent for Order #${orderId}`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`[Notification] Failed to send OrderCreated notification for Order #${orderId}:`, err);
|
||||
}
|
||||
}
|
||||
export async function sendPaymentConfirmedNotification(orderId) {
|
||||
try {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { customer: true }
|
||||
});
|
||||
if (!order || !order.customer?.lineUserId) {
|
||||
console.log(`[Notification] Order #${orderId} has no associated lineUserId or does not exist.`);
|
||||
return;
|
||||
}
|
||||
const flexMessage = {
|
||||
type: "flex",
|
||||
altText: "HomAroma: ได้รับการชำระเงินเรียบร้อยแล้ว",
|
||||
contents: {
|
||||
type: "bubble",
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
alignItems: "center",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "✨ ชำระเงินสำเร็จ ✨",
|
||||
weight: "bold",
|
||||
size: "xl",
|
||||
color: "#1C3F3A"
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `ขอขอบคุณสำหรับคำสั่งซื้อออเดอร์ #HM-${order.id}`,
|
||||
size: "xs",
|
||||
color: "#888888",
|
||||
margin: "xs"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
backgroundColor: "#F4F6F5",
|
||||
margin: "lg",
|
||||
paddingAll: "md",
|
||||
cornerRadius: "md",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "ได้รับแต้มครั้งนี้",
|
||||
size: "sm",
|
||||
color: "#555555"
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `+${order.pointsEarned} แต้ม`,
|
||||
weight: "bold",
|
||||
size: "sm",
|
||||
color: "#1C3F3A",
|
||||
align: "end"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
margin: "xs",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "แต้มสะสมรวมล่าสุด",
|
||||
size: "sm",
|
||||
color: "#555555"
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `${order.customer.points} แต้ม`,
|
||||
weight: "bold",
|
||||
size: "sm",
|
||||
color: "#D4B16A",
|
||||
align: "end"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "button",
|
||||
style: "primary",
|
||||
color: "#1C3F3A",
|
||||
action: {
|
||||
"type": "uri",
|
||||
"label": "แลกของรางวัล & คูปองสะสม",
|
||||
"uri": "https://spa.homaroma.shop/profile"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
await lineClient.pushMessage({
|
||||
to: order.customer.lineUserId,
|
||||
messages: [flexMessage]
|
||||
});
|
||||
console.log(`[Notification] PaymentConfirmed notification sent for Order #${orderId}`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`[Notification] Failed to send PaymentConfirmed notification for Order #${orderId}:`, err);
|
||||
}
|
||||
}
|
||||
export async function sendOrderShippedNotification(orderId) {
|
||||
try {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { customer: true }
|
||||
});
|
||||
if (!order || !order.customer?.lineUserId) {
|
||||
console.log(`[Notification] Order #${orderId} has no associated lineUserId or does not exist.`);
|
||||
return;
|
||||
}
|
||||
const flexMessage = {
|
||||
type: "flex",
|
||||
altText: "HomAroma: สินค้าของคุณอยู่ระหว่างจัดส่ง",
|
||||
contents: {
|
||||
type: "bubble",
|
||||
header: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
backgroundColor: "#1C3F3A",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "ออเดอร์ของคุณกำลังเดินทาง 🚚",
|
||||
color: "#FFFFFF",
|
||||
weight: "bold",
|
||||
size: "md"
|
||||
}
|
||||
]
|
||||
},
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
spacing: "md",
|
||||
contents: [
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "หมายเลขออเดอร์:",
|
||||
size: "sm",
|
||||
color: "#888888",
|
||||
flex: 2
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: `#HM-${order.id}`,
|
||||
size: "sm",
|
||||
color: "#333333",
|
||||
weight: "bold",
|
||||
flex: 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "box",
|
||||
layout: "horizontal",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "หมายเลขพัสดุ:",
|
||||
size: "sm",
|
||||
color: "#888888",
|
||||
flex: 2
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: order.trackingNo || "อยู่ระหว่างเข้ารับพัสดุ",
|
||||
size: "sm",
|
||||
color: "#1C3F3A",
|
||||
weight: "bold",
|
||||
flex: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "button",
|
||||
style: "primary",
|
||||
color: "#1C3F3A",
|
||||
action: {
|
||||
"type": "uri",
|
||||
"label": "เช็คสถานะการจัดส่ง",
|
||||
"uri": "https://spa.homaroma.shop/track-order"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
await lineClient.pushMessage({
|
||||
to: order.customer.lineUserId,
|
||||
messages: [flexMessage]
|
||||
});
|
||||
console.log(`[Notification] OrderShipped notification sent for Order #${orderId}`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`[Notification] Failed to send OrderShipped notification for Order #${orderId}:`, err);
|
||||
}
|
||||
}
|
||||
export async function sendOrderCancelledNotification(orderId) {
|
||||
try {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { customer: true }
|
||||
});
|
||||
if (!order || !order.customer?.lineUserId) {
|
||||
console.log(`[Notification] Order #${orderId} has no associated lineUserId or does not exist.`);
|
||||
return;
|
||||
}
|
||||
const flexMessage = {
|
||||
type: "flex",
|
||||
altText: "HomAroma: คำสั่งซื้อของคุณถูกยกเลิกแล้ว",
|
||||
contents: {
|
||||
type: "bubble",
|
||||
header: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
backgroundColor: "#8C2A2A",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: "คำสั่งซื้อถูกยกเลิก 🚫",
|
||||
color: "#FFFFFF",
|
||||
weight: "bold",
|
||||
size: "md"
|
||||
}
|
||||
]
|
||||
},
|
||||
body: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
spacing: "md",
|
||||
contents: [
|
||||
{
|
||||
type: "text",
|
||||
text: `คำสั่งซื้อหมายเลข #HM-${order.id} ได้ถูกยกเลิกแล้ว เนื่องจากเกินกำหนดเวลาชำระเงิน หรือสิทธิประโยชน์บางประการ หรือตามความประสงค์ของคุณ`,
|
||||
size: "sm",
|
||||
color: "#333333",
|
||||
wrap: true
|
||||
},
|
||||
...(order.pointsUsed > 0 ? [
|
||||
{
|
||||
type: "text",
|
||||
text: `* ระบบได้ทำการคืนคะแนนสะสมจำนวน ${order.pointsUsed} แต้มเข้าบัญชีของคุณเรียบร้อยแล้ว`,
|
||||
size: "xs",
|
||||
color: "#8C2A2A",
|
||||
weight: "bold",
|
||||
margin: "md"
|
||||
}
|
||||
] : [])
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
type: "box",
|
||||
layout: "vertical",
|
||||
contents: [
|
||||
{
|
||||
type: "button",
|
||||
style: "primary",
|
||||
color: "#1C3F3A",
|
||||
action: {
|
||||
"type": "uri",
|
||||
"label": "ช้อปต่ออีกครั้ง",
|
||||
"uri": "https://spa.homaroma.shop/"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
await lineClient.pushMessage({
|
||||
to: order.customer.lineUserId,
|
||||
messages: [flexMessage]
|
||||
});
|
||||
console.log(`[Notification] OrderCancelled notification sent for Order #${orderId}`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`[Notification] Failed to send OrderCancelled notification for Order #${orderId}:`, err);
|
||||
}
|
||||
}
|
||||
export async function markOrderAsPaid(orderId) {
|
||||
try {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId }
|
||||
});
|
||||
if (!order) {
|
||||
console.log(`[Payment] Order #${orderId} not found in markOrderAsPaid.`);
|
||||
return;
|
||||
}
|
||||
if (order.status === "PAID") {
|
||||
console.log(`[Payment] Order #${orderId} is already PAID.`);
|
||||
return;
|
||||
}
|
||||
// 1. Update status to PAID
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: "PAID" }
|
||||
});
|
||||
// 2. Grant points
|
||||
if (order.customerId && order.pointsEarned > 0) {
|
||||
await prisma.customer.update({
|
||||
where: { id: order.customerId },
|
||||
data: { points: { increment: order.pointsEarned } }
|
||||
});
|
||||
console.log(`[Payment] Granted ${order.pointsEarned} points to Customer #${order.customerId} for Order #${orderId}`);
|
||||
}
|
||||
// 3. Send payment notification
|
||||
await sendPaymentConfirmedNotification(orderId);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`[Payment] Error in markOrderAsPaid for Order #${orderId}:`, err);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=notifications.js.map
|
||||
@@ -1,9 +0,0 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
const adapter = new PrismaPg({ connectionString });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
export default prisma;
|
||||
//# sourceMappingURL=prisma.js.map
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
export const adminOnly = new Elysia({ name: "adminOnly" })
|
||||
.use(jwt({
|
||||
name: "jwt",
|
||||
secret: process.env.JWT_SECRET || "fallback-secret"
|
||||
}))
|
||||
.onBeforeHandle(async ({ jwt, headers, set }) => {
|
||||
const auth = headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Missing or invalid token format" };
|
||||
}
|
||||
const token = auth.substring(7);
|
||||
const payload = await jwt.verify(token);
|
||||
if (!payload) {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Invalid token" };
|
||||
}
|
||||
if (payload.role !== "ADMIN") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Admin access required" };
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=auth.js.map
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const articlesRoute = new Elysia({ prefix: "/articles" })
|
||||
.get("/", async ({ query, set }) => {
|
||||
try {
|
||||
const { publishedOnly } = query;
|
||||
const where = publishedOnly === 'true' ? { isPublished: true } : {};
|
||||
return await prisma.article.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to load articles" };
|
||||
}
|
||||
})
|
||||
.get("/:slug", async ({ params: { slug }, set }) => {
|
||||
try {
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { slug }
|
||||
});
|
||||
if (!article) {
|
||||
set.status = 404;
|
||||
return { error: "Article not found" };
|
||||
}
|
||||
return article;
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch article" };
|
||||
}
|
||||
})
|
||||
.get("/id/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
const article = await prisma.article.findUnique({
|
||||
where: { id: parseInt(id, 10) }
|
||||
});
|
||||
if (!article) {
|
||||
set.status = 404;
|
||||
return { error: "Article not found" };
|
||||
}
|
||||
return article;
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch article" };
|
||||
}
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
return await prisma.article.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
coverImage: data.coverImage,
|
||||
content: data.content,
|
||||
isPublished: data.isPublished
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to create article. Ensure slug is unique." };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
title: t.String(),
|
||||
slug: t.String(),
|
||||
coverImage: t.Optional(t.String()),
|
||||
content: t.String(),
|
||||
isPublished: t.Boolean()
|
||||
})
|
||||
})
|
||||
.put("/:id", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
return await prisma.article.update({
|
||||
where: { id: parseInt(id, 10) },
|
||||
data: {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
coverImage: data.coverImage,
|
||||
content: data.content,
|
||||
isPublished: data.isPublished
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to update article" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
title: t.String(),
|
||||
slug: t.String(),
|
||||
coverImage: t.Optional(t.String()),
|
||||
content: t.String(),
|
||||
isPublished: t.Boolean()
|
||||
})
|
||||
})
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.article.delete({
|
||||
where: { id: parseInt(id, 10) }
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete article" };
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=articles.js.map
|
||||
@@ -108,4 +108,50 @@ export const articlesRoute = new Elysia({ prefix: "/articles" })
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete article" };
|
||||
}
|
||||
})
|
||||
.patch("/:id/translations", async ({ params, body, set }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid article ID" };
|
||||
}
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
|
||||
for (const lang of LANGS) {
|
||||
const title = typeof payload.title === 'string' ? (lang === 'th' ? payload.title : null) : payload.title?.[lang];
|
||||
const content = typeof payload.content === 'string' ? (lang === 'th' ? payload.content : null) : payload.content?.[lang];
|
||||
|
||||
if (!title && !content) continue;
|
||||
|
||||
const existing = await prisma.articleTranslation.findUnique({
|
||||
where: { articleId_langCode: { articleId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
const updateData: any = {};
|
||||
if (title) updateData.title = title;
|
||||
if (content) updateData.content = content;
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.articleTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.articleTranslation.create({
|
||||
data: { articleId: id, langCode: lang, ...updateData }
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.article.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true }
|
||||
});
|
||||
|
||||
return { success: true, translations: results, article: updated };
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import bcrypt from "bcryptjs";
|
||||
import prisma from "../lib/prisma";
|
||||
export const authRoute = new Elysia({ prefix: "/auth" })
|
||||
.use(jwt({
|
||||
name: "jwt",
|
||||
secret: process.env.JWT_SECRET || "homaroma_secret_2026_scent",
|
||||
exp: "24h",
|
||||
}))
|
||||
.post("/login", async ({ body, jwt, set }) => {
|
||||
const { username, password } = body;
|
||||
// 1. Find user
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
if (!user) {
|
||||
set.status = 401;
|
||||
return { success: false, message: "Invalid credentials" };
|
||||
}
|
||||
// 2. Check password
|
||||
const isMatch = await bcrypt.compare(password, user.password);
|
||||
if (!isMatch) {
|
||||
set.status = 401;
|
||||
return { success: false, message: "Invalid credentials" };
|
||||
}
|
||||
// 3. Generate Token
|
||||
const token = await jwt.sign({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
},
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
username: t.String(),
|
||||
password: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Admin Login",
|
||||
tags: ["auth"],
|
||||
},
|
||||
})
|
||||
.post("/line", async ({ body, jwt, set }) => {
|
||||
const { lineUserId, displayName, pictureUrl, email, phone } = body;
|
||||
// Upsert Customer
|
||||
const customer = await prisma.customer.upsert({
|
||||
where: { lineUserId },
|
||||
update: {
|
||||
displayName,
|
||||
pictureUrl,
|
||||
...(email && { email }),
|
||||
...(phone && { phone }),
|
||||
},
|
||||
create: {
|
||||
lineUserId,
|
||||
displayName,
|
||||
pictureUrl,
|
||||
email,
|
||||
phone,
|
||||
},
|
||||
});
|
||||
// Generate Token
|
||||
const token = await jwt.sign({
|
||||
id: customer.id,
|
||||
lineUserId: customer.lineUserId,
|
||||
role: "CUSTOMER",
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
token,
|
||||
customer,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
lineUserId: t.String(),
|
||||
displayName: t.Optional(t.String()),
|
||||
pictureUrl: t.Optional(t.String()),
|
||||
email: t.Optional(t.String()),
|
||||
phone: t.Optional(t.String()),
|
||||
}),
|
||||
detail: {
|
||||
summary: "LINE Login for Customers",
|
||||
tags: ["auth"],
|
||||
},
|
||||
});
|
||||
//# sourceMappingURL=auth.js.map
|
||||
@@ -1,222 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
/** Resolve category name based on lang */
|
||||
function resolveCatName(cat, lang) {
|
||||
if (lang === 'all') {
|
||||
const nameObj = {};
|
||||
cat.translations?.forEach((tr) => { nameObj[tr.langCode] = tr.name; });
|
||||
return nameObj;
|
||||
}
|
||||
const trans = cat.translations?.find((t) => t.langCode === lang)
|
||||
|| cat.translations?.find((t) => t.langCode === 'th')
|
||||
|| cat.translations?.[0];
|
||||
return trans?.name ?? cat.name;
|
||||
}
|
||||
export const categoriesRoute = new Elysia({ prefix: "/categories" })
|
||||
.get("/", async ({ query }) => {
|
||||
const lang = query.lang || 'th';
|
||||
const categories = await prisma.category.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
translations: true,
|
||||
_count: {
|
||||
select: { products: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return categories.map((cat) => ({
|
||||
...cat,
|
||||
name: resolveCatName(cat, lang),
|
||||
}));
|
||||
})
|
||||
.get("/:id/products", async ({ params, query, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
const lang = query.lang || 'th';
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid category ID" });
|
||||
// Pagination params
|
||||
const page = query.page ? parseInt(query.page) : 1;
|
||||
const limit = query.limit ? parseInt(query.limit) : 12;
|
||||
const skip = (page - 1) * limit;
|
||||
const [category, total] = await Promise.all([
|
||||
prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
translations: true,
|
||||
products: {
|
||||
include: {
|
||||
scent: true,
|
||||
category: { include: { translations: true } },
|
||||
translations: true,
|
||||
variants: { orderBy: { price: "asc" } },
|
||||
images: { orderBy: { order: "asc" } },
|
||||
},
|
||||
orderBy: { id: "asc" },
|
||||
skip,
|
||||
take: limit,
|
||||
},
|
||||
_count: {
|
||||
select: { products: true }
|
||||
}
|
||||
},
|
||||
}),
|
||||
prisma.product.count({ where: { categoryId: id } }),
|
||||
]);
|
||||
if (!category)
|
||||
return status(404, { message: "Category not found" });
|
||||
return {
|
||||
category: {
|
||||
id: category.id,
|
||||
name: resolveCatName(category, lang),
|
||||
imageUrl: category.imageUrl,
|
||||
_count: category._count,
|
||||
},
|
||||
data: category.products.map((p) => {
|
||||
if (lang === 'all') {
|
||||
const nameObj = {};
|
||||
const descObj = {};
|
||||
const detailsObj = {};
|
||||
p.translations?.forEach((t) => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
return {
|
||||
...p,
|
||||
name: nameObj,
|
||||
description: descObj,
|
||||
details: detailsObj,
|
||||
category: { ...p.category, name: resolveCatName(p.category, lang) },
|
||||
};
|
||||
}
|
||||
else {
|
||||
const trans = p.translations?.find((t) => t.langCode === lang) || p.translations?.find((t) => t.langCode === 'th') || p.translations?.[0];
|
||||
return {
|
||||
...p,
|
||||
name: trans?.name || p.scent?.name || '',
|
||||
description: trans?.description,
|
||||
details: trans?.details,
|
||||
category: { ...p.category, name: resolveCatName(p.category, lang) },
|
||||
};
|
||||
}
|
||||
}),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
})
|
||||
.patch("/:id/translations", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid category ID" });
|
||||
const payload = body;
|
||||
const results = [];
|
||||
for (const lang of LANGS) {
|
||||
const name = typeof payload.name === 'string'
|
||||
? (lang === 'th' ? payload.name : null)
|
||||
: payload.name?.[lang];
|
||||
if (!name)
|
||||
continue;
|
||||
const existing = await prisma.categoryTranslation.findUnique({
|
||||
where: { categoryId_langCode: { categoryId: id, langCode: lang } }
|
||||
});
|
||||
if (existing) {
|
||||
const updated = await prisma.categoryTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: { name },
|
||||
});
|
||||
results.push(updated);
|
||||
}
|
||||
else {
|
||||
const created = await prisma.categoryTranslation.create({
|
||||
data: { categoryId: id, langCode: lang, name },
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
const updated = await prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true, _count: { select: { products: true } } },
|
||||
});
|
||||
return { success: true, translations: results, category: updated };
|
||||
})
|
||||
.post("/", async ({ body, status }) => {
|
||||
try {
|
||||
const category = await prisma.category.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
imageUrl: body.imageUrl,
|
||||
// Auto-seed Thai translation
|
||||
translations: { create: { langCode: 'th', name: body.name } },
|
||||
},
|
||||
});
|
||||
return category;
|
||||
}
|
||||
catch (err) {
|
||||
if (err.code === "P2002") {
|
||||
return status(400, { message: "Category name already exists" });
|
||||
}
|
||||
return status(500, { message: "Failed to create category" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
})
|
||||
})
|
||||
.put("/:id", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid category ID" });
|
||||
try {
|
||||
const category = await prisma.category.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: body.name,
|
||||
imageUrl: body.imageUrl,
|
||||
},
|
||||
});
|
||||
return category;
|
||||
}
|
||||
catch (err) {
|
||||
if (err.code === "P2025") {
|
||||
return status(404, { message: "Category not found" });
|
||||
}
|
||||
if (err.code === "P2002") {
|
||||
return status(400, { message: "Category name already exists" });
|
||||
}
|
||||
console.error("Update category error:", err);
|
||||
return status(500, { message: "Failed to update category" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String()),
|
||||
imageUrl: t.Optional(t.Union([t.String(), t.Null()])),
|
||||
})
|
||||
})
|
||||
.delete("/:id", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid category ID" });
|
||||
try {
|
||||
const category = await prisma.category.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { products: true } } }
|
||||
});
|
||||
if (!category)
|
||||
return status(404, { message: "Category not found" });
|
||||
if (category._count.products > 0) {
|
||||
return status(400, { message: "Cannot delete category with existing products" });
|
||||
}
|
||||
await prisma.category.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
return status(500, { message: "Failed to delete category" });
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=categories.js.map
|
||||
@@ -1,186 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const couponsRoute = new Elysia({ prefix: "/coupons" })
|
||||
// Get all coupons (Admin)
|
||||
.get("/", async () => {
|
||||
return await prisma.coupon.findMany({
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
})
|
||||
// Get active coupons (Storefront)
|
||||
.get("/active", async () => {
|
||||
const now = new Date();
|
||||
return await prisma.coupon.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: now } }
|
||||
]
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
})
|
||||
// Create new coupon (Admin)
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
// Check if code already exists
|
||||
const existing = await prisma.coupon.findUnique({
|
||||
where: { code: data.code.toUpperCase() }
|
||||
});
|
||||
if (existing) {
|
||||
set.status = 400;
|
||||
return { error: "Coupon code already exists" };
|
||||
}
|
||||
const coupon = await prisma.coupon.create({
|
||||
data: {
|
||||
code: data.code.toUpperCase(),
|
||||
type: data.type,
|
||||
value: data.value,
|
||||
minPurchase: data.minPurchase || 0,
|
||||
maxDiscount: data.maxDiscount || null,
|
||||
usageLimit: data.usageLimit || null,
|
||||
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
|
||||
isActive: data.isActive !== undefined ? data.isActive : true
|
||||
}
|
||||
});
|
||||
return coupon;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Coupons] Create Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to create coupon" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
code: t.String(),
|
||||
type: t.String(),
|
||||
value: t.Number(),
|
||||
minPurchase: t.Optional(t.Number()),
|
||||
maxDiscount: t.Optional(t.Nullable(t.Number())),
|
||||
usageLimit: t.Optional(t.Nullable(t.Number())),
|
||||
expiresAt: t.Optional(t.Nullable(t.String())),
|
||||
isActive: t.Optional(t.Boolean())
|
||||
})
|
||||
})
|
||||
// Update coupon (Admin)
|
||||
.patch("/:id", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
const coupon = await prisma.coupon.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(data.code && { code: data.code.toUpperCase() }),
|
||||
...(data.type && { type: data.type }),
|
||||
...(data.value !== undefined && { value: data.value }),
|
||||
...(data.minPurchase !== undefined && { minPurchase: data.minPurchase }),
|
||||
...(data.maxDiscount !== undefined && { maxDiscount: data.maxDiscount }),
|
||||
...(data.usageLimit !== undefined && { usageLimit: data.usageLimit }),
|
||||
...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : null }),
|
||||
...(data.isActive !== undefined && { isActive: data.isActive })
|
||||
}
|
||||
});
|
||||
return coupon;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Coupons] Update Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update coupon" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
code: t.Optional(t.String()),
|
||||
type: t.Optional(t.String()),
|
||||
value: t.Optional(t.Number()),
|
||||
minPurchase: t.Optional(t.Number()),
|
||||
maxDiscount: t.Optional(t.Nullable(t.Number())),
|
||||
usageLimit: t.Optional(t.Nullable(t.Number())),
|
||||
expiresAt: t.Optional(t.Nullable(t.String())),
|
||||
isActive: t.Optional(t.Boolean())
|
||||
})
|
||||
})
|
||||
// Delete coupon (Admin)
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.coupon.delete({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete coupon" };
|
||||
}
|
||||
})
|
||||
// Validate coupon (Storefront)
|
||||
.post("/validate", async ({ body, set }) => {
|
||||
try {
|
||||
const { code, cartTotal } = body;
|
||||
const upperCode = code.toUpperCase();
|
||||
const coupon = await prisma.coupon.findUnique({
|
||||
where: { code: upperCode }
|
||||
});
|
||||
if (!coupon) {
|
||||
set.status = 404;
|
||||
return { valid: false, error: "Coupon not found" };
|
||||
}
|
||||
if (!coupon.isActive) {
|
||||
set.status = 400;
|
||||
return { valid: false, error: "Coupon is no longer active" };
|
||||
}
|
||||
if (coupon.expiresAt && new Date() > coupon.expiresAt) {
|
||||
set.status = 400;
|
||||
return { valid: false, error: "Coupon has expired" };
|
||||
}
|
||||
if (coupon.usageLimit && coupon.usedCount >= coupon.usageLimit) {
|
||||
set.status = 400;
|
||||
return { valid: false, error: "Coupon usage limit reached" };
|
||||
}
|
||||
if (cartTotal < coupon.minPurchase) {
|
||||
set.status = 400;
|
||||
return { valid: false, error: `Minimum purchase of ฿${coupon.minPurchase} required` };
|
||||
}
|
||||
// Calculate discount amount
|
||||
let discountAmount = 0;
|
||||
if (coupon.type === "PERCENTAGE") {
|
||||
discountAmount = cartTotal * (coupon.value / 100);
|
||||
if (coupon.maxDiscount && discountAmount > coupon.maxDiscount) {
|
||||
discountAmount = coupon.maxDiscount;
|
||||
}
|
||||
}
|
||||
else if (coupon.type === "FIXED_AMOUNT") {
|
||||
discountAmount = coupon.value;
|
||||
}
|
||||
else if (coupon.type === "FREE_SHIPPING") {
|
||||
// We handle free shipping separately, returning discountAmount = 0 but a flag
|
||||
return {
|
||||
valid: true,
|
||||
coupon,
|
||||
discountAmount: 0,
|
||||
isFreeShipping: true
|
||||
};
|
||||
}
|
||||
// Ensure discount doesn't exceed cart total
|
||||
if (discountAmount > cartTotal) {
|
||||
discountAmount = cartTotal;
|
||||
}
|
||||
return {
|
||||
valid: true,
|
||||
coupon,
|
||||
discountAmount,
|
||||
isFreeShipping: false
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Coupons] Validate Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to validate coupon" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
code: t.String(),
|
||||
cartTotal: t.Number()
|
||||
})
|
||||
});
|
||||
//# sourceMappingURL=coupons.js.map
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const customersRoute = new Elysia({ prefix: "/customers" })
|
||||
.get("/", async ({ query }) => {
|
||||
const search = query.search;
|
||||
const whereClause = search ? {
|
||||
OR: [
|
||||
{ displayName: { contains: search, mode: "insensitive" } },
|
||||
{ email: { contains: search, mode: "insensitive" } },
|
||||
{ phone: { contains: search, mode: "insensitive" } },
|
||||
{ lineUserId: { contains: search, mode: "insensitive" } }
|
||||
]
|
||||
} : {};
|
||||
return await prisma.customer.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
_count: {
|
||||
select: { orders: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.get("/:id", async ({ params: { id }, set }) => {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: { orders: { orderBy: { createdAt: 'desc' } } }
|
||||
});
|
||||
if (!customer) {
|
||||
set.status = 404;
|
||||
return { error: "Customer not found" };
|
||||
}
|
||||
return customer;
|
||||
})
|
||||
.patch("/:id/points", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const { pointsDelta, reason } = body;
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
if (!customer) {
|
||||
set.status = 404;
|
||||
return { error: "Customer not found" };
|
||||
}
|
||||
const newPoints = Math.max(0, customer.points + pointsDelta);
|
||||
const updatedCustomer = await prisma.customer.update({
|
||||
where: { id: Number(id) },
|
||||
data: { points: newPoints }
|
||||
});
|
||||
return updatedCustomer;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Customers] Adjust Points Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to adjust points" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
pointsDelta: t.Number(),
|
||||
reason: t.Optional(t.String())
|
||||
})
|
||||
});
|
||||
//# sourceMappingURL=customers.js.map
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const heroRoute = new Elysia({ prefix: "/hero" })
|
||||
.get("/", async () => {
|
||||
return await prisma.hero.findFirst({
|
||||
where: { isActive: true },
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
}) || await prisma.hero.findFirst({
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
})
|
||||
.get("/all", async () => {
|
||||
return await prisma.hero.findMany({
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
});
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
const { id, ...heroData } = data;
|
||||
if (id) {
|
||||
return await prisma.hero.update({
|
||||
where: { id: Number(id) },
|
||||
data: heroData
|
||||
});
|
||||
}
|
||||
else {
|
||||
return await prisma.hero.create({
|
||||
data: heroData
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Hero] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to save hero data" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
id: t.Optional(t.Number()),
|
||||
tagline: t.String(),
|
||||
title: t.String(),
|
||||
description: t.String(),
|
||||
imageUrl: t.String(),
|
||||
buttonText: t.String(),
|
||||
buttonLink: t.String(),
|
||||
isActive: t.Boolean()
|
||||
})
|
||||
})
|
||||
.patch("/:id/activate", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.$transaction([
|
||||
prisma.hero.updateMany({
|
||||
data: { isActive: false }
|
||||
}),
|
||||
prisma.hero.update({
|
||||
where: { id: Number(id) },
|
||||
data: { isActive: true }
|
||||
})
|
||||
]);
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to activate hero" };
|
||||
}
|
||||
})
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.hero.delete({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete hero" };
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=hero.js.map
|
||||
@@ -74,4 +74,54 @@ export const heroRoute = new Elysia({ prefix: "/hero" })
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete hero" };
|
||||
}
|
||||
})
|
||||
.patch("/:id/translations", async ({ params, body, set }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid hero ID" };
|
||||
}
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
|
||||
for (const lang of LANGS) {
|
||||
const tagline = typeof payload.tagline === 'string' ? (lang === 'th' ? payload.tagline : null) : payload.tagline?.[lang];
|
||||
const title = typeof payload.title === 'string' ? (lang === 'th' ? payload.title : null) : payload.title?.[lang];
|
||||
const description = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description?.[lang];
|
||||
const buttonText = typeof payload.buttonText === 'string' ? (lang === 'th' ? payload.buttonText : null) : payload.buttonText?.[lang];
|
||||
|
||||
if (!tagline && !title && !description && !buttonText) continue;
|
||||
|
||||
const existing = await prisma.heroTranslation.findUnique({
|
||||
where: { heroId_langCode: { heroId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
const updateData: any = {};
|
||||
if (tagline) updateData.tagline = tagline;
|
||||
if (title) updateData.title = title;
|
||||
if (description) updateData.description = description;
|
||||
if (buttonText) updateData.buttonText = buttonText;
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.heroTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.heroTranslation.create({
|
||||
data: { heroId: id, langCode: lang, ...updateData }
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.hero.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true }
|
||||
});
|
||||
|
||||
return { success: true, translations: results, hero: updated };
|
||||
});
|
||||
@@ -1,348 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
const toNum = (val) => {
|
||||
if (val === null || val === undefined || val === "")
|
||||
return 0;
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
const str = (val) => String(val ?? "").trim();
|
||||
const MONTHS = { Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06', Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12' };
|
||||
const parseLazadaDate = (val) => {
|
||||
const d = str(val);
|
||||
if (!d)
|
||||
return "";
|
||||
// e.g. "25 Apr 2026 23:24"
|
||||
const parts = d.split(" ");
|
||||
if (parts.length >= 4) {
|
||||
const [day, monthStr, year, time] = parts;
|
||||
const month = MONTHS[monthStr] || "01";
|
||||
return `${year}-${month}-${day.padStart(2, "0")} ${time}:00`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
export const lazadaOrderRoute = new Elysia({ prefix: "/lazada-order" })
|
||||
// ── Import (Excel rows as JSON) ───────────────────────────────────────────
|
||||
.post("/import", async ({ body, set }) => {
|
||||
try {
|
||||
const { rows, shop } = body;
|
||||
const shopName = shop || "hom-aroma";
|
||||
console.log(`[LazadaOrder] Received ${rows.length} raw rows for shop: ${shopName}`);
|
||||
// Group rows by orderNumber
|
||||
const orderMap = new Map();
|
||||
for (const row of rows) {
|
||||
const orderNumber = str(row["orderNumber"]);
|
||||
if (!orderNumber)
|
||||
continue;
|
||||
if (!orderMap.has(orderNumber))
|
||||
orderMap.set(orderNumber, { meta: row, items: [] });
|
||||
orderMap.get(orderNumber).items.push(row);
|
||||
}
|
||||
console.log(`[LazadaOrder] Grouped into ${orderMap.size} unique orders`);
|
||||
const CHUNK = 30;
|
||||
const entries = Array.from(orderMap.entries());
|
||||
let upserted = 0;
|
||||
for (let i = 0; i < entries.length; i += CHUNK) {
|
||||
const chunk = entries.slice(i, i + CHUNK);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [orderNumber, { meta, items }] of chunk) {
|
||||
// Sync strategy: delete old items
|
||||
await tx.lazadaOrderItem.deleteMany({ where: { orderNumber } });
|
||||
let totalPaidPrice = 0;
|
||||
let totalShippingFee = 0;
|
||||
const itemsData = items.map((item) => {
|
||||
const paidPrice = toNum(item["paidPrice"]);
|
||||
const shippingFee = toNum(item["shippingFee"]);
|
||||
totalPaidPrice += paidPrice;
|
||||
totalShippingFee += shippingFee;
|
||||
return {
|
||||
orderItemId: str(item["orderItemId"]),
|
||||
sellerSku: str(item["sellerSku"]),
|
||||
lazadaSku: str(item["lazadaSku"]),
|
||||
itemName: str(item["itemName"]),
|
||||
variation: str(item["variation"]),
|
||||
unitPrice: toNum(item["unitPrice"]),
|
||||
paidPrice,
|
||||
sellerDiscountTotal: toNum(item["sellerDiscountTotal"]),
|
||||
platformDiscountTotal: toNum(item["platformDiscountTotal"]),
|
||||
shippingFee,
|
||||
status: str(item["status"]),
|
||||
};
|
||||
});
|
||||
// Decide order status based on items (or first item)
|
||||
// We'll just use the first item's status if they share it
|
||||
const orderStatus = str(meta["status"]);
|
||||
const orderData = {
|
||||
shop: shopName,
|
||||
createTime: parseLazadaDate(meta["createTime"]),
|
||||
updateTime: parseLazadaDate(meta["updateTime"]),
|
||||
deliveredDate: parseLazadaDate(meta["deliveredDate"]),
|
||||
customerName: str(meta["customerName"]),
|
||||
shippingCity: str(meta["shippingCity"]),
|
||||
shippingPostCode: str(meta["shippingPostCode"]),
|
||||
shippingRegion: str(meta["shippingRegion"]),
|
||||
payMethod: str(meta["payMethod"]),
|
||||
status: orderStatus,
|
||||
trackingCode: str(meta["trackingCode"]),
|
||||
shippingProvider: str(meta["shippingProvider"]),
|
||||
orderAmount: totalPaidPrice,
|
||||
shippingFee: totalShippingFee,
|
||||
};
|
||||
await tx.lazadaOrder.upsert({
|
||||
where: { orderNumber },
|
||||
update: { ...orderData, items: { create: itemsData } },
|
||||
create: { orderNumber, ...orderData, items: { create: itemsData } },
|
||||
});
|
||||
upserted++;
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
console.log(`[LazadaOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
|
||||
}
|
||||
console.log(`[LazadaOrder] ✅ Done — ${upserted} orders upserted`);
|
||||
return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length };
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[LazadaOrder] ❌ Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}, { body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) })
|
||||
// ── Clear Shop Data ───────────────────────────────────────────────────────
|
||||
.delete("/clear", async ({ query, set }) => {
|
||||
try {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const result = await prisma.lazadaOrder.deleteMany({ where: { shop } });
|
||||
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
|
||||
}
|
||||
catch (error) {
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
})
|
||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
||||
.get("/orders/:orderNumber", async ({ params, set }) => {
|
||||
const order = await prisma.lazadaOrder.findUnique({ where: { orderNumber: params.orderNumber }, include: { items: true } });
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
return order;
|
||||
})
|
||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
||||
.get("/orders", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 30);
|
||||
const skip = (page - 1) * limit;
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.lazadaOrder.findMany({ where, include: { items: true }, orderBy: { createTime: "desc" }, skip, take: limit }),
|
||||
prisma.lazadaOrder.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
||||
.get("/summary", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [orderCount, agg] = await Promise.all([
|
||||
prisma.lazadaOrder.count({ where }),
|
||||
prisma.lazadaOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true } }),
|
||||
]);
|
||||
return {
|
||||
orderCount,
|
||||
totalAmount: agg._sum.orderAmount ?? 0,
|
||||
totalShipping: agg._sum.shippingFee ?? 0,
|
||||
};
|
||||
})
|
||||
// ── GET Analytics (Single Shop) ───────────────────────────────────────────
|
||||
.get("/analytics", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const dateFrom = query.dateFrom;
|
||||
const dateTo = query.dateTo;
|
||||
const where = { shop };
|
||||
if (dateFrom || dateTo) {
|
||||
where.createTime = {};
|
||||
if (dateFrom)
|
||||
where.createTime.gte = dateFrom;
|
||||
if (dateTo)
|
||||
where.createTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
const allOrders = await prisma.lazadaOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderNumber: true, status: true, createTime: true,
|
||||
orderAmount: true, shippingFee: true, payMethod: true,
|
||||
items: { select: { itemName: true, variation: true, sellerDiscountTotal: true, platformDiscountTotal: true, paidPrice: true, sellerSku: true } },
|
||||
},
|
||||
});
|
||||
// Monthly aggregation
|
||||
const monthMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthMap.has(month))
|
||||
monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 });
|
||||
const m = monthMap.get(month);
|
||||
m.orders += 1;
|
||||
m.revenue += o.orderAmount;
|
||||
m.platformDiscount += o.items.reduce((s, i) => s + Math.abs(i.platformDiscountTotal), 0);
|
||||
m.sellerDiscount += o.items.reduce((s, i) => s + Math.abs(i.sellerDiscountTotal), 0);
|
||||
if (o.status?.toLowerCase().includes("canceled") || o.status?.toLowerCase().includes("failed"))
|
||||
m.cancelled += 1;
|
||||
m.items += o.items.length;
|
||||
}
|
||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Status breakdown
|
||||
const statusMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const key = o.status || "Unknown";
|
||||
statusMap.set(key, (statusMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const statusBreakdown = Array.from(statusMap.entries()).map(([status, count]) => ({ status, count })).sort((a, b) => b.count - a.count);
|
||||
// Payment method breakdown
|
||||
const payMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const key = o.payMethod || "Unknown";
|
||||
payMap.set(key, (payMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const paymentBreakdown = Array.from(payMap.entries()).map(([method, count]) => ({ method, count })).sort((a, b) => b.count - a.count);
|
||||
// Top products
|
||||
const productMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
for (const item of o.items) {
|
||||
const key = `${item.itemName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key))
|
||||
productMap.set(key, { name: item.itemName, variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key);
|
||||
p.qty += 1; // Lazada export is 1 row per item quantity usually, so we add 1
|
||||
p.revenue += item.paidPrice;
|
||||
}
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
// Summary
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + Math.abs(i.platformDiscountTotal), 0);
|
||||
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + Math.abs(i.sellerDiscountTotal), 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status?.toLowerCase().includes("canceled")).length;
|
||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
||||
const completedOrders = totalOrders - cancelledOrders;
|
||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
|
||||
return {
|
||||
monthly, statusBreakdown, paymentBreakdown, topProducts,
|
||||
summary: {
|
||||
totalRevenue, totalShipping, totalPlatformDisc, totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping, // Simple net (excluding lazada fees which aren't in this report usually)
|
||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
||||
},
|
||||
};
|
||||
})
|
||||
// ── GET Analytics Overview (ALL shops combined) ──────────────────────────
|
||||
.get("/analytics-overview", async ({ query }) => {
|
||||
const dateFrom = query.dateFrom;
|
||||
const dateTo = query.dateTo;
|
||||
const where = {};
|
||||
if (dateFrom || dateTo) {
|
||||
where.createTime = {};
|
||||
if (dateFrom)
|
||||
where.createTime.gte = dateFrom;
|
||||
if (dateTo)
|
||||
where.createTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
const allOrders = await prisma.lazadaOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderNumber: true, shop: true, status: true, createTime: true,
|
||||
orderAmount: true, shippingFee: true,
|
||||
items: { select: { platformDiscountTotal: true, sellerDiscountTotal: true, paidPrice: true, itemName: true, variation: true } },
|
||||
},
|
||||
});
|
||||
// Per-shop summary
|
||||
const shopMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
if (!shopMap.has(o.shop)) {
|
||||
shopMap.set(o.shop, { shop: o.shop, orders: 0, completed: 0, cancelled: 0, revenue: 0, shippingFee: 0, platformDiscount: 0, sellerDiscount: 0, items: 0 });
|
||||
}
|
||||
const s = shopMap.get(o.shop);
|
||||
s.orders += 1;
|
||||
s.revenue += o.orderAmount;
|
||||
s.shippingFee += o.shippingFee;
|
||||
s.platformDiscount += o.items.reduce((acc, i) => acc + Math.abs(i.platformDiscountTotal), 0);
|
||||
s.sellerDiscount += o.items.reduce((acc, i) => acc + Math.abs(i.sellerDiscountTotal), 0);
|
||||
s.items += o.items.length;
|
||||
if (o.status?.toLowerCase().includes("canceled"))
|
||||
s.cancelled += 1;
|
||||
else
|
||||
s.completed += 1;
|
||||
}
|
||||
const shops = Array.from(shopMap.values());
|
||||
// Monthly stacked by shop
|
||||
const monthShopMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthShopMap.has(month))
|
||||
monthShopMap.set(month, new Map());
|
||||
const sm = monthShopMap.get(month);
|
||||
if (!sm.has(o.shop))
|
||||
sm.set(o.shop, { revenue: 0, orders: 0 });
|
||||
const ms = sm.get(o.shop);
|
||||
ms.revenue += o.orderAmount;
|
||||
ms.orders += 1;
|
||||
}
|
||||
const shopKeys = Array.from(shopMap.keys());
|
||||
const monthly = Array.from(monthShopMap.entries())
|
||||
.map(([month, sm]) => {
|
||||
const row = { month };
|
||||
for (const shop of shopKeys) {
|
||||
const d = sm.get(shop) ?? { revenue: 0, orders: 0 };
|
||||
row[`${shop}_revenue`] = d.revenue;
|
||||
row[`${shop}_orders`] = d.orders;
|
||||
}
|
||||
row.totalRevenue = Array.from(sm.values()).reduce((s, v) => s + v.revenue, 0);
|
||||
row.totalOrders = Array.from(sm.values()).reduce((s, v) => s + v.orders, 0);
|
||||
row.revenue = row.totalRevenue; // Provide fallback for global view
|
||||
return row;
|
||||
})
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Top Products Global
|
||||
const productMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
for (const item of o.items) {
|
||||
const key = `${item.itemName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key))
|
||||
productMap.set(key, { name: item.itemName, variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key);
|
||||
p.qty += 1;
|
||||
p.revenue += item.paidPrice;
|
||||
}
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
// Global summary
|
||||
const totalRevenue = shops.reduce((s, sh) => s + sh.revenue, 0);
|
||||
const totalShipping = shops.reduce((s, sh) => s + sh.shippingFee, 0);
|
||||
const totalPlatformDisc = shops.reduce((s, sh) => s + sh.platformDiscount, 0);
|
||||
const totalSellerDisc = shops.reduce((s, sh) => s + sh.sellerDiscount, 0);
|
||||
const totalOrders = shops.reduce((s, sh) => s + sh.orders, 0);
|
||||
const completedOrders = shops.reduce((s, sh) => s + sh.completed, 0);
|
||||
const cancelledOrders = shops.reduce((s, sh) => s + sh.cancelled, 0);
|
||||
return {
|
||||
shops,
|
||||
monthly,
|
||||
topProducts,
|
||||
summary: {
|
||||
totalRevenue,
|
||||
totalShipping,
|
||||
totalPlatformDisc,
|
||||
totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping,
|
||||
totalOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
cancelRate: totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0,
|
||||
aov: completedOrders > 0 ? totalRevenue / completedOrders : 0,
|
||||
}
|
||||
};
|
||||
});
|
||||
//# sourceMappingURL=lazadaOrder.js.map
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Elysia } from 'elysia';
|
||||
import * as line from '@line/bot-sdk';
|
||||
import { lineConfig, lineClient } from '../lib/lineClient';
|
||||
import crypto from 'crypto';
|
||||
const webhookHandler = async ({ request, body, set }) => {
|
||||
console.log('[LINE Webhook] Handler body type:', typeof body);
|
||||
console.log('[LINE Webhook] Channel Secret configured:', !!lineConfig.channelSecret, lineConfig.channelSecret ? lineConfig.channelSecret.substring(0, 4) + '...' : 'NONE');
|
||||
// 1. Verify Signature
|
||||
const signature = request.headers.get('x-line-signature');
|
||||
if (!signature) {
|
||||
set.status = 400;
|
||||
return { error: 'Missing Signature' };
|
||||
}
|
||||
// xxIn our global onParse hook, we intercepted the body and returned it as a raw text string.
|
||||
const rawBody = typeof body === 'string' ? body : JSON.stringify(body);
|
||||
if (!rawBody) {
|
||||
set.status = 400;
|
||||
return { error: 'Empty Body' };
|
||||
}
|
||||
const hash = crypto
|
||||
.createHmac('SHA256', lineConfig.channelSecret)
|
||||
.update(rawBody)
|
||||
.digest('base64');
|
||||
console.log('[LINE Webhook] Signature Check - Received:', signature, 'Calculated:', hash);
|
||||
if (hash !== signature) {
|
||||
console.error('[LINE Webhook] Invalid Signature Blocked');
|
||||
set.status = 401;
|
||||
return { error: 'Invalid Signature' };
|
||||
}
|
||||
try {
|
||||
const parsedBody = typeof body === 'string' ? JSON.parse(body) : body;
|
||||
const events = parsedBody.events || [];
|
||||
for (const event of events) {
|
||||
if (event.type === 'message' && event.message.type === 'text') {
|
||||
// Ignore verification dummy tokens
|
||||
if (event.replyToken === '00000000000000000000000000000000' || event.replyToken === 'ffffffffffffffffffffffffffffffff') {
|
||||
continue;
|
||||
}
|
||||
// Simple echo bot for now
|
||||
await lineClient.replyMessage({
|
||||
replyToken: event.replyToken,
|
||||
messages: [{
|
||||
type: 'text',
|
||||
text: `คุณส่งข้อความว่า: ${event.message.text}`
|
||||
}]
|
||||
});
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[LINE Webhook] Error:', err);
|
||||
set.status = 500;
|
||||
return { error: 'Webhook processing failed' };
|
||||
}
|
||||
};
|
||||
export const lineRoutes = new Elysia({ prefix: '/line' })
|
||||
.post('/webhook', webhookHandler)
|
||||
.post('/webhook/', webhookHandler);
|
||||
//# sourceMappingURL=line.js.map
|
||||
@@ -1,106 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
.get("/", async () => {
|
||||
return await prisma.menu.findMany({
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { order: "asc" }
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.get("/:name", async ({ params: { name }, set }) => {
|
||||
const menu = await prisma.menu.findUnique({
|
||||
where: { name },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { order: "asc" }
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!menu) {
|
||||
// Return fallback defaults instead of 404 to prevent frontend breakage
|
||||
if (name === "main") {
|
||||
return { name, items: [{ id: 1, label: "Home", url: "/", order: 1 }, { id: 2, label: "Shop", url: "/shop", order: 2 }, { id: 3, label: "Scent Guide", url: "/scent-guide", order: 3 }, { id: 4, label: "About Us", url: "/about", order: 4 }] };
|
||||
}
|
||||
else if (name === "footer-explore") {
|
||||
return { name, items: [{ id: 5, label: "Home", url: "/", order: 1 }, { id: 6, label: "Shop", url: "/shop", order: 2 }, { id: 7, label: "Scent Guide", url: "/scent-guide", order: 3 }, { id: 8, label: "About Us", url: "/about", order: 4 }] };
|
||||
}
|
||||
else if (name === "footer-support") {
|
||||
return { name, items: [{ id: 9, label: "Track Order", url: "/track-order", order: 1 }, { id: 10, label: "FAQ", url: "/faq", order: 2 }, { id: 11, label: "Contact Us", url: "/contact", order: 3 }, { id: 12, label: "Delivery & Returns", url: "/delivery", order: 4 }] };
|
||||
}
|
||||
return { name, items: [] };
|
||||
}
|
||||
return menu;
|
||||
})
|
||||
.use(adminOnly)
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const { name, items } = body;
|
||||
const menu = await prisma.menu.create({
|
||||
data: {
|
||||
name,
|
||||
items: items ? {
|
||||
create: items
|
||||
} : undefined
|
||||
},
|
||||
include: { items: true }
|
||||
});
|
||||
return menu;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to create menu" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
items: t.Optional(t.Array(t.Object({
|
||||
label: t.String(),
|
||||
url: t.String(),
|
||||
order: t.Number()
|
||||
})))
|
||||
})
|
||||
})
|
||||
.put("/:name", async ({ params: { name }, body, set }) => {
|
||||
try {
|
||||
const { items } = body;
|
||||
let menu = await prisma.menu.findUnique({ where: { name } });
|
||||
if (!menu) {
|
||||
menu = await prisma.menu.create({ data: { name } });
|
||||
}
|
||||
await prisma.$transaction([
|
||||
prisma.menuItem.deleteMany({ where: { menuId: menu.id } }),
|
||||
prisma.menuItem.createMany({
|
||||
data: items.map(item => ({
|
||||
menuId: menu.id,
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
order: item.order
|
||||
}))
|
||||
})
|
||||
]);
|
||||
return await prisma.menu.findUnique({
|
||||
where: { name },
|
||||
include: { items: { orderBy: { order: "asc" } } }
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update menu" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
items: t.Array(t.Object({
|
||||
id: t.Optional(t.Number()),
|
||||
label: t.String(),
|
||||
url: t.String(),
|
||||
order: t.Number()
|
||||
}))
|
||||
})
|
||||
});
|
||||
//# sourceMappingURL=menus.js.map
|
||||
@@ -102,4 +102,44 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
order: t.Number()
|
||||
}))
|
||||
})
|
||||
})
|
||||
.patch("/items/:id/translations", async ({ params, body, set }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid menu item ID" };
|
||||
}
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
|
||||
for (const lang of LANGS) {
|
||||
const label = typeof payload.label === 'string' ? (lang === 'th' ? payload.label : null) : payload.label?.[lang];
|
||||
if (!label) continue;
|
||||
|
||||
const existing = await prisma.menuItemTranslation.findUnique({
|
||||
where: { menuItemId_langCode: { menuItemId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.menuItemTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: { label }
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.menuItemTranslation.create({
|
||||
data: { menuItemId: id, langCode: lang, label }
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.menuItem.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true }
|
||||
});
|
||||
|
||||
return { success: true, translations: results, menuItem: updated };
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const newsletterRoute = new Elysia({ prefix: "/newsletter" })
|
||||
.post("/subscribe", async ({ body, set }) => {
|
||||
try {
|
||||
const { email } = body;
|
||||
const existing = await prisma.newsletterSubscriber.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
if (existing) {
|
||||
return { message: "Email already subscribed" };
|
||||
}
|
||||
await prisma.newsletterSubscriber.create({
|
||||
data: { email }
|
||||
});
|
||||
return { message: "Successfully subscribed" };
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to subscribe" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.String({ format: "email" })
|
||||
})
|
||||
})
|
||||
.get("/subscribers", async () => {
|
||||
return await prisma.newsletterSubscriber.findMany({
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
});
|
||||
//# sourceMappingURL=newsletter.js.map
|
||||
@@ -1,247 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { sendOrderCreatedNotification, sendPaymentConfirmedNotification, sendOrderShippedNotification, sendOrderCancelledNotification, markOrderAsPaid } from "../lib/notifications";
|
||||
export const ordersRoute = new Elysia({ prefix: "/orders" })
|
||||
.get("/", async () => {
|
||||
return await prisma.order.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { items: true, customer: true }
|
||||
});
|
||||
})
|
||||
.get("/:id", async ({ params: { id }, set }) => {
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: Number(id) },
|
||||
include: { items: true, customer: true }
|
||||
});
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
return order;
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
const settings = await prisma.storeSettings.findFirst();
|
||||
const earnRate = settings?.pointsEarnRate || 1.0;
|
||||
const netAmount = data.totalAmount - data.shippingFee;
|
||||
const pointsEarned = Math.floor(Math.max(0, netAmount) * earnRate);
|
||||
if (data.customerId && data.pointsUsed && data.pointsUsed > 0) {
|
||||
// Verify customer has enough points
|
||||
const customer = await prisma.customer.findUnique({ where: { id: data.customerId } });
|
||||
if (!customer || customer.points < data.pointsUsed) {
|
||||
set.status = 400;
|
||||
return { error: "Insufficient points" };
|
||||
}
|
||||
// Deduct points immediately
|
||||
await prisma.customer.update({
|
||||
where: { id: data.customerId },
|
||||
data: { points: { decrement: data.pointsUsed } }
|
||||
});
|
||||
}
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
customerId: data.customerId,
|
||||
customerName: data.customerName,
|
||||
customerEmail: data.customerEmail,
|
||||
customerPhone: data.customerPhone,
|
||||
shippingAddress: data.shippingAddress,
|
||||
subtotal: data.subtotal || 0,
|
||||
promoLevel1Name: data.promoLevel1Name,
|
||||
promoLevel1Disc: data.promoLevel1Disc || 0,
|
||||
promoLevel2Name: data.promoLevel2Name,
|
||||
promoLevel2Disc: data.promoLevel2Disc || 0,
|
||||
couponCode: data.couponCode || null,
|
||||
discountAmount: data.discountAmount || 0,
|
||||
pointsUsed: data.pointsUsed || 0,
|
||||
pointsDiscount: data.pointsDiscount || 0,
|
||||
pointsEarned: pointsEarned,
|
||||
shippingFee: data.shippingFee,
|
||||
totalAmount: data.totalAmount,
|
||||
items: {
|
||||
create: data.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
variantId: item.variantId,
|
||||
quantity: item.quantity,
|
||||
priceAtPurchase: item.priceAtPurchase,
|
||||
productName: item.productName,
|
||||
variantSize: item.variantSize,
|
||||
productImage: item.productImage
|
||||
}))
|
||||
}
|
||||
},
|
||||
include: { items: true }
|
||||
});
|
||||
// If a coupon was used, increment its usedCount
|
||||
if (data.couponCode) {
|
||||
try {
|
||||
await prisma.coupon.update({
|
||||
where: { code: data.couponCode.toUpperCase() },
|
||||
data: { usedCount: { increment: 1 } }
|
||||
});
|
||||
}
|
||||
catch (e) {
|
||||
console.error("Failed to increment coupon usedCount:", e);
|
||||
}
|
||||
}
|
||||
// Trigger LINE notification asynchronously
|
||||
sendOrderCreatedNotification(order.id).catch(e => {
|
||||
console.error("Failed to trigger sendOrderCreatedNotification:", e);
|
||||
});
|
||||
return order;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Orders] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to create order" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
customerId: t.Optional(t.Nullable(t.Number())),
|
||||
customerName: t.String(),
|
||||
customerEmail: t.String(),
|
||||
customerPhone: t.String(),
|
||||
shippingAddress: t.String(),
|
||||
totalAmount: t.Number(),
|
||||
shippingFee: t.Number(),
|
||||
subtotal: t.Optional(t.Number()),
|
||||
promoLevel1Name: t.Optional(t.Nullable(t.String())),
|
||||
promoLevel1Disc: t.Optional(t.Number()),
|
||||
promoLevel2Name: t.Optional(t.Nullable(t.String())),
|
||||
promoLevel2Disc: t.Optional(t.Number()),
|
||||
couponCode: t.Optional(t.Nullable(t.String())),
|
||||
discountAmount: t.Optional(t.Number()),
|
||||
pointsUsed: t.Optional(t.Number()),
|
||||
pointsDiscount: t.Optional(t.Number()),
|
||||
items: t.Array(t.Object({
|
||||
productId: t.Number(),
|
||||
variantId: t.Optional(t.Number()),
|
||||
quantity: t.Number(),
|
||||
priceAtPurchase: t.Number(),
|
||||
productName: t.String(),
|
||||
variantSize: t.Optional(t.String()),
|
||||
productImage: t.Optional(t.String())
|
||||
}))
|
||||
})
|
||||
})
|
||||
.patch("/:id/status", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const { status } = body;
|
||||
const order = await prisma.order.findUnique({ where: { id: Number(id) } });
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
if (status === 'PAID') {
|
||||
await markOrderAsPaid(order.id);
|
||||
}
|
||||
else {
|
||||
await prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status }
|
||||
});
|
||||
// If status becomes CANCELLED, refund the points used and notify
|
||||
if (status === 'CANCELLED' && order.status !== 'CANCELLED') {
|
||||
if (order.customerId && order.pointsUsed > 0) {
|
||||
await prisma.customer.update({
|
||||
where: { id: order.customerId },
|
||||
data: { points: { increment: order.pointsUsed } }
|
||||
});
|
||||
}
|
||||
sendOrderCancelledNotification(order.id).catch(e => console.error(e));
|
||||
}
|
||||
// If status becomes SHIPPED, notify
|
||||
if (status === 'SHIPPED' && order.status !== 'SHIPPED') {
|
||||
sendOrderShippedNotification(order.id).catch(e => console.error(e));
|
||||
}
|
||||
}
|
||||
return await prisma.order.findUnique({
|
||||
where: { id: order.id },
|
||||
include: { items: true, customer: true }
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to update order status" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
status: t.String()
|
||||
})
|
||||
})
|
||||
.patch("/:id/slip", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const { paymentSlipUrl } = body;
|
||||
return await prisma.order.update({
|
||||
where: { id: Number(id) },
|
||||
data: { paymentSlipUrl },
|
||||
include: { items: true }
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to upload slip" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
paymentSlipUrl: t.String()
|
||||
})
|
||||
})
|
||||
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const { trackingNo } = body;
|
||||
const order = await prisma.order.update({
|
||||
where: { id: Number(id) },
|
||||
data: { trackingNo, status: "SHIPPED" },
|
||||
include: { items: true }
|
||||
});
|
||||
// Trigger notification asynchronously
|
||||
sendOrderShippedNotification(order.id).catch(e => {
|
||||
console.error("Failed to trigger sendOrderShippedNotification:", e);
|
||||
});
|
||||
return order;
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to update tracking number" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
trackingNo: t.String()
|
||||
})
|
||||
})
|
||||
.get("/customer/:customerId", async ({ params: { customerId } }) => {
|
||||
return await prisma.order.findMany({
|
||||
where: { customerId: Number(customerId) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { items: true }
|
||||
});
|
||||
})
|
||||
.post("/track", async ({ body, set }) => {
|
||||
try {
|
||||
const { orderId, phone } = body;
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: Number(orderId) }
|
||||
});
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
const sanitizePhone = (p) => p.replace(/[^0-9]/g, '');
|
||||
if (sanitizePhone(order.customerPhone) !== sanitizePhone(phone)) {
|
||||
set.status = 401;
|
||||
return { error: "Authentication failed. Phone number does not match." };
|
||||
}
|
||||
return { success: true, orderId: order.id };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Invalid tracking request" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
orderId: t.Number(),
|
||||
phone: t.String()
|
||||
})
|
||||
});
|
||||
//# sourceMappingURL=orders.js.map
|
||||
@@ -1,484 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { HeroArticlePayloadSchema, RichTextPayloadSchema, ScentNotesBreakdownPayloadSchema, QuoteCalloutPayloadSchema, ProductCarouselPayloadSchema } from "@homaroma/types";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
const RESERVED_SLUGS = [
|
||||
"shop", "category", "products", "cart", "checkout", "account", "search",
|
||||
"contact", "login", "admin", "wishlist", "track", "coupons", "scent-guide",
|
||||
"faq", "about", "profile", "article", "pages", "orders", "customers",
|
||||
"settings", "menus", "auth", "api"
|
||||
];
|
||||
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
.get("/", async () => {
|
||||
return await prisma.page.findMany({
|
||||
include: {
|
||||
sections: {
|
||||
orderBy: { order: "asc" }
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.get("/:slug", async ({ params: { slug }, query, set }) => {
|
||||
const includeInactive = String(query?.includeInactive || "").toLowerCase() === "true";
|
||||
const lang = query?.lang || 'th';
|
||||
const page = await prisma.page.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
sections: {
|
||||
where: includeInactive ? undefined : { isActive: true },
|
||||
orderBy: { order: "asc" }
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!page) {
|
||||
set.status = 404;
|
||||
return { error: "Page not found" };
|
||||
}
|
||||
const populatedSections = await Promise.all(page.sections.map(async (section) => {
|
||||
const payload = section.payload;
|
||||
if (!payload)
|
||||
return section;
|
||||
const populatedPayload = { ...payload };
|
||||
if (section.type === 'PRODUCTS') {
|
||||
let products = [];
|
||||
const limit = payload.limit || 3;
|
||||
const productInclude = {
|
||||
category: true,
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
},
|
||||
orderBy: { price: "asc" }
|
||||
},
|
||||
images: {
|
||||
orderBy: { order: "asc" }
|
||||
}
|
||||
};
|
||||
if (payload.type === 'featured') {
|
||||
products = await prisma.product.findMany({
|
||||
where: { categoryId: 1 },
|
||||
include: productInclude,
|
||||
take: limit
|
||||
});
|
||||
}
|
||||
else if (payload.type === 'scent' && payload.scentId) {
|
||||
products = await prisma.product.findMany({
|
||||
where: { scentId: Number(payload.scentId) },
|
||||
include: productInclude,
|
||||
take: limit
|
||||
});
|
||||
}
|
||||
else if (payload.type === 'custom' && Array.isArray(payload.productIds)) {
|
||||
const ids = payload.productIds.map(Number);
|
||||
products = await prisma.product.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: productInclude
|
||||
});
|
||||
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||
}
|
||||
populatedPayload.products = products.map((p) => {
|
||||
if (lang === 'all') {
|
||||
const nameObj = {};
|
||||
const descObj = {};
|
||||
const detailsObj = {};
|
||||
p.translations?.forEach((t) => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
return { ...p, name: nameObj, description: descObj, details: detailsObj };
|
||||
}
|
||||
else {
|
||||
const trans = p.translations?.find((t) => t.langCode === lang) || p.translations?.find((t) => t.langCode === 'th') || p.translations?.[0];
|
||||
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details };
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (section.type === 'CATEGORIES') {
|
||||
let categories = [];
|
||||
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
|
||||
const ids = payload.categoryIds.map(Number);
|
||||
categories = await prisma.category.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: {
|
||||
_count: {
|
||||
select: { products: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||
}
|
||||
else {
|
||||
categories = await prisma.category.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { products: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
populatedPayload.categories = categories;
|
||||
}
|
||||
return {
|
||||
...section,
|
||||
payload: populatedPayload
|
||||
};
|
||||
}));
|
||||
return {
|
||||
...page,
|
||||
sections: populatedSections
|
||||
};
|
||||
})
|
||||
.get("/:slug/sections", async ({ params: { slug }, set }) => {
|
||||
try {
|
||||
return await prisma.pageSection.findMany({
|
||||
where: { pageId: Number(slug) },
|
||||
orderBy: { order: "asc" }
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch page sections" };
|
||||
}
|
||||
})
|
||||
.post("/newsletter", async ({ body, set }) => {
|
||||
try {
|
||||
const { email } = body;
|
||||
if (!email || !email.includes("@")) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid email address" };
|
||||
}
|
||||
const sub = await prisma.newsletterSubscription.upsert({
|
||||
where: { email },
|
||||
update: {},
|
||||
create: { email }
|
||||
});
|
||||
return { success: true, data: sub };
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Newsletter] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to subscribe" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.String()
|
||||
})
|
||||
})
|
||||
.use(adminOnly)
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
const { id, slug, title, isPublished, status, metadata } = data;
|
||||
if (typeof slug === 'string' && RESERVED_SLUGS.includes(slug.toLowerCase())) {
|
||||
set.status = 400;
|
||||
return { error: "Reserved slug cannot be used" };
|
||||
}
|
||||
let finalIsPublished = true;
|
||||
if (status !== undefined) {
|
||||
finalIsPublished = status === "published";
|
||||
}
|
||||
else if (isPublished !== undefined) {
|
||||
finalIsPublished = isPublished;
|
||||
}
|
||||
if (id) {
|
||||
return await prisma.page.update({
|
||||
where: { id: Number(id) },
|
||||
data: { slug, title, isPublished: finalIsPublished }
|
||||
});
|
||||
}
|
||||
else {
|
||||
return await prisma.page.create({
|
||||
data: { slug, title, isPublished: finalIsPublished }
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Pages] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to save page" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
id: t.Optional(t.Number()),
|
||||
slug: t.String(),
|
||||
title: t.String(),
|
||||
isPublished: t.Optional(t.Boolean()),
|
||||
status: t.Optional(t.String()),
|
||||
metadata: t.Optional(t.Any())
|
||||
})
|
||||
})
|
||||
.delete("/:slug", async ({ params: { slug }, set }) => {
|
||||
try {
|
||||
await prisma.page.delete({
|
||||
where: { id: Number(slug) }
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete page" };
|
||||
}
|
||||
})
|
||||
.post("/:slug/sections", async ({ params: { slug }, body, set }) => {
|
||||
try {
|
||||
const pageId = Number(slug);
|
||||
const { type, order, payload, isActive } = body;
|
||||
const validationError = validateSectionPayload(type, payload);
|
||||
if (validationError) {
|
||||
set.status = 400;
|
||||
return { error: validationError };
|
||||
}
|
||||
let finalOrder = order;
|
||||
if (finalOrder === undefined) {
|
||||
const lastSection = await prisma.pageSection.findFirst({
|
||||
where: { pageId },
|
||||
orderBy: { order: "desc" }
|
||||
});
|
||||
finalOrder = lastSection ? lastSection.order + 1 : 1;
|
||||
}
|
||||
return await prisma.pageSection.create({
|
||||
data: {
|
||||
pageId,
|
||||
type: type.toUpperCase(),
|
||||
order: finalOrder,
|
||||
payload,
|
||||
isActive: isActive !== false
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[PageSections] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to add section" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
type: t.String(),
|
||||
order: t.Optional(t.Number()),
|
||||
payload: t.Any(),
|
||||
isActive: t.Optional(t.Boolean())
|
||||
})
|
||||
})
|
||||
.put("/sections/:sectionId", async ({ params: { sectionId }, body, set }) => {
|
||||
try {
|
||||
const id = Number(sectionId);
|
||||
const { type, order, payload, isActive } = body;
|
||||
if (payload && type) {
|
||||
const validationError = validateSectionPayload(type, payload);
|
||||
if (validationError) {
|
||||
set.status = 400;
|
||||
return { error: validationError };
|
||||
}
|
||||
}
|
||||
return await prisma.pageSection.update({
|
||||
where: { id },
|
||||
data: {
|
||||
type: type ? type.toUpperCase() : undefined,
|
||||
order,
|
||||
payload,
|
||||
isActive
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[PageSections] Update Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update section" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
type: t.Optional(t.String()),
|
||||
order: t.Optional(t.Number()),
|
||||
payload: t.Optional(t.Any()),
|
||||
isActive: t.Optional(t.Boolean())
|
||||
})
|
||||
})
|
||||
.put("/:slug/sections/reorder", async ({ params: { slug }, body, set }) => {
|
||||
try {
|
||||
const pageId = Number(slug);
|
||||
const { sectionOrders } = body;
|
||||
await prisma.$transaction(sectionOrders.map((item) => prisma.pageSection.update({
|
||||
where: { id: item.id, pageId },
|
||||
data: { order: item.order }
|
||||
})));
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[PageSections] Reorder Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to reorder sections" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
sectionOrders: t.Array(t.Object({
|
||||
id: t.Number(),
|
||||
order: t.Number()
|
||||
}))
|
||||
})
|
||||
})
|
||||
.delete("/sections/:sectionId", async ({ params: { sectionId }, set }) => {
|
||||
try {
|
||||
await prisma.pageSection.delete({
|
||||
where: { id: Number(sectionId) }
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete section" };
|
||||
}
|
||||
});
|
||||
function validateSectionPayload(type, payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return "Payload must be a JSON object";
|
||||
}
|
||||
switch (type.toUpperCase()) {
|
||||
case 'HERO':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim())
|
||||
return "Hero payload must contain a non-empty string title";
|
||||
if (typeof payload.description !== 'string')
|
||||
return "Hero payload must contain a string description";
|
||||
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim())
|
||||
return "Hero payload must contain a non-empty string imageUrl";
|
||||
if (typeof payload.tagline !== 'string')
|
||||
return "Hero payload must contain a string tagline";
|
||||
if (typeof payload.buttonText !== 'string')
|
||||
return "Hero payload must contain a string buttonText";
|
||||
if (typeof payload.buttonLink !== 'string')
|
||||
return "Hero payload must contain a string buttonLink";
|
||||
break;
|
||||
case 'CATEGORIES':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim())
|
||||
return "Categories payload must contain a non-empty string title";
|
||||
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string')
|
||||
return "Subtitle must be a string";
|
||||
if (payload.categoryIds !== undefined && !Array.isArray(payload.categoryIds)) {
|
||||
return "categoryIds must be an array of numbers";
|
||||
}
|
||||
break;
|
||||
case 'PRODUCTS':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim())
|
||||
return "Products payload must contain a non-empty string title";
|
||||
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string')
|
||||
return "Subtitle must be a string";
|
||||
if (!['featured', 'scent', 'custom'].includes(payload.type)) {
|
||||
return "Products type must be 'featured', 'scent', or 'custom'";
|
||||
}
|
||||
if (payload.type === 'scent' && typeof payload.scentId !== 'number') {
|
||||
return "Scent products section requires a numeric scentId";
|
||||
}
|
||||
if (payload.type === 'custom' && !Array.isArray(payload.productIds)) {
|
||||
return "Custom products section requires an array of productIds";
|
||||
}
|
||||
if (payload.limit !== undefined && typeof payload.limit !== 'number') {
|
||||
return "Limit must be a number";
|
||||
}
|
||||
if (payload.layout !== undefined && !['grid', 'carousel'].includes(payload.layout)) {
|
||||
return "layout must be 'grid' or 'carousel'";
|
||||
}
|
||||
if (payload.enableQuickShop !== undefined && typeof payload.enableQuickShop !== 'boolean') {
|
||||
return "enableQuickShop must be a boolean";
|
||||
}
|
||||
break;
|
||||
case 'BANNER':
|
||||
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim())
|
||||
return "Banner payload must contain a non-empty string imageUrl";
|
||||
if (payload.linkUrl !== undefined && typeof payload.linkUrl !== 'string')
|
||||
return "linkUrl must be a string";
|
||||
if (payload.title !== undefined && typeof payload.title !== 'string')
|
||||
return "title must be a string";
|
||||
if (payload.description !== undefined && typeof payload.description !== 'string')
|
||||
return "description must be a string";
|
||||
if (payload.buttonText !== undefined && typeof payload.buttonText !== 'string')
|
||||
return "buttonText must be a string";
|
||||
break;
|
||||
case 'TEXT':
|
||||
if (typeof payload.content !== 'string' || !payload.content.trim())
|
||||
return "Text payload must contain a non-empty string content";
|
||||
if (payload.title !== undefined && typeof payload.title !== 'string')
|
||||
return "title must be a string";
|
||||
if (payload.textAlign !== undefined && !['left', 'center', 'right'].includes(payload.textAlign)) {
|
||||
return "textAlign must be 'left', 'center', or 'right'";
|
||||
}
|
||||
break;
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
if (!Array.isArray(payload.slides) || payload.slides.length === 0)
|
||||
return "Announcement Bar requires an array of slides";
|
||||
if (payload.slides.some((s) => typeof s !== 'string'))
|
||||
return "All slides must be strings";
|
||||
break;
|
||||
case 'NEWSLETTER':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim())
|
||||
return "Newsletter payload must contain a non-empty string title";
|
||||
if (typeof payload.description !== 'string')
|
||||
return "Newsletter description must be a string";
|
||||
if (typeof payload.buttonText !== 'string' || !payload.buttonText.trim())
|
||||
return "Newsletter buttonText must be a non-empty string";
|
||||
if (typeof payload.showPrivacyCheckbox !== 'boolean')
|
||||
return "showPrivacyCheckbox must be a boolean";
|
||||
break;
|
||||
case 'EDITORIAL_TILE':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim())
|
||||
return "Editorial Tile payload must contain a non-empty string title";
|
||||
if (typeof payload.description !== 'string')
|
||||
return "Editorial Tile description must be a string";
|
||||
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim())
|
||||
return "Editorial Tile imageUrl must be a non-empty string";
|
||||
if (!['left', 'right'].includes(payload.imagePosition))
|
||||
return "imagePosition must be 'left' or 'right'";
|
||||
break;
|
||||
case 'EDITORIAL_CAROUSEL':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim())
|
||||
return "Editorial Carousel payload must contain a non-empty string title";
|
||||
if (!Array.isArray(payload.slides) || payload.slides.length === 0)
|
||||
return "Editorial Carousel requires an array of slides";
|
||||
for (const [idx, slide] of payload.slides.entries()) {
|
||||
if (typeof slide.title !== 'string' || !slide.title.trim())
|
||||
return `Slide #${idx + 1} must contain a non-empty title`;
|
||||
if (typeof slide.description !== 'string')
|
||||
return `Slide #${idx + 1} must contain a description string`;
|
||||
if (typeof slide.imageUrl !== 'string' || !slide.imageUrl.trim())
|
||||
return `Slide #${idx + 1} must contain a non-empty imageUrl`;
|
||||
if (typeof slide.category !== 'string')
|
||||
return `Slide #${idx + 1} must contain a category string`;
|
||||
}
|
||||
break;
|
||||
case 'HERO_ARTICLE': {
|
||||
const res = HeroArticlePayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
break;
|
||||
}
|
||||
case 'RICH_TEXT': {
|
||||
const res = RichTextPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
break;
|
||||
}
|
||||
case 'SCENT_NOTES_BREAKDOWN': {
|
||||
const res = ScentNotesBreakdownPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
break;
|
||||
}
|
||||
case 'QUOTE_CALLOUT': {
|
||||
const res = QuoteCalloutPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
break;
|
||||
}
|
||||
case 'PRODUCT_CAROUSEL': {
|
||||
const res = ProductCarouselPayloadSchema.safeParse(payload);
|
||||
if (!res.success)
|
||||
return res.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return `Unknown section type: ${type}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//# sourceMappingURL=pages.js.map
|
||||
@@ -232,6 +232,46 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
return { error: "Failed to delete page" };
|
||||
}
|
||||
})
|
||||
.patch("/:id/translations", async ({ params, body, set }) => {
|
||||
const id = Number(params.id);
|
||||
if (isNaN(id)) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid page ID" };
|
||||
}
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
|
||||
for (const lang of LANGS) {
|
||||
const title = typeof payload.title === 'string' ? (lang === 'th' ? payload.title : null) : payload.title?.[lang];
|
||||
if (!title) continue;
|
||||
|
||||
const existing = await prisma.pageTranslation.findUnique({
|
||||
where: { pageId_langCode: { pageId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.pageTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: { title }
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.pageTranslation.create({
|
||||
data: { pageId: id, langCode: lang, title }
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.page.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true }
|
||||
});
|
||||
|
||||
return { success: true, translations: results, page: updated };
|
||||
})
|
||||
.post("/:slug/sections", async ({ params: { slug }, body, set }) => {
|
||||
try {
|
||||
const pageId = Number(slug);
|
||||
@@ -348,6 +388,11 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
}
|
||||
});
|
||||
|
||||
function isValidText(val: any, allowEmpty: boolean = false): boolean {
|
||||
if (typeof val === 'string') return allowEmpty ? true : val.trim().length > 0;
|
||||
return val !== null && typeof val === 'object' && !Array.isArray(val);
|
||||
}
|
||||
|
||||
function validateSectionPayload(type: string, payload: any): string | null {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return "Payload must be a JSON object";
|
||||
@@ -355,23 +400,23 @@ function validateSectionPayload(type: string, payload: any): string | null {
|
||||
|
||||
switch (type.toUpperCase()) {
|
||||
case 'HERO':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Hero payload must contain a non-empty string title";
|
||||
if (typeof payload.description !== 'string') return "Hero payload must contain a string description";
|
||||
if (!isValidText(payload.title)) return "Hero payload must contain a non-empty title (string or object)";
|
||||
if (payload.description === undefined || !isValidText(payload.description, true)) return "Hero payload must contain a description (string or object)";
|
||||
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Hero payload must contain a non-empty string imageUrl";
|
||||
if (typeof payload.tagline !== 'string') return "Hero payload must contain a string tagline";
|
||||
if (typeof payload.buttonText !== 'string') return "Hero payload must contain a string buttonText";
|
||||
if (payload.tagline === undefined || !isValidText(payload.tagline, true)) return "Hero payload must contain a tagline (string or object)";
|
||||
if (payload.buttonText === undefined || !isValidText(payload.buttonText, true)) return "Hero payload must contain a buttonText (string or object)";
|
||||
if (typeof payload.buttonLink !== 'string') return "Hero payload must contain a string buttonLink";
|
||||
break;
|
||||
case 'CATEGORIES':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Categories payload must contain a non-empty string title";
|
||||
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string') return "Subtitle must be a string";
|
||||
if (!isValidText(payload.title)) return "Categories payload must contain a non-empty title (string or object)";
|
||||
if (payload.subtitle !== undefined && !isValidText(payload.subtitle, true)) return "Subtitle must be a string or object";
|
||||
if (payload.categoryIds !== undefined && !Array.isArray(payload.categoryIds)) {
|
||||
return "categoryIds must be an array of numbers";
|
||||
}
|
||||
break;
|
||||
case 'PRODUCTS':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Products payload must contain a non-empty string title";
|
||||
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string') return "Subtitle must be a string";
|
||||
if (!isValidText(payload.title)) return "Products payload must contain a non-empty title (string or object)";
|
||||
if (payload.subtitle !== undefined && !isValidText(payload.subtitle, true)) return "Subtitle must be a string or object";
|
||||
if (!['featured', 'scent', 'custom'].includes(payload.type)) {
|
||||
return "Products type must be 'featured', 'scent', or 'custom'";
|
||||
}
|
||||
@@ -394,41 +439,41 @@ function validateSectionPayload(type: string, payload: any): string | null {
|
||||
case 'BANNER':
|
||||
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Banner payload must contain a non-empty string imageUrl";
|
||||
if (payload.linkUrl !== undefined && typeof payload.linkUrl !== 'string') return "linkUrl must be a string";
|
||||
if (payload.title !== undefined && typeof payload.title !== 'string') return "title must be a string";
|
||||
if (payload.description !== undefined && typeof payload.description !== 'string') return "description must be a string";
|
||||
if (payload.buttonText !== undefined && typeof payload.buttonText !== 'string') return "buttonText must be a string";
|
||||
if (payload.title !== undefined && !isValidText(payload.title, true)) return "title must be a string or object";
|
||||
if (payload.description !== undefined && !isValidText(payload.description, true)) return "description must be a string or object";
|
||||
if (payload.buttonText !== undefined && !isValidText(payload.buttonText, true)) return "buttonText must be a string or object";
|
||||
break;
|
||||
case 'TEXT':
|
||||
if (typeof payload.content !== 'string' || !payload.content.trim()) return "Text payload must contain a non-empty string content";
|
||||
if (payload.title !== undefined && typeof payload.title !== 'string') return "title must be a string";
|
||||
if (!isValidText(payload.content)) return "Text payload must contain non-empty content (string or object)";
|
||||
if (payload.title !== undefined && !isValidText(payload.title, true)) return "title must be a string or object";
|
||||
if (payload.textAlign !== undefined && !['left', 'center', 'right'].includes(payload.textAlign)) {
|
||||
return "textAlign must be 'left', 'center', or 'right'";
|
||||
}
|
||||
break;
|
||||
case 'ANNOUNCEMENT_BAR':
|
||||
if (!Array.isArray(payload.slides) || payload.slides.length === 0) return "Announcement Bar requires an array of slides";
|
||||
if (payload.slides.some((s: any) => typeof s !== 'string')) return "All slides must be strings";
|
||||
if (payload.slides.some((s: any) => !isValidText(s, true))) return "All slides must be strings or objects";
|
||||
break;
|
||||
case 'NEWSLETTER':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Newsletter payload must contain a non-empty string title";
|
||||
if (typeof payload.description !== 'string') return "Newsletter description must be a string";
|
||||
if (typeof payload.buttonText !== 'string' || !payload.buttonText.trim()) return "Newsletter buttonText must be a non-empty string";
|
||||
if (!isValidText(payload.title)) return "Newsletter payload must contain a non-empty title (string or object)";
|
||||
if (payload.description === undefined || !isValidText(payload.description, true)) return "Newsletter description must be a string or object";
|
||||
if (!isValidText(payload.buttonText)) return "Newsletter buttonText must be a non-empty string or object";
|
||||
if (typeof payload.showPrivacyCheckbox !== 'boolean') return "showPrivacyCheckbox must be a boolean";
|
||||
break;
|
||||
case 'EDITORIAL_TILE':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Editorial Tile payload must contain a non-empty string title";
|
||||
if (typeof payload.description !== 'string') return "Editorial Tile description must be a string";
|
||||
if (!isValidText(payload.title)) return "Editorial Tile payload must contain a non-empty title (string or object)";
|
||||
if (payload.description === undefined || !isValidText(payload.description, true)) return "Editorial Tile description must be a string or object";
|
||||
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Editorial Tile imageUrl must be a non-empty string";
|
||||
if (!['left', 'right'].includes(payload.imagePosition)) return "imagePosition must be 'left' or 'right'";
|
||||
break;
|
||||
case 'EDITORIAL_CAROUSEL':
|
||||
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Editorial Carousel payload must contain a non-empty string title";
|
||||
if (!isValidText(payload.title)) return "Editorial Carousel payload must contain a non-empty title (string or object)";
|
||||
if (!Array.isArray(payload.slides) || payload.slides.length === 0) return "Editorial Carousel requires an array of slides";
|
||||
for (const [idx, slide] of payload.slides.entries()) {
|
||||
if (typeof slide.title !== 'string' || !slide.title.trim()) return `Slide #${idx+1} must contain a non-empty title`;
|
||||
if (typeof slide.description !== 'string') return `Slide #${idx+1} must contain a description string`;
|
||||
if (!isValidText(slide.title)) return `Slide #${idx+1} must contain a non-empty title (string or object)`;
|
||||
if (slide.description === undefined || !isValidText(slide.description, true)) return `Slide #${idx+1} must contain a description (string or object)`;
|
||||
if (typeof slide.imageUrl !== 'string' || !slide.imageUrl.trim()) return `Slide #${idx+1} must contain a non-empty imageUrl`;
|
||||
if (typeof slide.category !== 'string') return `Slide #${idx+1} must contain a category string`;
|
||||
if (slide.category === undefined || !isValidText(slide.category, true)) return `Slide #${idx+1} must contain a category (string or object)`;
|
||||
}
|
||||
break;
|
||||
case 'HERO_ARTICLE': {
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { markOrderAsPaid } from "../lib/notifications";
|
||||
const OMISE_SECRET_KEY = process.env.OMISE_SECRET_KEY || "";
|
||||
export const paymentsRoute = new Elysia({ prefix: "/payments" })
|
||||
.post("/charge", async ({ body, set }) => {
|
||||
console.log("[Payment Charge] Incoming request body:", JSON.stringify(body));
|
||||
try {
|
||||
const { orderId, amount, type, token } = body;
|
||||
if (!OMISE_SECRET_KEY) {
|
||||
console.error("[Payment Charge] Error: OMISE_SECRET_KEY is empty");
|
||||
set.status = 500;
|
||||
return { error: "Omise secret key is not configured" };
|
||||
}
|
||||
// 1. Check if order exists
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId }
|
||||
});
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
// Convert amount to satang (integer)
|
||||
const amountInSatang = Math.round(amount * 100);
|
||||
// Setup Basic Auth header for Omise
|
||||
const authHeader = "Basic " + Buffer.from(OMISE_SECRET_KEY + ":").toString("base64");
|
||||
if (type === "card") {
|
||||
if (!token) {
|
||||
set.status = 400;
|
||||
return { error: "Card token is required for card payments" };
|
||||
}
|
||||
// Create a card charge directly
|
||||
const chargeRes = await fetch("https://api.omise.co/charges", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": authHeader,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amountInSatang,
|
||||
currency: "THB",
|
||||
card: token,
|
||||
metadata: {
|
||||
orderId: String(orderId)
|
||||
}
|
||||
})
|
||||
});
|
||||
const charge = await chargeRes.json();
|
||||
if (charge.error) {
|
||||
set.status = 400;
|
||||
return { error: charge.message || "Failed to create credit card charge" };
|
||||
}
|
||||
// If card payment is immediately successful (some cards don't require 3DS)
|
||||
if (charge.status === "successful") {
|
||||
await markOrderAsPaid(orderId);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
status: charge.status,
|
||||
chargeId: charge.id,
|
||||
authorizeUri: charge.authorize_uri || null // For 3DS card validation redirect
|
||||
};
|
||||
}
|
||||
else {
|
||||
// PromptPay:
|
||||
// 1. Create source
|
||||
const sourceRes = await fetch("https://api.omise.co/sources", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": authHeader,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: "promptpay",
|
||||
amount: amountInSatang,
|
||||
currency: "THB"
|
||||
})
|
||||
});
|
||||
const source = await sourceRes.json();
|
||||
console.log("[Payment Charge] Omise PromptPay Source response:", JSON.stringify(source));
|
||||
if (source.error) {
|
||||
set.status = 400;
|
||||
return { error: source.message || "Failed to create PromptPay source" };
|
||||
}
|
||||
// 2. Create charge
|
||||
const chargeRes = await fetch("https://api.omise.co/charges", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": authHeader,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: amountInSatang,
|
||||
currency: "THB",
|
||||
source: source.id,
|
||||
metadata: {
|
||||
orderId: String(orderId)
|
||||
}
|
||||
})
|
||||
});
|
||||
const charge = await chargeRes.json();
|
||||
console.log("[Payment Charge] Omise PromptPay Charge response:", JSON.stringify(charge));
|
||||
if (charge.error) {
|
||||
set.status = 400;
|
||||
return { error: charge.message || "Failed to create PromptPay charge" };
|
||||
}
|
||||
// Get QR Code Image URL from scannable_code or references
|
||||
const qrCodeUrl = charge.source?.scannable_code?.image?.download_uri || charge.source?.references?.barcode || null;
|
||||
return {
|
||||
success: true,
|
||||
status: charge.status,
|
||||
chargeId: charge.id,
|
||||
qrCodeUrl: qrCodeUrl
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Payment Charge] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to process payment request" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
orderId: t.Number(),
|
||||
amount: t.Number(),
|
||||
type: t.Union([t.Literal("card"), t.Literal("promptpay")]),
|
||||
token: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.post("/webhook", async ({ body, set }) => {
|
||||
try {
|
||||
const event = body;
|
||||
console.log(`[Omise Webhook] Received event: ${event.key}`);
|
||||
if (event.key === "charge.complete") {
|
||||
const charge = event.data;
|
||||
const status = charge.status;
|
||||
const orderIdStr = charge.metadata?.orderId;
|
||||
if (orderIdStr && status === "successful") {
|
||||
const orderId = parseInt(orderIdStr, 10);
|
||||
console.log(`[Omise Webhook] Payment successful for Order #${orderId}`);
|
||||
await markOrderAsPaid(orderId);
|
||||
}
|
||||
}
|
||||
return { received: true };
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Omise Webhook] Error processing event:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to process webhook" };
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=payments.js.map
|
||||
@@ -1,279 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
.get("/scents", async () => {
|
||||
return await prisma.scent.findMany({
|
||||
orderBy: { name: "asc" }
|
||||
});
|
||||
})
|
||||
.get("/", async ({ query }) => {
|
||||
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
|
||||
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
|
||||
const search = query.search?.trim() || undefined;
|
||||
const lang = query.lang || 'th';
|
||||
// Pagination params
|
||||
const page = query.page ? parseInt(query.page) : 1;
|
||||
const limit = query.limit ? parseInt(query.limit) : 12;
|
||||
const skip = (page - 1) * limit;
|
||||
const where = {
|
||||
...(categoryId && { categoryId }),
|
||||
...(scentId && { scentId }),
|
||||
...(search && {
|
||||
OR: [
|
||||
{ translations: { some: { name: { contains: search, mode: "insensitive" } } } },
|
||||
{ scent: { name: { contains: search, mode: "insensitive" } } },
|
||||
{ category: { name: { contains: search, mode: "insensitive" } } },
|
||||
],
|
||||
}),
|
||||
};
|
||||
const [products, total] = await Promise.all([
|
||||
prisma.product.findMany({
|
||||
where,
|
||||
include: {
|
||||
category: { include: { translations: true } },
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
},
|
||||
orderBy: { price: "asc" }
|
||||
},
|
||||
images: { orderBy: { order: "asc" } },
|
||||
},
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.product.count({ where }),
|
||||
]);
|
||||
// Map translations
|
||||
const mappedProducts = products.map(p => {
|
||||
if (lang === 'all') {
|
||||
const nameObj = {};
|
||||
const descObj = {};
|
||||
const detailsObj = {};
|
||||
p.translations.forEach(t => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
return { ...p, name: nameObj, description: descObj, details: detailsObj, category: { ...p.category, name: p.category.name } }; // Actually for 'all', keep as is, but wait, category name needs resolving if we want it? Let's just resolve it simply below.
|
||||
}
|
||||
else {
|
||||
const trans = p.translations.find(t => t.langCode === lang) || p.translations.find(t => t.langCode === 'th') || p.translations[0];
|
||||
const catTrans = p.category.translations?.find((t) => t.langCode === lang) || p.category.translations?.find((t) => t.langCode === 'th') || p.category.translations?.[0];
|
||||
return {
|
||||
...p,
|
||||
name: trans?.name || '',
|
||||
description: trans?.description,
|
||||
details: trans?.details,
|
||||
category: { ...p.category, name: catTrans?.name || p.category.name }
|
||||
};
|
||||
}
|
||||
});
|
||||
mappedProducts.sort((a, b) => {
|
||||
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
|
||||
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
return {
|
||||
data: mappedProducts,
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
lastPage: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
})
|
||||
.get("/:id", async ({ params, query, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid product ID" });
|
||||
const lang = query.lang || 'th';
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
category: { include: { translations: true } },
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
include: {
|
||||
bomItems: { include: { rawMaterial: true } }
|
||||
},
|
||||
orderBy: { price: "asc" }
|
||||
},
|
||||
images: { orderBy: { order: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!product)
|
||||
return status(404, { message: "Product not found" });
|
||||
if (lang === 'all') {
|
||||
const nameObj = {};
|
||||
const descObj = {};
|
||||
const detailsObj = {};
|
||||
product.translations.forEach(t => {
|
||||
nameObj[t.langCode] = t.name;
|
||||
descObj[t.langCode] = t.description;
|
||||
detailsObj[t.langCode] = t.details;
|
||||
});
|
||||
return { ...product, name: nameObj, description: descObj, details: detailsObj };
|
||||
}
|
||||
else {
|
||||
const trans = product.translations.find(t => t.langCode === lang) || product.translations.find(t => t.langCode === 'th') || product.translations[0];
|
||||
const catTrans = product.category.translations?.find((t) => t.langCode === lang) || product.category.translations?.find((t) => t.langCode === 'th') || product.category.translations?.[0];
|
||||
return {
|
||||
...product,
|
||||
name: trans?.name || '',
|
||||
description: trans?.description,
|
||||
details: trans?.details,
|
||||
category: { ...product.category, name: catTrans?.name || product.category.name }
|
||||
};
|
||||
}
|
||||
})
|
||||
.put("/:id", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid product ID" });
|
||||
const { categoryId, description, details, variants, images } = body;
|
||||
try {
|
||||
const updatedProduct = await prisma.$transaction(async (tx) => {
|
||||
// 1. Update basic product info
|
||||
const prod = await tx.product.update({
|
||||
where: { id },
|
||||
data: { categoryId },
|
||||
});
|
||||
// 2. Sync Variants
|
||||
await tx.productVariant.deleteMany({
|
||||
where: { productId: id },
|
||||
});
|
||||
if (variants && variants.length > 0) {
|
||||
for (const v of variants) {
|
||||
await tx.productVariant.create({
|
||||
data: {
|
||||
productId: id,
|
||||
size: v.size,
|
||||
price: v.price,
|
||||
bomItems: v.bomItems && v.bomItems.length > 0 ? {
|
||||
create: v.bomItems.map((b) => ({
|
||||
rawMaterialId: b.rawMaterialId,
|
||||
quantity: b.quantity,
|
||||
}))
|
||||
} : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// 3. Sync Images
|
||||
await tx.productImage.deleteMany({
|
||||
where: { productId: id },
|
||||
});
|
||||
if (images && images.length > 0) {
|
||||
for (const [idx, img] of images.entries()) {
|
||||
await tx.productImage.create({
|
||||
data: {
|
||||
productId: id,
|
||||
url: img.url,
|
||||
order: img.order ?? (typeof img.order === 'number' ? img.order : idx),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return prod;
|
||||
});
|
||||
return { success: true, product: updatedProduct };
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Update product error:", err);
|
||||
return status(500, { success: false, message: "Failed to update product" });
|
||||
}
|
||||
}, {
|
||||
body: t.Any(),
|
||||
detail: {
|
||||
summary: "Update Product",
|
||||
tags: ["products"],
|
||||
},
|
||||
})
|
||||
.patch("/:id", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid product ID" });
|
||||
try {
|
||||
const payload = body;
|
||||
const prodData = {};
|
||||
if (payload.categoryId !== undefined)
|
||||
prodData.categoryId = payload.categoryId;
|
||||
if (payload.scentId !== undefined)
|
||||
prodData.scentId = payload.scentId;
|
||||
if (payload.price !== undefined)
|
||||
prodData.price = payload.price;
|
||||
const prod = await prisma.product.update({
|
||||
where: { id },
|
||||
data: prodData,
|
||||
});
|
||||
// Update translations
|
||||
// If name, description, details are strings, treat as 'th'
|
||||
// If they are objects, iterate over them
|
||||
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
for (const lang of languages) {
|
||||
const updateData = {};
|
||||
let shouldUpdate = false;
|
||||
if (payload.name) {
|
||||
const val = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name[lang];
|
||||
if (val !== undefined && val !== null) {
|
||||
updateData.name = val;
|
||||
shouldUpdate = true;
|
||||
}
|
||||
}
|
||||
if (payload.description) {
|
||||
const val = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description[lang];
|
||||
if (val !== undefined && val !== null) {
|
||||
updateData.description = val;
|
||||
shouldUpdate = true;
|
||||
}
|
||||
}
|
||||
if (payload.details) {
|
||||
const val = typeof payload.details === 'string' ? (lang === 'th' ? payload.details : null) : payload.details[lang];
|
||||
if (val !== undefined && val !== null) {
|
||||
updateData.details = val;
|
||||
shouldUpdate = true;
|
||||
}
|
||||
}
|
||||
if (shouldUpdate) {
|
||||
// First check if translation exists to create or update
|
||||
const existing = await prisma.productTranslation.findUnique({
|
||||
where: { productId_langCode: { productId: id, langCode: lang } }
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.productTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData
|
||||
});
|
||||
}
|
||||
else if (updateData.name) { // name is required for creation
|
||||
await prisma.productTranslation.create({
|
||||
data: {
|
||||
productId: id,
|
||||
langCode: lang,
|
||||
name: updateData.name,
|
||||
description: updateData.description,
|
||||
details: updateData.details
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { success: true, product: prod };
|
||||
}
|
||||
catch (err) {
|
||||
console.error("Patch product error:", err);
|
||||
return status(500, { success: false, message: "Failed to patch product" });
|
||||
}
|
||||
}, {
|
||||
body: t.Any(),
|
||||
detail: {
|
||||
summary: "Partial Update Product",
|
||||
tags: ["products"],
|
||||
},
|
||||
});
|
||||
//# sourceMappingURL=products.js.map
|
||||
@@ -4,9 +4,47 @@ import prisma from "../lib/prisma";
|
||||
export const productsRoute = new Elysia({ prefix: "/products" })
|
||||
.get("/scents", async () => {
|
||||
return await prisma.scent.findMany({
|
||||
orderBy: { name: "asc" }
|
||||
orderBy: { name: "asc" },
|
||||
include: { translations: true }
|
||||
});
|
||||
})
|
||||
.patch("/scents/:id/translations", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id)) return status(400, { message: "Invalid scent ID" });
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
|
||||
for (const lang of LANGS) {
|
||||
const name = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name?.[lang];
|
||||
if (!name) continue;
|
||||
|
||||
const existing = await prisma.scentTranslation.findUnique({
|
||||
where: { scentId_langCode: { scentId: id, langCode: lang } }
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.scentTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: { name },
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.scentTranslation.create({
|
||||
data: { scentId: id, langCode: lang, name },
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.scent.findUnique({
|
||||
where: { id },
|
||||
include: { translations: true },
|
||||
});
|
||||
|
||||
return { success: true, translations: results, scent: updated };
|
||||
})
|
||||
.get("/", async ({ query }) => {
|
||||
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
|
||||
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const profileRoute = new Elysia({ prefix: "/profile" })
|
||||
.get("/:customerId", async ({ params: { customerId }, set }) => {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: Number(customerId) },
|
||||
include: { addresses: true }
|
||||
});
|
||||
if (!customer) {
|
||||
set.status = 404;
|
||||
return { error: "Customer not found" };
|
||||
}
|
||||
return customer;
|
||||
})
|
||||
.put("/:customerId", async ({ params: { customerId }, body, set }) => {
|
||||
try {
|
||||
const { email, phone } = body;
|
||||
const customer = await prisma.customer.update({
|
||||
where: { id: Number(customerId) },
|
||||
data: {
|
||||
email: email || null,
|
||||
phone: phone || null
|
||||
}
|
||||
});
|
||||
return customer;
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to update customer profile" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.Optional(t.Nullable(t.String())),
|
||||
phone: t.Optional(t.Nullable(t.String()))
|
||||
})
|
||||
})
|
||||
.get("/:customerId/addresses", async ({ params: { customerId } }) => {
|
||||
return await prisma.customerAddress.findMany({
|
||||
where: { customerId: Number(customerId) },
|
||||
orderBy: { isDefault: 'desc' }
|
||||
});
|
||||
})
|
||||
.post("/:customerId/addresses", async ({ params: { customerId }, body, set }) => {
|
||||
try {
|
||||
const { recipientName, phone, addressLine, subdistrict, district, province, zipcode, isDefault } = body;
|
||||
const cId = Number(customerId);
|
||||
// If isDefault is true, unset other defaults first
|
||||
if (isDefault) {
|
||||
await prisma.customerAddress.updateMany({
|
||||
where: { customerId: cId },
|
||||
data: { isDefault: false }
|
||||
});
|
||||
}
|
||||
const address = await prisma.customerAddress.create({
|
||||
data: {
|
||||
customerId: cId,
|
||||
recipientName,
|
||||
phone,
|
||||
addressLine,
|
||||
subdistrict,
|
||||
district,
|
||||
province,
|
||||
zipcode,
|
||||
isDefault: !!isDefault
|
||||
}
|
||||
});
|
||||
return address;
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to create address" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
recipientName: t.String(),
|
||||
phone: t.String(),
|
||||
addressLine: t.String(),
|
||||
subdistrict: t.String(),
|
||||
district: t.String(),
|
||||
province: t.String(),
|
||||
zipcode: t.String(),
|
||||
isDefault: t.Boolean()
|
||||
})
|
||||
})
|
||||
.put("/:customerId/addresses/:addressId", async ({ params: { customerId, addressId }, body, set }) => {
|
||||
try {
|
||||
const { recipientName, phone, addressLine, subdistrict, district, province, zipcode, isDefault } = body;
|
||||
const cId = Number(customerId);
|
||||
const aId = Number(addressId);
|
||||
// If setting as default, unset other defaults first
|
||||
if (isDefault) {
|
||||
await prisma.customerAddress.updateMany({
|
||||
where: { customerId: cId },
|
||||
data: { isDefault: false }
|
||||
});
|
||||
}
|
||||
const address = await prisma.customerAddress.update({
|
||||
where: { id: aId },
|
||||
data: {
|
||||
recipientName,
|
||||
phone,
|
||||
addressLine,
|
||||
subdistrict,
|
||||
district,
|
||||
province,
|
||||
zipcode,
|
||||
isDefault: !!isDefault
|
||||
}
|
||||
});
|
||||
return address;
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to update address" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
recipientName: t.String(),
|
||||
phone: t.String(),
|
||||
addressLine: t.String(),
|
||||
subdistrict: t.String(),
|
||||
district: t.String(),
|
||||
province: t.String(),
|
||||
zipcode: t.String(),
|
||||
isDefault: t.Boolean()
|
||||
})
|
||||
})
|
||||
.delete("/:customerId/addresses/:addressId", async ({ params: { addressId }, set }) => {
|
||||
try {
|
||||
await prisma.customerAddress.delete({
|
||||
where: { id: Number(addressId) }
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to delete address" };
|
||||
}
|
||||
})
|
||||
.patch("/:customerId/addresses/:addressId/default", async ({ params: { customerId, addressId }, set }) => {
|
||||
try {
|
||||
const cId = Number(customerId);
|
||||
const aId = Number(addressId);
|
||||
// Unset other defaults
|
||||
await prisma.customerAddress.updateMany({
|
||||
where: { customerId: cId },
|
||||
data: { isDefault: false }
|
||||
});
|
||||
// Set this one as default
|
||||
const address = await prisma.customerAddress.update({
|
||||
where: { id: aId },
|
||||
data: { isDefault: true }
|
||||
});
|
||||
return address;
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: err.message || "Failed to set default address" };
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=profile.js.map
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const promotionsRoute = new Elysia({ prefix: "/promotions" })
|
||||
.get("/active", async () => {
|
||||
const now = new Date();
|
||||
return await prisma.promotion.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ startDate: null },
|
||||
{ startDate: { lte: now } }
|
||||
],
|
||||
AND: [
|
||||
{ OR: [{ endDate: null }, { endDate: { gte: now } }] }
|
||||
]
|
||||
},
|
||||
orderBy: { level: 'asc' }
|
||||
});
|
||||
})
|
||||
.get("/", async () => {
|
||||
return await prisma.promotion.findMany({
|
||||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }]
|
||||
});
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
return await prisma.promotion.create({ data });
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to create promotion" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
level: t.Number(),
|
||||
type: t.String(),
|
||||
value: t.Number(),
|
||||
minPurchase: t.Number(),
|
||||
isActive: t.Boolean(),
|
||||
startDate: t.Optional(t.Nullable(t.String())),
|
||||
endDate: t.Optional(t.Nullable(t.String()))
|
||||
})
|
||||
})
|
||||
.put("/:id", async ({ params: { id }, body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
return await prisma.promotion.update({
|
||||
where: { id: Number(id) },
|
||||
data
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to update promotion" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
level: t.Number(),
|
||||
type: t.String(),
|
||||
value: t.Number(),
|
||||
minPurchase: t.Number(),
|
||||
isActive: t.Boolean(),
|
||||
startDate: t.Optional(t.Nullable(t.String())),
|
||||
endDate: t.Optional(t.Nullable(t.String()))
|
||||
})
|
||||
})
|
||||
.delete("/:id", async ({ params: { id }, set }) => {
|
||||
try {
|
||||
await prisma.promotion.delete({
|
||||
where: { id: Number(id) }
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
set.status = 500;
|
||||
return { error: "Failed to delete promotion" };
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=promotions.js.map
|
||||
@@ -1,80 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const rawMaterialsRoute = new Elysia({ prefix: "/raw-materials" })
|
||||
.get("/", async () => {
|
||||
const rawMaterials = await prisma.rawMaterial.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
return rawMaterials;
|
||||
})
|
||||
.get("/:id", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid ID" });
|
||||
const rawMaterial = await prisma.rawMaterial.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!rawMaterial)
|
||||
return status(404, { message: "Raw material not found" });
|
||||
return rawMaterial;
|
||||
})
|
||||
.post("/", async ({ body, status }) => {
|
||||
try {
|
||||
const rawMaterial = await prisma.rawMaterial.create({
|
||||
data: body,
|
||||
});
|
||||
return status(201, rawMaterial);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return status(500, { message: "Failed to create raw material" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
unit: t.String(),
|
||||
pricePerUnit: t.Number(),
|
||||
}),
|
||||
detail: { tags: ["raw-materials"] }
|
||||
})
|
||||
.put("/:id", async ({ params, body, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid ID" });
|
||||
try {
|
||||
const rawMaterial = await prisma.rawMaterial.update({
|
||||
where: { id },
|
||||
data: body,
|
||||
});
|
||||
return rawMaterial;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return status(500, { message: "Failed to update raw material" });
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
unit: t.String(),
|
||||
pricePerUnit: t.Number(),
|
||||
}),
|
||||
detail: { tags: ["raw-materials"] }
|
||||
})
|
||||
.delete("/:id", async ({ params, status }) => {
|
||||
const id = parseInt(params.id);
|
||||
if (isNaN(id))
|
||||
return status(400, { message: "Invalid ID" });
|
||||
try {
|
||||
await prisma.rawMaterial.delete({
|
||||
where: { id },
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
return status(500, { message: "Failed to delete raw material" });
|
||||
}
|
||||
}, {
|
||||
detail: { tags: ["raw-materials"] }
|
||||
});
|
||||
//# sourceMappingURL=raw-materials.js.map
|
||||
@@ -1,50 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const reviewsRoute = new Elysia({ prefix: "/reviews" })
|
||||
.get("/product/:productId", async ({ params: { productId } }) => {
|
||||
return await prisma.review.findMany({
|
||||
where: {
|
||||
productId: Number(productId),
|
||||
isPublished: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
displayName: true,
|
||||
pictureUrl: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
const review = await prisma.review.create({
|
||||
data: {
|
||||
rating: data.rating,
|
||||
comment: data.comment,
|
||||
productId: data.productId,
|
||||
customerId: data.customerId || null,
|
||||
customerName: data.customerName || "Anonymous",
|
||||
isPublished: true
|
||||
}
|
||||
});
|
||||
return review;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Reviews] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to create review" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
rating: t.Number(),
|
||||
comment: t.Optional(t.String()),
|
||||
productId: t.Number(),
|
||||
customerId: t.Optional(t.Nullable(t.Number())),
|
||||
customerName: t.Optional(t.String())
|
||||
})
|
||||
});
|
||||
//# sourceMappingURL=reviews.js.map
|
||||
@@ -1,59 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
.get("/", async ({ set }) => {
|
||||
try {
|
||||
let settings = await prisma.storeSettings.findFirst();
|
||||
if (!settings) {
|
||||
settings = await prisma.storeSettings.create({
|
||||
data: {}
|
||||
});
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Settings] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch settings" };
|
||||
}
|
||||
})
|
||||
.use(adminOnly)
|
||||
.put("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body;
|
||||
let settings = await prisma.storeSettings.findFirst();
|
||||
if (!settings) {
|
||||
settings = await prisma.storeSettings.create({ data });
|
||||
}
|
||||
else {
|
||||
settings = await prisma.storeSettings.update({
|
||||
where: { id: settings.id },
|
||||
data
|
||||
});
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Settings] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update settings" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
shippingFee: t.Number(),
|
||||
bankName: t.String(),
|
||||
bankAccountName: t.String(),
|
||||
bankAccountNumber: t.String(),
|
||||
pointsEarnRate: t.Number(),
|
||||
facebookUrl: t.Optional(t.String()),
|
||||
instagramUrl: t.Optional(t.String()),
|
||||
tiktokUrl: t.Optional(t.String()),
|
||||
footerDescription: t.Optional(t.String()),
|
||||
newsletterTitle: t.Optional(t.String()),
|
||||
newsletterDescription: t.Optional(t.String()),
|
||||
newsletterPlaceholder: t.Optional(t.String()),
|
||||
newsletterButtonText: t.Optional(t.String())
|
||||
})
|
||||
});
|
||||
//# sourceMappingURL=settings.js.map
|
||||
@@ -53,4 +53,60 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
newsletterPlaceholder: t.Optional(t.String()),
|
||||
newsletterButtonText: t.Optional(t.String())
|
||||
})
|
||||
})
|
||||
.patch("/translations", async ({ body, set }) => {
|
||||
try {
|
||||
let settings = await prisma.storeSettings.findFirst();
|
||||
if (!settings) {
|
||||
settings = await prisma.storeSettings.create({ data: {} });
|
||||
}
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
|
||||
for (const lang of LANGS) {
|
||||
const footerDescription = typeof payload.footerDescription === 'string' ? (lang === 'th' ? payload.footerDescription : null) : payload.footerDescription?.[lang];
|
||||
const newsletterTitle = typeof payload.newsletterTitle === 'string' ? (lang === 'th' ? payload.newsletterTitle : null) : payload.newsletterTitle?.[lang];
|
||||
const newsletterDescription = typeof payload.newsletterDescription === 'string' ? (lang === 'th' ? payload.newsletterDescription : null) : payload.newsletterDescription?.[lang];
|
||||
const newsletterPlaceholder = typeof payload.newsletterPlaceholder === 'string' ? (lang === 'th' ? payload.newsletterPlaceholder : null) : payload.newsletterPlaceholder?.[lang];
|
||||
const newsletterButtonText = typeof payload.newsletterButtonText === 'string' ? (lang === 'th' ? payload.newsletterButtonText : null) : payload.newsletterButtonText?.[lang];
|
||||
|
||||
if (!footerDescription && !newsletterTitle && !newsletterDescription && !newsletterPlaceholder && !newsletterButtonText) continue;
|
||||
|
||||
const existing = await prisma.storeSettingsTranslation.findUnique({
|
||||
where: { storeSettingsId_langCode: { storeSettingsId: settings.id, langCode: lang } }
|
||||
});
|
||||
|
||||
const updateData: any = {};
|
||||
if (footerDescription) updateData.footerDescription = footerDescription;
|
||||
if (newsletterTitle) updateData.newsletterTitle = newsletterTitle;
|
||||
if (newsletterDescription) updateData.newsletterDescription = newsletterDescription;
|
||||
if (newsletterPlaceholder) updateData.newsletterPlaceholder = newsletterPlaceholder;
|
||||
if (newsletterButtonText) updateData.newsletterButtonText = newsletterButtonText;
|
||||
|
||||
if (existing) {
|
||||
const updated = await prisma.storeSettingsTranslation.update({
|
||||
where: { id: existing.id },
|
||||
data: updateData
|
||||
});
|
||||
results.push(updated);
|
||||
} else {
|
||||
const created = await prisma.storeSettingsTranslation.create({
|
||||
data: { storeSettingsId: settings.id, langCode: lang, ...updateData }
|
||||
});
|
||||
results.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedSettings = await prisma.storeSettings.findFirst({
|
||||
include: { translations: true }
|
||||
});
|
||||
|
||||
return { success: true, translations: results, settings: updatedSettings };
|
||||
} catch (err) {
|
||||
console.error("[Settings] Translation Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update settings translation" };
|
||||
}
|
||||
});
|
||||
@@ -1,331 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
const toNum = (val) => {
|
||||
if (val === null || val === undefined || val === "")
|
||||
return 0;
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
export const shopeeRoute = new Elysia({ prefix: "/shopee" })
|
||||
// ── Import Orders ─────────────────────────────────────────────────────────
|
||||
.post("/import/orders", async ({ body, set }) => {
|
||||
try {
|
||||
const { rows, shop } = body;
|
||||
const shopName = shop || "hom-aroma";
|
||||
console.log(`[Shopee] Received ${rows.length} raw rows for shop: ${shopName}`);
|
||||
const orderMap = new Map();
|
||||
for (const row of rows) {
|
||||
const orderId = String(row["หมายเลขคำสั่งซื้อ"] || "").trim();
|
||||
if (!orderId)
|
||||
continue;
|
||||
if (!orderMap.has(orderId))
|
||||
orderMap.set(orderId, { meta: row, items: [] });
|
||||
orderMap.get(orderId).items.push(row);
|
||||
}
|
||||
console.log(`[Shopee] Grouped into ${orderMap.size} unique orders`);
|
||||
const CHUNK_SIZE = 30;
|
||||
const entries = Array.from(orderMap.entries());
|
||||
let upserted = 0;
|
||||
for (let i = 0; i < entries.length; i += CHUNK_SIZE) {
|
||||
const chunk = entries.slice(i, i + CHUNK_SIZE);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [orderId, { meta, items }] of chunk) {
|
||||
await tx.shopeeOrderItem.deleteMany({ where: { orderId } });
|
||||
const itemsData = items.map((item) => ({
|
||||
parentSku: String(item["เลขอ้างอิง Parent SKU"] || ""),
|
||||
productName: String(item["ชื่อสินค้า"] || ""),
|
||||
skuRef: String(item["เลขอ้างอิง SKU (SKU Reference No.)"] || ""),
|
||||
variantName: String(item["ชื่อตัวเลือก"] || ""),
|
||||
originalPrice: toNum(item["ราคาตั้งต้น"]),
|
||||
dealPrice: toNum(item["ราคาขาย"]),
|
||||
quantity: Math.max(1, Math.round(toNum(item["จำนวน"]))),
|
||||
totalPayment: toNum(item["ยอดชำระเงิน"]),
|
||||
rawData: item,
|
||||
}));
|
||||
const orderData = {
|
||||
shop: shopName,
|
||||
status: String(meta["สถานะการสั่งซื้อ"] || ""),
|
||||
buyerUsername: String(meta["ชื่อผู้ใช้ (ผู้ซื้อ)"] || ""),
|
||||
orderCreatedAt: String(meta["วันที่ทำการสั่งซื้อ"] || ""),
|
||||
paymentTime: String(meta["เวลาการชำระสินค้า"] || ""),
|
||||
paymentMethod: String(meta["ช่องทางการชำระเงิน"] || ""),
|
||||
trackingNo: String(meta["*หมายเลขติดตามพัสดุ"] || ""),
|
||||
totalAmount: toNum(meta["จำนวนเงินทั้งหมด"]),
|
||||
buyerPaidAmount: toNum(meta["ราคาสินค้าที่ชำระโดยผู้ซื้อ (THB)"]),
|
||||
shippingFee: toNum(meta["ค่าจัดส่งที่ชำระโดยผู้ซื้อ"]),
|
||||
commission: toNum(meta["ค่าคอมมิชชั่น"]),
|
||||
completedAt: String(meta["เวลาที่ทำการสั่งซื้อสำเร็จ"] || ""),
|
||||
};
|
||||
await tx.shopeeOrder.upsert({
|
||||
where: { orderId },
|
||||
update: { ...orderData, items: { create: itemsData } },
|
||||
create: { orderId, ...orderData, items: { create: itemsData } },
|
||||
});
|
||||
upserted++;
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
console.log(`[Shopee] Progress: ${Math.min(i + CHUNK_SIZE, entries.length)}/${entries.length}`);
|
||||
}
|
||||
console.log(`[Shopee] ✅ Done — ${upserted} orders upserted`);
|
||||
return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (จาก ${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length };
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[Shopee] ❌ Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}, { body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) })
|
||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
||||
.get("/orders/:orderId", async ({ params, set }) => {
|
||||
const { orderId } = params;
|
||||
const order = await prisma.shopeeOrder.findUnique({ where: { orderId }, include: { items: true } });
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
return order;
|
||||
})
|
||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
||||
.get("/orders", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 30);
|
||||
const skip = (page - 1) * limit;
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.shopeeOrder.findMany({ where, include: { items: true }, orderBy: { orderCreatedAt: "desc" }, skip, take: limit }),
|
||||
prisma.shopeeOrder.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
// ── GET Analytics (with date range filter) ───────────────────────────────
|
||||
.get("/analytics", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const dateFrom = query.dateFrom; // "YYYY-MM-DD"
|
||||
const dateTo = query.dateTo;
|
||||
// Build where — orderCreatedAt stored as "YYYY-MM-DD HH:mm" string
|
||||
const where = { shop };
|
||||
if (dateFrom || dateTo) {
|
||||
where.orderCreatedAt = {};
|
||||
if (dateFrom)
|
||||
where.orderCreatedAt.gte = dateFrom;
|
||||
if (dateTo)
|
||||
where.orderCreatedAt.lte = dateTo + " 23:59";
|
||||
}
|
||||
const allOrders = await prisma.shopeeOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, status: true, orderCreatedAt: true,
|
||||
totalAmount: true, buyerPaidAmount: true, commission: true,
|
||||
shippingFee: true, paymentMethod: true,
|
||||
items: { select: { quantity: true, totalPayment: true } },
|
||||
},
|
||||
});
|
||||
// Monthly aggregation
|
||||
const monthMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.orderCreatedAt ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthMap.has(month))
|
||||
monthMap.set(month, { month, revenue: 0, orders: 0, commission: 0, cancelled: 0, items: 0 });
|
||||
const m = monthMap.get(month);
|
||||
m.orders += 1;
|
||||
m.revenue += o.totalAmount;
|
||||
m.commission += o.commission;
|
||||
if (o.status.includes("ยกเลิก"))
|
||||
m.cancelled += 1;
|
||||
m.items += o.items.reduce((s, i) => s + i.quantity, 0);
|
||||
}
|
||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Status breakdown
|
||||
const statusMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
let status = o.status;
|
||||
if (status.includes("สำเร็จแล้ว"))
|
||||
status = "สำเร็จแล้ว";
|
||||
else if (status.includes("ได้รับสินค้าแล้ว"))
|
||||
status = "ได้รับสินค้าแล้ว";
|
||||
else if (status.includes("ยกเลิก"))
|
||||
status = "ยกเลิก";
|
||||
statusMap.set(status, (statusMap.get(status) ?? 0) + 1);
|
||||
}
|
||||
const statusBreakdown = Array.from(statusMap.entries())
|
||||
.map(([status, count]) => ({ status, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
// Payment method breakdown
|
||||
const paymentMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const key = o.paymentMethod || "Unknown";
|
||||
if (!paymentMap.has(key))
|
||||
paymentMap.set(key, { count: 0, revenue: 0 });
|
||||
const p = paymentMap.get(key);
|
||||
p.count += 1;
|
||||
p.revenue += o.buyerPaidAmount;
|
||||
}
|
||||
const paymentBreakdown = Array.from(paymentMap.entries())
|
||||
.map(([method, v]) => ({ method, ...v }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
// Top products (from filtered order IDs)
|
||||
const filteredOrderIds = allOrders.map(o => o.orderId);
|
||||
const allItems = filteredOrderIds.length > 0
|
||||
? await prisma.shopeeOrderItem.findMany({
|
||||
where: { orderId: { in: filteredOrderIds } },
|
||||
select: { productName: true, variantName: true, quantity: true, totalPayment: true, dealPrice: true },
|
||||
})
|
||||
: [];
|
||||
const productMap = new Map();
|
||||
for (const item of allItems) {
|
||||
const key = `${item.productName}__${item.variantName ?? ""}`;
|
||||
if (!productMap.has(key))
|
||||
productMap.set(key, { name: item.productName, variant: item.variantName ?? "", qty: 0, revenue: 0, orders: 0 });
|
||||
const p = productMap.get(key);
|
||||
p.qty += item.quantity;
|
||||
p.revenue += item.totalPayment;
|
||||
p.orders += 1;
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
// Summary
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.totalAmount, 0);
|
||||
const totalCommission = allOrders.reduce((s, o) => s + o.commission, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const completedOrders = allOrders.filter(o => !o.status.includes("ยกเลิก")).length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
|
||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
||||
const netRevenue = totalRevenue - totalCommission - totalShipping;
|
||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
|
||||
return {
|
||||
monthly, statusBreakdown, paymentBreakdown, topProducts,
|
||||
summary: {
|
||||
totalRevenue, totalCommission, totalShipping, netRevenue,
|
||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
||||
commissionRate: totalRevenue > 0 ? (totalCommission / totalRevenue) * 100 : 0,
|
||||
},
|
||||
};
|
||||
})
|
||||
// ── GET Analytics Overview (ALL shops combined) ──────────────────────────
|
||||
.get("/analytics-overview", async ({ query }) => {
|
||||
const dateFrom = query.dateFrom;
|
||||
const dateTo = query.dateTo;
|
||||
const where = {};
|
||||
if (dateFrom || dateTo) {
|
||||
where.orderCreatedAt = {};
|
||||
if (dateFrom)
|
||||
where.orderCreatedAt.gte = dateFrom;
|
||||
if (dateTo)
|
||||
where.orderCreatedAt.lte = dateTo + " 23:59";
|
||||
}
|
||||
const allOrders = await prisma.shopeeOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, shop: true, status: true, orderCreatedAt: true,
|
||||
totalAmount: true, buyerPaidAmount: true, commission: true,
|
||||
shippingFee: true,
|
||||
items: { select: { quantity: true, totalPayment: true } },
|
||||
},
|
||||
});
|
||||
// Per-shop summary
|
||||
const shopMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
if (!shopMap.has(o.shop)) {
|
||||
shopMap.set(o.shop, { shop: o.shop, orders: 0, completed: 0, cancelled: 0, revenue: 0, commission: 0, shippingFee: 0, items: 0 });
|
||||
}
|
||||
const s = shopMap.get(o.shop);
|
||||
s.orders += 1;
|
||||
s.revenue += o.totalAmount;
|
||||
s.commission += o.commission;
|
||||
s.shippingFee += o.shippingFee;
|
||||
s.items += o.items.reduce((acc, i) => acc + i.quantity, 0);
|
||||
if (o.status.includes("ยกเลิก"))
|
||||
s.cancelled += 1;
|
||||
else
|
||||
s.completed += 1;
|
||||
}
|
||||
const byShop = Array.from(shopMap.values()).map(s => ({
|
||||
...s,
|
||||
net: s.revenue - s.commission - s.shippingFee,
|
||||
cancelRate: s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0,
|
||||
aov: s.completed > 0 ? s.revenue / s.completed : 0,
|
||||
})).sort((a, b) => b.revenue - a.revenue);
|
||||
// Monthly stacked by shop
|
||||
const monthShopMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.orderCreatedAt ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthShopMap.has(month))
|
||||
monthShopMap.set(month, new Map());
|
||||
const sm = monthShopMap.get(month);
|
||||
if (!sm.has(o.shop))
|
||||
sm.set(o.shop, { revenue: 0, orders: 0, commission: 0 });
|
||||
const ms = sm.get(o.shop);
|
||||
ms.revenue += o.totalAmount;
|
||||
ms.orders += 1;
|
||||
ms.commission += o.commission;
|
||||
}
|
||||
const shops = Array.from(shopMap.keys());
|
||||
const monthly = Array.from(monthShopMap.entries())
|
||||
.map(([month, sm]) => {
|
||||
const row = { month };
|
||||
for (const shop of shops) {
|
||||
const d = sm.get(shop) ?? { revenue: 0, orders: 0, commission: 0 };
|
||||
row[`${shop}_revenue`] = d.revenue;
|
||||
row[`${shop}_orders`] = d.orders;
|
||||
}
|
||||
row.totalRevenue = Array.from(sm.values()).reduce((s, v) => s + v.revenue, 0);
|
||||
row.totalOrders = Array.from(sm.values()).reduce((s, v) => s + v.orders, 0);
|
||||
return row;
|
||||
})
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Top products across all shops (filtered by date range via orderId)
|
||||
const filteredOrderIds = allOrders.map(o => o.orderId);
|
||||
const allItems = filteredOrderIds.length > 0 ? await prisma.shopeeOrderItem.findMany({
|
||||
where: { orderId: { in: filteredOrderIds } },
|
||||
select: { productName: true, variantName: true, quantity: true, totalPayment: true, order: { select: { shop: true } } },
|
||||
}) : [];
|
||||
const productMap = new Map();
|
||||
for (const item of allItems) {
|
||||
const key = `${item.productName}__${item.variantName ?? ""}`;
|
||||
if (!productMap.has(key))
|
||||
productMap.set(key, { name: item.productName, variant: item.variantName ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key);
|
||||
p.qty += item.quantity;
|
||||
p.revenue += item.totalPayment;
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
// Grand totals
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.totalAmount, 0);
|
||||
const totalCommission = allOrders.reduce((s, o) => s + o.commission, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const totalCancelled = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
|
||||
return {
|
||||
byShop, monthly, topProducts, shops,
|
||||
totals: {
|
||||
totalRevenue, totalCommission, totalShipping,
|
||||
netRevenue: totalRevenue - totalCommission - totalShipping,
|
||||
totalOrders, totalCancelled,
|
||||
cancelRate: totalOrders > 0 ? (totalCancelled / totalOrders) * 100 : 0,
|
||||
aov: (totalOrders - totalCancelled) > 0 ? totalRevenue / (totalOrders - totalCancelled) : 0,
|
||||
commissionRate: totalRevenue > 0 ? (totalCommission / totalRevenue) * 100 : 0,
|
||||
},
|
||||
};
|
||||
})
|
||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
||||
.get("/summary", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [orderCount, agg, itemAgg] = await Promise.all([
|
||||
prisma.shopeeOrder.count({ where }),
|
||||
prisma.shopeeOrder.aggregate({ where, _sum: { totalAmount: true, buyerPaidAmount: true, commission: true, shippingFee: true } }),
|
||||
prisma.shopeeOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, totalPayment: true } }),
|
||||
]);
|
||||
return {
|
||||
orderCount,
|
||||
totalAmount: agg._sum.totalAmount ?? 0,
|
||||
buyerPaidAmount: agg._sum.buyerPaidAmount ?? 0,
|
||||
totalCommission: agg._sum.commission ?? 0,
|
||||
totalShipping: agg._sum.shippingFee ?? 0,
|
||||
totalItems: itemAgg._sum.quantity ?? 0,
|
||||
totalItemRevenue: itemAgg._sum.totalPayment ?? 0,
|
||||
};
|
||||
});
|
||||
//# sourceMappingURL=shopee.js.map
|
||||
@@ -1,261 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
export const tiktokRoute = new Elysia({ prefix: "/tiktok" })
|
||||
.post("/import/income", async ({ body, set }) => {
|
||||
try {
|
||||
const { data } = body;
|
||||
// ── Debug logging ────────────────────────────────────────────────────
|
||||
console.log(`[TikTok/Income] Received ${data.length} rows`);
|
||||
if (data.length > 0) {
|
||||
console.log(`[TikTok/Income] First row keys:`, Object.keys(data[0]));
|
||||
console.log(`[TikTok/Income] First row id:`, data[0].id);
|
||||
console.log(`[TikTok/Income] First row sample:`, JSON.stringify(data[0]).slice(0, 300));
|
||||
}
|
||||
// Check for duplicate/missing IDs
|
||||
const ids = data.map((r) => r.id);
|
||||
const uniqueIds = new Set(ids);
|
||||
console.log(`[TikTok/Income] Total IDs: ${ids.length}, Unique IDs: ${uniqueIds.size}`);
|
||||
if (uniqueIds.size < ids.length) {
|
||||
console.warn(`[TikTok/Income] ⚠️ Duplicate IDs detected!`);
|
||||
}
|
||||
// ── Chunked upsert (avoid transaction timeout on large files) ────────
|
||||
const CHUNK_SIZE = 50;
|
||||
let inserted = 0;
|
||||
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||
const chunk = data.slice(i, i + CHUNK_SIZE);
|
||||
await prisma.$transaction(chunk.map((row) => prisma.tiktokIncome.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
type: row.type,
|
||||
orderCreatedTime: row.orderCreatedTime,
|
||||
orderSettledTime: row.orderSettledTime,
|
||||
currency: row.currency,
|
||||
totalSettlementAmount: row.totalSettlementAmount,
|
||||
totalRevenue: row.totalRevenue,
|
||||
totalFees: row.totalFees,
|
||||
rawData: row.rawData,
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
orderCreatedTime: row.orderCreatedTime,
|
||||
orderSettledTime: row.orderSettledTime,
|
||||
currency: row.currency,
|
||||
totalSettlementAmount: row.totalSettlementAmount,
|
||||
totalRevenue: row.totalRevenue,
|
||||
totalFees: row.totalFees,
|
||||
rawData: row.rawData,
|
||||
},
|
||||
})), { timeout: 30000 });
|
||||
inserted += chunk.length;
|
||||
console.log(`[TikTok/Income] Progress: ${inserted}/${data.length}`);
|
||||
}
|
||||
console.log(`[TikTok/Income] ✅ Done — ${inserted} records upserted`);
|
||||
return { success: true, message: `Imported ${inserted} income records.` };
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[TikTok/Income] ❌ Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
})
|
||||
.post("/import/product", async ({ body, set }) => {
|
||||
try {
|
||||
const { data } = body;
|
||||
await prisma.$transaction(data.map((row) => prisma.tiktokProduct.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
productName: row.productName,
|
||||
status: row.status,
|
||||
gmv: row.gmv,
|
||||
itemsSold: row.itemsSold,
|
||||
orders: row.orders,
|
||||
rawData: row.rawData,
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
productName: row.productName,
|
||||
status: row.status,
|
||||
gmv: row.gmv,
|
||||
itemsSold: row.itemsSold,
|
||||
orders: row.orders,
|
||||
rawData: row.rawData,
|
||||
},
|
||||
})));
|
||||
return { success: true, message: `Imported ${data.length} product records.` };
|
||||
}
|
||||
catch (error) {
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
})
|
||||
.post("/import/product-sku", async ({ body, set }) => {
|
||||
try {
|
||||
const { data } = body;
|
||||
await prisma.$transaction(data.map((row) => prisma.tiktokProductSku.upsert({
|
||||
where: { id: row.id },
|
||||
update: {
|
||||
productId: row.productId,
|
||||
skuName: row.skuName,
|
||||
status: row.status,
|
||||
gmv: row.gmv,
|
||||
skuOrders: row.skuOrders,
|
||||
itemsSold: row.itemsSold,
|
||||
rawData: row.rawData,
|
||||
},
|
||||
create: {
|
||||
id: row.id,
|
||||
productId: row.productId,
|
||||
skuName: row.skuName,
|
||||
status: row.status,
|
||||
gmv: row.gmv,
|
||||
skuOrders: row.skuOrders,
|
||||
itemsSold: row.itemsSold,
|
||||
rawData: row.rawData,
|
||||
},
|
||||
})));
|
||||
return { success: true, message: `Imported ${data.length} SKU records.` };
|
||||
}
|
||||
catch (error) {
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
data: t.Array(t.Any()),
|
||||
}),
|
||||
})
|
||||
// ── GET endpoints ────────────────────────────────────────────────────────────
|
||||
.get("/income", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 50);
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.tiktokIncome.findMany({
|
||||
orderBy: { orderCreatedTime: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.tiktokIncome.count(),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
.get("/product", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 50);
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.tiktokProduct.findMany({
|
||||
orderBy: { gmv: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.tiktokProduct.count(),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
.get("/product-sku", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 50);
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.tiktokProductSku.findMany({
|
||||
orderBy: { gmv: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.tiktokProductSku.count(),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
.get("/summary", async () => {
|
||||
const [incomeCount, productCount, skuCount, incomeAgg, productAgg] = await Promise.all([
|
||||
prisma.tiktokIncome.count(),
|
||||
prisma.tiktokProduct.count(),
|
||||
prisma.tiktokProductSku.count(),
|
||||
prisma.tiktokIncome.aggregate({ _sum: { totalRevenue: true, totalSettlementAmount: true } }),
|
||||
prisma.tiktokProduct.aggregate({ _sum: { gmv: true, itemsSold: true, orders: true } }),
|
||||
]);
|
||||
return {
|
||||
incomeCount,
|
||||
productCount,
|
||||
skuCount,
|
||||
totalRevenue: incomeAgg._sum.totalRevenue ?? 0,
|
||||
totalSettlement: incomeAgg._sum.totalSettlementAmount ?? 0,
|
||||
totalGmv: productAgg._sum.gmv ?? 0,
|
||||
totalItemsSold: productAgg._sum.itemsSold ?? 0,
|
||||
totalOrders: productAgg._sum.orders ?? 0,
|
||||
};
|
||||
})
|
||||
.get("/analysis/sku", async () => {
|
||||
// 1. Top 10 by GMV
|
||||
const topGmv = await prisma.$queryRaw `
|
||||
WITH SkuStats AS (
|
||||
SELECT "id", "productId", "skuName", "gmv", "skuOrders", "itemsSold",
|
||||
CASE WHEN "itemsSold" > 0 THEN "gmv" / "itemsSold" ELSE 0 END AS "avgPrice",
|
||||
CASE WHEN "skuOrders" > 0 THEN "gmv" / "skuOrders" ELSE 0 END AS "aov"
|
||||
FROM "TiktokProductSku"
|
||||
)
|
||||
SELECT * FROM SkuStats ORDER BY "gmv" DESC LIMIT 10
|
||||
`;
|
||||
// 2. Top 10 by Volume (itemsSold)
|
||||
const topVolume = await prisma.$queryRaw `
|
||||
WITH SkuStats AS (
|
||||
SELECT "id", "productId", "skuName", "gmv", "skuOrders", "itemsSold",
|
||||
CASE WHEN "itemsSold" > 0 THEN "gmv" / "itemsSold" ELSE 0 END AS "avgPrice",
|
||||
CASE WHEN "skuOrders" > 0 THEN "gmv" / "skuOrders" ELSE 0 END AS "aov"
|
||||
FROM "TiktokProductSku"
|
||||
)
|
||||
SELECT * FROM SkuStats ORDER BY "itemsSold" DESC LIMIT 10
|
||||
`;
|
||||
// 3. Scent/Variant Grouping
|
||||
const scentAnalysis = await prisma.$queryRaw `
|
||||
WITH SplitNames AS (
|
||||
SELECT
|
||||
"id",
|
||||
"gmv",
|
||||
"itemsSold",
|
||||
"skuOrders",
|
||||
TRIM(SPLIT_PART("skuName", ':', 2)) AS "scentName"
|
||||
FROM "TiktokProductSku"
|
||||
WHERE "skuName" LIKE '%:%'
|
||||
)
|
||||
SELECT
|
||||
"scentName",
|
||||
SUM("gmv") AS "totalGmv",
|
||||
SUM("itemsSold") AS "totalItems",
|
||||
SUM("skuOrders") AS "totalOrders",
|
||||
COUNT("id") AS "skuCount"
|
||||
FROM SplitNames
|
||||
GROUP BY "scentName"
|
||||
ORDER BY "totalGmv" DESC
|
||||
LIMIT 15
|
||||
`;
|
||||
// 4. Overall Summary (Average AOV, Total GMV)
|
||||
const summaryList = await prisma.$queryRaw `
|
||||
SELECT
|
||||
SUM("gmv") AS "totalGmv",
|
||||
SUM("itemsSold") AS "totalItems",
|
||||
SUM("skuOrders") AS "totalOrders",
|
||||
CASE WHEN SUM("skuOrders") > 0 THEN SUM("gmv") / SUM("skuOrders") ELSE 0 END AS "avgAov",
|
||||
CASE WHEN SUM("itemsSold") > 0 THEN SUM("gmv") / SUM("itemsSold") ELSE 0 END AS "avgPrice"
|
||||
FROM "TiktokProductSku"
|
||||
`;
|
||||
// Handle BigInt conversion since Prisma $queryRaw returns BigInts for COUNT/SUM in Postgres sometimes.
|
||||
const serialize = (data) => JSON.parse(JSON.stringify(data, (key, value) => (typeof value === "bigint" ? Number(value) : value)));
|
||||
return serialize({
|
||||
summary: summaryList[0] || null,
|
||||
topGmv,
|
||||
topVolume,
|
||||
scentAnalysis
|
||||
});
|
||||
});
|
||||
//# sourceMappingURL=tiktok.js.map
|
||||
@@ -1,371 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
const toNum = (val) => {
|
||||
if (val === null || val === undefined || val === "")
|
||||
return 0;
|
||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
// TikTok CSV values can have trailing \t — trim everything
|
||||
const str = (val) => String(val ?? "").replace(/\t/g, "").trim();
|
||||
// TikTok dates are "DD/MM/YYYY HH:mm:ss" -> convert to "YYYY-MM-DD HH:mm:ss" for sorting/filtering
|
||||
const parseTiktokDate = (val) => {
|
||||
const d = str(val);
|
||||
if (!d)
|
||||
return "";
|
||||
const [datePart, timePart] = d.split(" ");
|
||||
if (!datePart)
|
||||
return d;
|
||||
const parts = datePart.split("/");
|
||||
if (parts.length === 3) {
|
||||
const [day, month, year] = parts;
|
||||
return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")} ${timePart || "00:00:00"}`;
|
||||
}
|
||||
return d;
|
||||
};
|
||||
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||
// ── Import (CSV rows as JSON) ──────────────────────────────────────────────
|
||||
.post("/import", async ({ body, set }) => {
|
||||
try {
|
||||
const { rows, shop } = body;
|
||||
const shopName = shop || "hom-aroma";
|
||||
console.log(`[TiktokOrder] Received ${rows.length} raw rows for shop: ${shopName}`);
|
||||
// Group rows by Order ID (trim tabs)
|
||||
const orderMap = new Map();
|
||||
for (const row of rows) {
|
||||
const orderId = str(row["Order ID"]);
|
||||
if (!orderId)
|
||||
continue;
|
||||
if (!orderMap.has(orderId))
|
||||
orderMap.set(orderId, { meta: row, items: [] });
|
||||
orderMap.get(orderId).items.push(row);
|
||||
}
|
||||
console.log(`[TiktokOrder] Grouped into ${orderMap.size} unique orders`);
|
||||
const CHUNK = 30;
|
||||
const entries = Array.from(orderMap.entries());
|
||||
let upserted = 0;
|
||||
for (let i = 0; i < entries.length; i += CHUNK) {
|
||||
const chunk = entries.slice(i, i + CHUNK);
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const [orderId, { meta, items }] of chunk) {
|
||||
// Delete old items first (sync strategy)
|
||||
await tx.tiktokOrderItem.deleteMany({ where: { orderId } });
|
||||
const itemsData = items.map((item) => ({
|
||||
skuId: str(item["SKU ID"]),
|
||||
sellerSku: str(item["Seller SKU"]),
|
||||
productName: str(item["Product Name"]),
|
||||
variation: str(item["Variation"]),
|
||||
quantity: Math.max(1, Math.round(toNum(item["Quantity"]))),
|
||||
skuQtyReturn: Math.round(toNum(item["Sku Quantity of return"])),
|
||||
unitOriginalPrice: toNum(item["SKU Unit Original Price"]),
|
||||
subtotalBeforeDiscount: toNum(item["SKU Subtotal Before Discount"]),
|
||||
platformDiscount: toNum(item["SKU Platform Discount"]),
|
||||
sellerDiscount: toNum(item["SKU Seller Discount"]),
|
||||
subtotalAfterDiscount: toNum(item["SKU Subtotal After Discount"]),
|
||||
productCategory: str(item["Product Category"]),
|
||||
weight: toNum(item["Weight(kg)"]),
|
||||
}));
|
||||
const orderData = {
|
||||
shop: shopName,
|
||||
status: str(meta["Order Status"]),
|
||||
subStatus: str(meta["Order Substatus"]),
|
||||
orderType: str(meta["Normal or Pre-order"]),
|
||||
cancelBy: str(meta["Cancel By"]),
|
||||
cancelReason: str(meta["Cancel Reason"]),
|
||||
fulfillmentType: str(meta["Fulfillment Type"]),
|
||||
warehouseName: str(meta["Warehouse Name"]),
|
||||
trackingId: str(meta["Tracking ID"]),
|
||||
deliveryOption: str(meta["Delivery Option"]),
|
||||
shippingProvider: str(meta["Shipping Provider Name"]),
|
||||
buyerUsername: str(meta["Buyer Username"]),
|
||||
paymentMethod: str(meta["Payment Method"]),
|
||||
orderAmount: toNum(meta["Order Amount"]),
|
||||
refundAmount: toNum(meta["Order Refund Amount"]),
|
||||
shippingFee: toNum(meta["Shipping Fee After Discount"]),
|
||||
originalShipping: toNum(meta["Original Shipping Fee"]),
|
||||
taxes: toNum(meta["Taxes"]),
|
||||
province: str(meta["Province"]),
|
||||
district: str(meta["District"]),
|
||||
country: str(meta["Country"]),
|
||||
createdTime: parseTiktokDate(meta["Created Time"]),
|
||||
paidTime: parseTiktokDate(meta["Paid Time"]),
|
||||
rtsTime: parseTiktokDate(meta["RTS Time"]),
|
||||
shippedTime: parseTiktokDate(meta["Shipped Time"]),
|
||||
deliveredTime: parseTiktokDate(meta["Delivered Time"]),
|
||||
cancelledTime: parseTiktokDate(meta["Cancelled Time"]),
|
||||
};
|
||||
await tx.tiktokOrder.upsert({
|
||||
where: { orderId },
|
||||
update: { ...orderData, items: { create: itemsData } },
|
||||
create: { orderId, ...orderData, items: { create: itemsData } },
|
||||
});
|
||||
upserted++;
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
console.log(`[TiktokOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
|
||||
}
|
||||
console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`);
|
||||
return { success: true, message: `นำเข้าสำเร็จ ${upserted} คำสั่งซื้อ (${rows.length} แถว)`, ordersCount: upserted, rowsCount: rows.length };
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[TiktokOrder] ❌ Error:`, error.message);
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}, { body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) })
|
||||
// ── Clear Shop Data ───────────────────────────────────────────────────────
|
||||
.delete("/clear", async ({ query, set }) => {
|
||||
try {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const result = await prisma.tiktokOrder.deleteMany({ where: { shop } });
|
||||
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
|
||||
}
|
||||
catch (error) {
|
||||
set.status = 500;
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
})
|
||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
||||
.get("/orders/:orderId", async ({ params, set }) => {
|
||||
const order = await prisma.tiktokOrder.findUnique({ where: { orderId: params.orderId }, include: { items: true } });
|
||||
if (!order) {
|
||||
set.status = 404;
|
||||
return { error: "Order not found" };
|
||||
}
|
||||
return order;
|
||||
})
|
||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
||||
.get("/orders", async ({ query }) => {
|
||||
const page = Number(query.page ?? 1);
|
||||
const limit = Number(query.limit ?? 30);
|
||||
const skip = (page - 1) * limit;
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit }),
|
||||
prisma.tiktokOrder.count({ where }),
|
||||
]);
|
||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||
})
|
||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
||||
.get("/summary", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const where = { shop };
|
||||
const [orderCount, agg, itemAgg] = await Promise.all([
|
||||
prisma.tiktokOrder.count({ where }),
|
||||
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true } }),
|
||||
prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }),
|
||||
]);
|
||||
return {
|
||||
orderCount,
|
||||
totalAmount: agg._sum.orderAmount ?? 0,
|
||||
totalShipping: agg._sum.shippingFee ?? 0,
|
||||
totalRefund: agg._sum.refundAmount ?? 0,
|
||||
totalTaxes: agg._sum.taxes ?? 0,
|
||||
totalItems: itemAgg._sum.quantity ?? 0,
|
||||
totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0,
|
||||
};
|
||||
})
|
||||
// ── GET Analytics (date range filter) ────────────────────────────────────
|
||||
.get("/analytics", async ({ query }) => {
|
||||
const shop = query.shop || "hom-aroma";
|
||||
const dateFrom = query.dateFrom;
|
||||
const dateTo = query.dateTo;
|
||||
const where = { shop };
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdTime = {};
|
||||
if (dateFrom)
|
||||
where.createdTime.gte = dateFrom;
|
||||
if (dateTo)
|
||||
where.createdTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
const allOrders = await prisma.tiktokOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, status: true, subStatus: true, createdTime: true,
|
||||
orderAmount: true, shippingFee: true, refundAmount: true, taxes: true,
|
||||
paymentMethod: true, province: true,
|
||||
items: { select: { quantity: true, subtotalAfterDiscount: true, platformDiscount: true, sellerDiscount: true } },
|
||||
},
|
||||
});
|
||||
// Monthly aggregation
|
||||
const monthMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthMap.has(month))
|
||||
monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 });
|
||||
const m = monthMap.get(month);
|
||||
m.orders += 1;
|
||||
m.revenue += o.orderAmount;
|
||||
m.platformDiscount += o.items.reduce((s, i) => s + i.platformDiscount, 0);
|
||||
m.sellerDiscount += o.items.reduce((s, i) => s + i.sellerDiscount, 0);
|
||||
if (o.status.includes("ยกเลิก") || o.status.includes("cancel"))
|
||||
m.cancelled += 1;
|
||||
m.items += o.items.reduce((s, i) => s + i.quantity, 0);
|
||||
}
|
||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Status breakdown
|
||||
const statusMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const key = o.status || "Unknown";
|
||||
statusMap.set(key, (statusMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const statusBreakdown = Array.from(statusMap.entries()).map(([status, count]) => ({ status, count })).sort((a, b) => b.count - a.count);
|
||||
// Payment method breakdown
|
||||
const payMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const key = o.paymentMethod || "Unknown";
|
||||
payMap.set(key, (payMap.get(key) ?? 0) + 1);
|
||||
}
|
||||
const paymentBreakdown = Array.from(payMap.entries()).map(([method, count]) => ({ method, count })).sort((a, b) => b.count - a.count);
|
||||
// Top products
|
||||
const filteredIds = allOrders.map(o => o.orderId);
|
||||
const allItems = filteredIds.length > 0
|
||||
? await prisma.tiktokOrderItem.findMany({
|
||||
where: { orderId: { in: filteredIds } },
|
||||
select: { productName: true, variation: true, quantity: true, subtotalAfterDiscount: true, sellerSku: true },
|
||||
})
|
||||
: [];
|
||||
const productMap = new Map();
|
||||
for (const item of allItems) {
|
||||
const key = `${item.productName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key))
|
||||
productMap.set(key, { name: item.productName, sku: item.sellerSku ?? "", variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key);
|
||||
p.qty += item.quantity;
|
||||
p.revenue += item.subtotalAfterDiscount;
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
// Summary
|
||||
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
|
||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||
const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
|
||||
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.platformDiscount, 0);
|
||||
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.sellerDiscount, 0);
|
||||
const totalOrders = allOrders.length;
|
||||
const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
|
||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
||||
const completedOrders = totalOrders - cancelledOrders;
|
||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
|
||||
return {
|
||||
monthly, statusBreakdown, paymentBreakdown, topProducts,
|
||||
summary: {
|
||||
totalRevenue, totalShipping, totalRefund, totalPlatformDisc, totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping,
|
||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
||||
},
|
||||
};
|
||||
})
|
||||
// ── GET Analytics Overview (ALL shops combined) ──────────────────────────
|
||||
.get("/analytics-overview", async ({ query }) => {
|
||||
const dateFrom = query.dateFrom;
|
||||
const dateTo = query.dateTo;
|
||||
const where = {};
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdTime = {};
|
||||
if (dateFrom)
|
||||
where.createdTime.gte = dateFrom;
|
||||
if (dateTo)
|
||||
where.createdTime.lte = dateTo + " 23:59";
|
||||
}
|
||||
const allOrders = await prisma.tiktokOrder.findMany({
|
||||
where,
|
||||
select: {
|
||||
orderId: true, shop: true, status: true, createdTime: true,
|
||||
orderAmount: true, shippingFee: true, refundAmount: true,
|
||||
items: { select: { quantity: true, platformDiscount: true, sellerDiscount: true, subtotalAfterDiscount: true } },
|
||||
},
|
||||
});
|
||||
// Per-shop summary
|
||||
const shopMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
if (!shopMap.has(o.shop)) {
|
||||
shopMap.set(o.shop, { shop: o.shop, orders: 0, completed: 0, cancelled: 0, revenue: 0, shippingFee: 0, platformDiscount: 0, sellerDiscount: 0, items: 0 });
|
||||
}
|
||||
const s = shopMap.get(o.shop);
|
||||
s.orders += 1;
|
||||
s.revenue += o.orderAmount;
|
||||
s.shippingFee += o.shippingFee;
|
||||
s.platformDiscount += o.items.reduce((acc, i) => acc + i.platformDiscount, 0);
|
||||
s.sellerDiscount += o.items.reduce((acc, i) => acc + i.sellerDiscount, 0);
|
||||
s.items += o.items.reduce((acc, i) => acc + i.quantity, 0);
|
||||
if (o.status.includes("ยกเลิก") || o.status.includes("cancel"))
|
||||
s.cancelled += 1;
|
||||
else
|
||||
s.completed += 1;
|
||||
}
|
||||
const shops = Array.from(shopMap.values());
|
||||
// Monthly stacked by shop
|
||||
const monthShopMap = new Map();
|
||||
for (const o of allOrders) {
|
||||
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
|
||||
if (!monthShopMap.has(month))
|
||||
monthShopMap.set(month, new Map());
|
||||
const sm = monthShopMap.get(month);
|
||||
if (!sm.has(o.shop))
|
||||
sm.set(o.shop, { revenue: 0, orders: 0 });
|
||||
const ms = sm.get(o.shop);
|
||||
ms.revenue += o.orderAmount;
|
||||
ms.orders += 1;
|
||||
}
|
||||
const shopKeys = Array.from(shopMap.keys());
|
||||
const monthly = Array.from(monthShopMap.entries())
|
||||
.map(([month, sm]) => {
|
||||
const row = { month };
|
||||
for (const shop of shopKeys) {
|
||||
const d = sm.get(shop) ?? { revenue: 0, orders: 0 };
|
||||
row[`${shop}_revenue`] = d.revenue;
|
||||
row[`${shop}_orders`] = d.orders;
|
||||
}
|
||||
row.totalRevenue = Array.from(sm.values()).reduce((s, v) => s + v.revenue, 0);
|
||||
row.totalOrders = Array.from(sm.values()).reduce((s, v) => s + v.orders, 0);
|
||||
row.revenue = row.totalRevenue; // Fallback for global view
|
||||
return row;
|
||||
})
|
||||
.sort((a, b) => a.month.localeCompare(b.month));
|
||||
// Top Products Global
|
||||
const filteredIds = allOrders.map(o => o.orderId);
|
||||
const allItems = filteredIds.length > 0
|
||||
? await prisma.tiktokOrderItem.findMany({
|
||||
where: { orderId: { in: filteredIds } },
|
||||
select: { productName: true, variation: true, quantity: true, subtotalAfterDiscount: true },
|
||||
})
|
||||
: [];
|
||||
const productMap = new Map();
|
||||
for (const item of allItems) {
|
||||
const key = `${item.productName}__${item.variation ?? ""}`;
|
||||
if (!productMap.has(key))
|
||||
productMap.set(key, { name: item.productName, variation: item.variation ?? "", qty: 0, revenue: 0 });
|
||||
const p = productMap.get(key);
|
||||
p.qty += item.quantity;
|
||||
p.revenue += item.subtotalAfterDiscount;
|
||||
}
|
||||
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||
// Global summary
|
||||
const totalRevenue = shops.reduce((s, sh) => s + sh.revenue, 0);
|
||||
const totalShipping = shops.reduce((s, sh) => s + sh.shippingFee, 0);
|
||||
const totalPlatformDisc = shops.reduce((s, sh) => s + sh.platformDiscount, 0);
|
||||
const totalSellerDisc = shops.reduce((s, sh) => s + sh.sellerDiscount, 0);
|
||||
const totalOrders = shops.reduce((s, sh) => s + sh.orders, 0);
|
||||
const completedOrders = shops.reduce((s, sh) => s + sh.completed, 0);
|
||||
const cancelledOrders = shops.reduce((s, sh) => s + sh.cancelled, 0);
|
||||
return {
|
||||
shops,
|
||||
monthly,
|
||||
topProducts,
|
||||
summary: {
|
||||
totalRevenue,
|
||||
totalShipping,
|
||||
totalPlatformDisc,
|
||||
totalSellerDisc,
|
||||
netRevenue: totalRevenue - totalShipping,
|
||||
totalOrders,
|
||||
completedOrders,
|
||||
cancelledOrders,
|
||||
cancelRate: totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0,
|
||||
aov: completedOrders > 0 ? totalRevenue / completedOrders : 0,
|
||||
}
|
||||
};
|
||||
});
|
||||
//# sourceMappingURL=tiktokOrder.js.map
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { join } from "path";
|
||||
import { mkdir } from "fs/promises";
|
||||
export const uploadRoute = new Elysia({ prefix: "/upload" })
|
||||
.post("/", async ({ body, set }) => {
|
||||
const { file } = body;
|
||||
if (!file) {
|
||||
set.status = 400;
|
||||
return { error: "No file uploaded" };
|
||||
}
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
set.status = 400;
|
||||
return { error: "Only image files are allowed" };
|
||||
}
|
||||
console.log(`[Upload] Processing: ${file.name} (${(file.size / 1024).toFixed(2)} KB, ${file.type})`);
|
||||
// Generate unique filename
|
||||
const ext = file.name.split(".").pop();
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const uploadDir = join(process.cwd(), "public", "uploads");
|
||||
const filePath = join(uploadDir, filename);
|
||||
try {
|
||||
// Ensure directory exists
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
// Write file using Bun.write
|
||||
await Bun.write(filePath, await file.arrayBuffer());
|
||||
const APP_URL = process.env.APP_URL || "http://localhost:3000";
|
||||
const publicUrl = `${APP_URL}/uploads/${filename}`;
|
||||
console.log(`[Upload] Success: ${filename} -> ${publicUrl}`);
|
||||
return {
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
filename
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
console.error("[Upload] Error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to save file" };
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
file: t.File({
|
||||
maxSize: '5m',
|
||||
type: ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
})
|
||||
}),
|
||||
detail: {
|
||||
summary: "Upload product image",
|
||||
tags: ["upload"]
|
||||
}
|
||||
});
|
||||
//# sourceMappingURL=upload.js.map
|
||||
+137
-2
@@ -7,9 +7,9 @@ import {
|
||||
import axios from "axios";
|
||||
import * as FormData from "form-data";
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:3000/api";
|
||||
const BACKEND_URL = "http://localhost:3051/api";
|
||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin";
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "homaroma123";
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "homaroma123"; // เปลี่ยนรหัสผ่านตรงนี้ให้ตรงกับ Production ด้วยนะครับ
|
||||
|
||||
let authToken = "";
|
||||
|
||||
@@ -100,6 +100,91 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
required: ["id", "lang", "name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_scent_translation",
|
||||
description: "Add or update translations for a specific scent.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Scent ID" },
|
||||
lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" },
|
||||
name: { type: "string", description: "Translated scent name" }
|
||||
},
|
||||
required: ["id", "lang", "name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_page_translation",
|
||||
description: "Add or update translations for a specific page.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Page ID" },
|
||||
lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" },
|
||||
title: { type: "string", description: "Translated page title" }
|
||||
},
|
||||
required: ["id", "lang", "title"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_hero_translation",
|
||||
description: "Add or update translations for a specific hero.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Hero ID" },
|
||||
lang: { type: "string", description: "Language code" },
|
||||
tagline: { type: "string" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
buttonText: { type: "string" }
|
||||
},
|
||||
required: ["id", "lang"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_menu_item_translation",
|
||||
description: "Add or update translations for a specific menu item.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Menu Item ID" },
|
||||
lang: { type: "string", description: "Language code" },
|
||||
label: { type: "string", description: "Translated menu item label" }
|
||||
},
|
||||
required: ["id", "lang", "label"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_store_settings_translation",
|
||||
description: "Add or update translations for store settings.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
lang: { type: "string", description: "Language code" },
|
||||
footerDescription: { type: "string" },
|
||||
newsletterTitle: { type: "string" },
|
||||
newsletterDescription: { type: "string" },
|
||||
newsletterPlaceholder: { type: "string" },
|
||||
newsletterButtonText: { type: "string" }
|
||||
},
|
||||
required: ["lang"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_article_translation",
|
||||
description: "Add or update translations for a specific article.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "Article ID" },
|
||||
lang: { type: "string", description: "Language code" },
|
||||
title: { type: "string" },
|
||||
content: { type: "string" }
|
||||
},
|
||||
required: ["id", "lang"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_product_content",
|
||||
description: "Update product basic content (name, description, details)",
|
||||
@@ -373,6 +458,56 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }]
|
||||
};
|
||||
}
|
||||
case "update_scent_translation": {
|
||||
const { id, lang, name } = params as any;
|
||||
const res = await api.patch(`/products/scents/${id}/translations`, {
|
||||
name: { [lang]: name }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "update_page_translation": {
|
||||
const { id, lang, title } = params as any;
|
||||
const res = await api.patch(`/pages/${id}/translations`, {
|
||||
title: { [lang]: title }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "update_hero_translation": {
|
||||
const { id, lang, tagline, title, description, buttonText } = params as any;
|
||||
const payload: any = {};
|
||||
if (tagline) payload.tagline = { [lang]: tagline };
|
||||
if (title) payload.title = { [lang]: title };
|
||||
if (description) payload.description = { [lang]: description };
|
||||
if (buttonText) payload.buttonText = { [lang]: buttonText };
|
||||
const res = await api.patch(`/hero/${id}/translations`, payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "update_menu_item_translation": {
|
||||
const { id, lang, label } = params as any;
|
||||
const res = await api.patch(`/menus/items/${id}/translations`, {
|
||||
label: { [lang]: label }
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "update_store_settings_translation": {
|
||||
const { lang, footerDescription, newsletterTitle, newsletterDescription, newsletterPlaceholder, newsletterButtonText } = params as any;
|
||||
const payload: any = {};
|
||||
if (footerDescription) payload.footerDescription = { [lang]: footerDescription };
|
||||
if (newsletterTitle) payload.newsletterTitle = { [lang]: newsletterTitle };
|
||||
if (newsletterDescription) payload.newsletterDescription = { [lang]: newsletterDescription };
|
||||
if (newsletterPlaceholder) payload.newsletterPlaceholder = { [lang]: newsletterPlaceholder };
|
||||
if (newsletterButtonText) payload.newsletterButtonText = { [lang]: newsletterButtonText };
|
||||
const res = await api.patch(`/settings/translations`, payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "update_article_translation": {
|
||||
const { id, lang, title, content } = params as any;
|
||||
const payload: any = {};
|
||||
if (title) payload.title = { [lang]: title };
|
||||
if (content) payload.content = { [lang]: content };
|
||||
const res = await api.patch(`/articles/${id}/translations`, payload);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
|
||||
case "update_product_content": {
|
||||
const payload: any = {};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay } from 'swiper/modules';
|
||||
import type { AnnouncementBarSectionPayload } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css';
|
||||
@@ -10,7 +12,10 @@ interface DynamicAnnouncementBarProps {
|
||||
}
|
||||
|
||||
export default function DynamicAnnouncementBar({ payload }: DynamicAnnouncementBarProps) {
|
||||
const { slides = [], backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
|
||||
const slides = (payload.slides || []).map((slide: any) => getLocalizedField(slide, lang));
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { BannerSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicBannerProps {
|
||||
payload: BannerSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicBanner({ payload }: DynamicBannerProps) {
|
||||
const { imageUrl, linkUrl, title, description, buttonText } = payload;
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { imageUrl, linkUrl } = payload;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
const buttonText = getLocalizedField(payload.buttonText, lang);
|
||||
|
||||
const Wrapper = linkUrl ? Link : 'div';
|
||||
const wrapperProps = linkUrl ? { to: linkUrl } : {};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getCategoryImage, type Category, type CategoriesSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicCategoriesProps {
|
||||
payload: CategoriesSectionPayload;
|
||||
@@ -9,7 +11,11 @@ interface DynamicCategoriesProps {
|
||||
}
|
||||
|
||||
export default function DynamicCategories({ payload, categories, loading }: DynamicCategoriesProps) {
|
||||
const { title, subtitle, categoryIds } = payload;
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { categoryIds } = payload;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const subtitle = getLocalizedField(payload.subtitle, lang);
|
||||
|
||||
// Filter categories if categoryIds list is provided
|
||||
const displayedCategories = React.useMemo(() => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Pagination } from 'swiper/modules';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { EditorialCarouselSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css';
|
||||
@@ -12,7 +14,16 @@ interface DynamicEditorialCarouselProps {
|
||||
}
|
||||
|
||||
export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCarouselProps) {
|
||||
const { title, slides = [] } = payload;
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const slides = (payload.slides || []).map((slide: any) => ({
|
||||
...slide,
|
||||
title: getLocalizedField(slide.title, lang),
|
||||
description: getLocalizedField(slide.description, lang),
|
||||
buttonText: getLocalizedField(slide.buttonText, lang),
|
||||
category: getLocalizedField(slide.category, lang)
|
||||
}));
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { EditorialTileSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicEditorialTileProps {
|
||||
payload: EditorialTileSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicEditorialTile({ payload }: DynamicEditorialTileProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
imageUrl,
|
||||
imagePosition = 'left',
|
||||
buttonText,
|
||||
buttonLink,
|
||||
backgroundColor = '#FDFDF9', // Default to spa-light
|
||||
textColor = '#1F3D2D' // Default to spa-forest
|
||||
} = payload;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
const buttonText = getLocalizedField(payload.buttonText, lang);
|
||||
|
||||
const renderTitle = (text: string) => {
|
||||
if (!text) return null;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { HeroSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicHeroProps {
|
||||
payload: HeroSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
const { tagline, title, description, imageUrl, buttonText, buttonLink } = payload;
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { imageUrl, buttonLink } = payload;
|
||||
const tagline = getLocalizedField(payload.tagline, lang);
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
const buttonText = getLocalizedField(payload.buttonText, lang);
|
||||
|
||||
const renderTitle = (text: string) => {
|
||||
if (!text) return null;
|
||||
@@ -44,14 +52,10 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
|
||||
<div className="relative z-10 text-center space-y-6 px-8 animate-fade-in max-w-4xl w-full mx-auto mt-16 md:mt-0">
|
||||
<p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
|
||||
{tagline || "Vibrant Elegance Collection"}
|
||||
{tagline || t('hero.defaultTagline')}
|
||||
</p>
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-light text-white leading-tight tracking-wide drop-shadow-md font-serif">
|
||||
{renderTitle(title) || (
|
||||
<>
|
||||
Inhale <span className="text-spa-gold font-medium italic">Serenity</span>
|
||||
</>
|
||||
)}
|
||||
{renderTitle(title) || renderTitle(t('hero.defaultTitle'))}
|
||||
</h1>
|
||||
<p className="text-spa-light/90 max-w-2xl mx-auto text-lg md:text-xl font-light pt-4 pb-8 drop-shadow-md">
|
||||
{description}
|
||||
@@ -61,13 +65,13 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
||||
to={buttonLink || "/shop"}
|
||||
className="px-8 py-4 bg-white text-spa-forest rounded-full font-bold uppercase tracking-widest text-sm hover:bg-spa-sage hover:shadow-xl transition-all duration-300"
|
||||
>
|
||||
{buttonText || "Explore Collection"}
|
||||
{buttonText || t('hero.exploreCollection')}
|
||||
</Link>
|
||||
<a
|
||||
href="#categories"
|
||||
className="px-8 py-4 text-white font-medium uppercase tracking-widest text-sm hover:text-spa-gold flex items-center gap-2 group transition-colors"
|
||||
>
|
||||
Learn More
|
||||
{t('hero.learnMore')}
|
||||
<span className="group-hover:translate-x-1 transition-transform">→</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicHeroArticleProps {
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps) {
|
||||
const { title, subtitle, imageUrl, author, date, category } = payload || {};
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const p = payload || {};
|
||||
const { imageUrl, date } = p;
|
||||
const title = getLocalizedField(p.title, lang);
|
||||
const subtitle = getLocalizedField(p.subtitle, lang);
|
||||
const author = getLocalizedField(p.author, lang);
|
||||
const category = getLocalizedField(p.category, lang);
|
||||
|
||||
return (
|
||||
<section className="relative w-full min-h-[60vh] md:min-h-[80vh] flex items-center justify-center bg-spa-sage overflow-hidden">
|
||||
@@ -20,7 +30,7 @@ export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps)
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-4xl md:text-6xl font-serif mb-6 leading-tight">
|
||||
{title || 'Scent Guide Article'}
|
||||
{title || t('newsletter.articleDefaultTitle', 'Scent Guide Article')}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="text-lg md:text-xl font-light opacity-90 max-w-2xl mx-auto mb-8">
|
||||
@@ -29,7 +39,7 @@ export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps)
|
||||
)}
|
||||
{(author || date) && (
|
||||
<div className="text-sm font-medium tracking-wide uppercase opacity-80 flex items-center justify-center gap-4">
|
||||
{author && <span>By {author}</span>}
|
||||
{author && <span>{t('newsletter.byAuthor', { author })}</span>}
|
||||
{author && date && <span>•</span>}
|
||||
{date && <span>{date}</span>}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { API_BASE, type NewsletterSectionPayload } from '../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicNewsletterProps {
|
||||
payload: NewsletterSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
buttonText = 'Subscribe',
|
||||
placeholderText = 'Enter your email address',
|
||||
showPrivacyCheckbox = true,
|
||||
privacyText = 'I agree to the privacy policy.'
|
||||
} = payload;
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const description = getLocalizedField(payload.description, lang);
|
||||
const buttonText = getLocalizedField(payload.buttonText, lang) || t('newsletter.subscribe');
|
||||
const placeholderText = getLocalizedField(payload.placeholderText, lang) || t('checkout.emailAddress');
|
||||
const privacyText = getLocalizedField(payload.privacyText, lang) || 'I agree to the privacy policy.';
|
||||
const { showPrivacyCheckbox = true } = payload;
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [agreed, setAgreed] = useState(!showPrivacyCheckbox);
|
||||
@@ -61,7 +63,7 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||
<div className="max-w-3xl mx-auto text-center space-y-8 relative z-10">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-3xl md:text-5xl font-serif font-light tracking-wide text-spa-gold">
|
||||
{title || "Begin Your Wellness Journey"}
|
||||
{title || t('newsletter.defaultTitle')}
|
||||
</h2>
|
||||
<p className="text-spa-light/80 text-sm md:text-base font-light max-w-xl mx-auto">
|
||||
{description}
|
||||
@@ -70,8 +72,8 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||
|
||||
{status === 'success' ? (
|
||||
<div className="p-6 bg-white/5 border border-spa-gold/30 rounded-2xl animate-pop-in space-y-2">
|
||||
<h4 className="text-spa-gold font-semibold text-lg">Thank You for Subscribing</h4>
|
||||
<p className="text-xs text-spa-light/75">You have successfully joined our newsletter list. We will send you exclusive sensory privileges soon.</p>
|
||||
<h4 className="text-spa-gold font-semibold text-lg">{t('newsletter.thankYou')}</h4>
|
||||
<p className="text-xs text-spa-light/75">{t('newsletter.successMessage')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg mx-auto">
|
||||
@@ -90,7 +92,7 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
||||
disabled={status === 'submitting'}
|
||||
className="px-8 py-4 bg-white text-spa-forest hover:bg-spa-sage rounded-full font-bold text-sm tracking-widest uppercase transition-all disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{status === 'submitting' ? 'Submitting...' : buttonText}
|
||||
{status === 'submitting' ? t('newsletter.subscribing') : buttonText}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,13 +4,20 @@ import ProductCard from './ProductCard';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
import 'swiper/css/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicProductCarouselProps {
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export default function DynamicProductCarousel({ payload }: DynamicProductCarouselProps) {
|
||||
const { title, description, products = [] } = payload || {};
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const p = payload || {};
|
||||
const { products = [] } = p;
|
||||
const title = getLocalizedField(p.title, lang);
|
||||
const description = getLocalizedField(p.description, lang);
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-spa-sage/30 px-6">
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Pagination } from 'swiper/modules';
|
||||
import ProductCard from './ProductCard';
|
||||
import type { Product, ProductsSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
// Import Swiper styles
|
||||
import 'swiper/css';
|
||||
@@ -13,7 +15,11 @@ interface DynamicProductsProps {
|
||||
}
|
||||
|
||||
export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
||||
const { title, subtitle, products = [], layout = 'grid', enableQuickShop = false } = payload;
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { products = [], layout = 'grid', enableQuickShop = false } = payload;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const subtitle = getLocalizedField(payload.subtitle, lang);
|
||||
|
||||
return (
|
||||
<section className="bg-pop-bg py-24 px-6 relative">
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicQuoteCalloutProps {
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProps) {
|
||||
const { quote, author, role } = payload || {};
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const p = payload || {};
|
||||
const quote = getLocalizedField(p.quote, lang);
|
||||
const author = getLocalizedField(p.author, lang);
|
||||
const role = getLocalizedField(p.role, lang);
|
||||
|
||||
return (
|
||||
<section className="py-24 px-6 bg-white">
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicScentNotesBreakdownProps {
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNotesBreakdownProps) {
|
||||
const { title, description, topNotes, heartNotes, baseNotes } = payload || {};
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const p = payload || {};
|
||||
const title = getLocalizedField(p.title, lang);
|
||||
const description = getLocalizedField(p.description, lang);
|
||||
const topNotes = getLocalizedField(p.topNotes, lang);
|
||||
const heartNotes = getLocalizedField(p.heartNotes, lang);
|
||||
const baseNotes = getLocalizedField(p.baseNotes, lang);
|
||||
|
||||
return (
|
||||
<section className="py-20 px-6 bg-spa-light">
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { TextSectionPayload } from '@homaroma/types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../utils/localization';
|
||||
|
||||
interface DynamicTextProps {
|
||||
payload: TextSectionPayload;
|
||||
}
|
||||
|
||||
export default function DynamicText({ payload }: DynamicTextProps) {
|
||||
const { title, content, textAlign = 'left' } = payload;
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { textAlign = 'left' } = payload;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const content = getLocalizedField(payload.content, lang);
|
||||
|
||||
const alignClass = {
|
||||
left: 'text-left',
|
||||
|
||||
@@ -284,7 +284,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
||||
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
|
||||
<div className="bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-xl p-1">
|
||||
<div className="px-3 py-2 border-b border-spa-stone/10 mb-1">
|
||||
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">Signed in as</p>
|
||||
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
||||
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
|
||||
</div>
|
||||
<Link
|
||||
@@ -297,7 +297,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
||||
onClick={logoutLiff}
|
||||
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-500 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors font-bold"
|
||||
>
|
||||
Logout
|
||||
{t('nav.logout', 'Logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -307,7 +307,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
||||
onClick={loginLiff}
|
||||
className="px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
|
||||
>
|
||||
LINE Login
|
||||
{t('nav.login', 'LINE Login')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -503,14 +503,14 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
||||
onClick={() => { logoutLiff(); setMobileOpen(false); }}
|
||||
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest text-rose-400 hover:bg-rose-500/10 transition-colors border border-rose-500/20 mt-2"
|
||||
>
|
||||
Logout
|
||||
{t('nav.logout', 'Logout')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { loginLiff(); setMobileOpen(false); }}
|
||||
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest text-[#00B900] hover:bg-[#00B900]/10 transition-colors border border-[#00B900]/50 mt-2"
|
||||
>
|
||||
Login with LINE
|
||||
{t('nav.login', 'Login with LINE')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
"findMyScent": "Find My Scent ✨",
|
||||
"promotions": "Promotions",
|
||||
"profile": "My Profile",
|
||||
"signedInAs": "Signed in as",
|
||||
"logout": "Logout",
|
||||
"login": "LINE Login",
|
||||
"search": {
|
||||
"title": "Search Collection",
|
||||
"placeholder": "Search scents, categories...",
|
||||
@@ -55,5 +58,47 @@
|
||||
"moreRelated": "More {{category}}",
|
||||
"reviews": "Reviews",
|
||||
"viewDetails": "View Details →"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Checkout",
|
||||
"shippingInfo": "Shipping Information",
|
||||
"fullName": "Full Name *",
|
||||
"phoneNumber": "Phone Number *",
|
||||
"emailAddress": "Email Address *",
|
||||
"shippingAddress": "Shipping Address *",
|
||||
"selectProfileAddress": "Select address from profile:",
|
||||
"confirmOrder": "Confirm Order",
|
||||
"orderSummary": "Order Summary",
|
||||
"discountCode": "Discount Code",
|
||||
"placeholderDiscount": "e.g. SUMMER2026",
|
||||
"apply": "Apply",
|
||||
"remove": "Remove",
|
||||
"usePoints": "Use Loyalty Points",
|
||||
"pointsAvailable": "{{points}} Points Available",
|
||||
"applyPoints": "Apply points discount",
|
||||
"pointsDiscount": "Points (-฿{{points}})",
|
||||
"subtotal": "Subtotal",
|
||||
"shippingFee": "Shipping Fee",
|
||||
"shippingDiscount": "Shipping Discount",
|
||||
"coupon": "Coupon",
|
||||
"pointsRedeemed": "Points Redeemed",
|
||||
"total": "Total",
|
||||
"invalidCoupon": "Invalid coupon",
|
||||
"errorValidating": "Error validating coupon",
|
||||
"failedOrder": "Failed to place order",
|
||||
"errorOccurred": "An error occurred. Please try again."
|
||||
},
|
||||
"hero": {
|
||||
"defaultTagline": "Vibrant Elegance Collection",
|
||||
"exploreCollection": "Explore Collection",
|
||||
"learnMore": "Learn More"
|
||||
},
|
||||
"newsletter": {
|
||||
"defaultTitle": "Begin Your Wellness Journey",
|
||||
"articleDefaultTitle": "Scent Guide Article",
|
||||
"byAuthor": "By {{author}}",
|
||||
"subscribing": "Submitting...",
|
||||
"thankYou": "Thank You for Subscribing",
|
||||
"successMessage": "You have successfully joined our newsletter list. We will send you exclusive sensory privileges soon."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@
|
||||
"findMyScent": "ค้นหากลิ่นของคุณ ✨",
|
||||
"promotions": "โปรโมชั่น",
|
||||
"profile": "โปรไฟล์ของฉัน",
|
||||
"signedInAs": "เข้าสู่ระบบในชื่อ",
|
||||
"logout": "ออกจากระบบ",
|
||||
"login": "เข้าสู่ระบบด้วย LINE",
|
||||
"search": {
|
||||
"title": "ค้นหาสินค้า",
|
||||
"placeholder": "ค้นหากลิ่น, คอลเลกชัน...",
|
||||
@@ -55,5 +58,47 @@
|
||||
"moreRelated": "เพิ่มเติมในหมวด {{category}}",
|
||||
"reviews": "รีวิว",
|
||||
"viewDetails": "ดูรายละเอียด →"
|
||||
},
|
||||
"checkout": {
|
||||
"title": "ชำระเงิน",
|
||||
"shippingInfo": "ข้อมูลการจัดส่ง",
|
||||
"fullName": "ชื่อ-นามสกุล *",
|
||||
"phoneNumber": "เบอร์โทรศัพท์ *",
|
||||
"emailAddress": "อีเมล *",
|
||||
"shippingAddress": "ที่อยู่จัดส่ง *",
|
||||
"selectProfileAddress": "เลือกดึงที่อยู่จัดส่งจากโปรไฟล์:",
|
||||
"confirmOrder": "ยืนยันการสั่งซื้อ",
|
||||
"orderSummary": "สรุปคำสั่งซื้อ",
|
||||
"discountCode": "รหัสส่วนลด",
|
||||
"placeholderDiscount": "เช่น SUMMER2026",
|
||||
"apply": "ใช้ส่วนลด",
|
||||
"remove": "ลบ",
|
||||
"usePoints": "ใช้คะแนนสะสม",
|
||||
"pointsAvailable": "มี {{points}} คะแนน",
|
||||
"applyPoints": "ใช้ส่วนลดด้วยคะแนน",
|
||||
"pointsDiscount": "คะแนน (-฿{{points}})",
|
||||
"subtotal": "ยอดรวม",
|
||||
"shippingFee": "ค่าจัดส่ง",
|
||||
"shippingDiscount": "ส่วนลดค่าจัดส่ง",
|
||||
"coupon": "คูปอง",
|
||||
"pointsRedeemed": "คะแนนที่ใช้",
|
||||
"total": "ยอดรวมสุทธิ",
|
||||
"invalidCoupon": "คูปองไม่ถูกต้อง",
|
||||
"errorValidating": "เกิดข้อผิดพลาดในการตรวจสอบคูปอง",
|
||||
"failedOrder": "ไม่สามารถทำรายการได้",
|
||||
"errorOccurred": "เกิดข้อผิดพลาด โปรดลองอีกครั้ง"
|
||||
},
|
||||
"hero": {
|
||||
"defaultTagline": "คอลเลกชันหรูหรามีชีวิตชีวา",
|
||||
"exploreCollection": "เลือกชมคอลเลกชัน",
|
||||
"learnMore": "ดูรายละเอียดเพิ่มเติม"
|
||||
},
|
||||
"newsletter": {
|
||||
"defaultTitle": "เริ่มต้นการเดินทางเพื่อสุขภาพของคุณ",
|
||||
"articleDefaultTitle": "บทความแนะนำกลิ่น",
|
||||
"byAuthor": "เขียนโดย {{author}}",
|
||||
"subscribing": "กำลังบันทึก...",
|
||||
"thankYou": "ขอบคุณที่สมัครรับข่าวสาร",
|
||||
"successMessage": "คุณได้เข้าร่วมรับข่าวสารจากเราเรียบร้อยแล้ว เราจะส่งสิทธิพิเศษให้คุณเร็วๆ นี้"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import { API_BASE, formatPrice, type Category, type CustomerAddress } from '../t
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { items, getCartTotal, clearCart } = useCartStore()
|
||||
const { customer } = useAuthStore()
|
||||
@@ -203,7 +205,7 @@ export default function CheckoutPage() {
|
||||
setIsFreeShipping(data.isFreeShipping)
|
||||
} else {
|
||||
removeCoupon()
|
||||
setCouponError(data.error || 'Coupon is no longer valid for this cart')
|
||||
setCouponError(data.error || t('checkout.invalidCoupon'))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -227,13 +229,13 @@ export default function CheckoutPage() {
|
||||
setDiscountAmount(data.discountAmount)
|
||||
setIsFreeShipping(data.isFreeShipping)
|
||||
} else {
|
||||
setCouponError(data.error || 'Invalid coupon')
|
||||
setCouponError(data.error || t('checkout.invalidCoupon'))
|
||||
setAppliedCoupon(null)
|
||||
setDiscountAmount(0)
|
||||
setIsFreeShipping(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setCouponError('Error validating coupon')
|
||||
setCouponError(t('checkout.errorValidating'))
|
||||
} finally {
|
||||
setCouponLoading(false)
|
||||
}
|
||||
@@ -300,11 +302,11 @@ export default function CheckoutPage() {
|
||||
|
||||
navigate(`/order/${responseData.id}`)
|
||||
} else {
|
||||
alert(responseData.error || 'Failed to place order')
|
||||
alert(responseData.error || t('checkout.failedOrder'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('An error occurred. Please try again.')
|
||||
alert(t('checkout.errorOccurred'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -315,14 +317,14 @@ export default function CheckoutPage() {
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 text-pop-black">
|
||||
<h1 className="text-4xl font-bold mb-12">Checkout</h1>
|
||||
<h1 className="text-4xl font-bold mb-12">{t('checkout.title')}</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-12 relative">
|
||||
{/* Form Section */}
|
||||
<div className="flex-1 space-y-8 order-2 lg:order-1">
|
||||
<form id="checkout-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 shadow-sm">
|
||||
<h2 className="text-xl font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">Shipping Information</h2>
|
||||
<h2 className="text-xl font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">{t('checkout.shippingInfo')}</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
@@ -338,7 +340,7 @@ export default function CheckoutPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="checkout-phone" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Phone Number *</label>
|
||||
<label htmlFor="checkout-phone" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">{t('checkout.phoneNumber')}</label>
|
||||
<input
|
||||
id="checkout-phone"
|
||||
type="tel"
|
||||
@@ -352,7 +354,7 @@ export default function CheckoutPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label htmlFor="checkout-email" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Email Address *</label>
|
||||
<label htmlFor="checkout-email" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">{t('checkout.emailAddress')}</label>
|
||||
<input
|
||||
id="checkout-email"
|
||||
type="email"
|
||||
@@ -364,7 +366,7 @@ export default function CheckoutPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label htmlFor="checkout-address" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Shipping Address *</label>
|
||||
<label htmlFor="checkout-address" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">{t('checkout.shippingAddress')}</label>
|
||||
<textarea
|
||||
id="checkout-address"
|
||||
required
|
||||
@@ -376,7 +378,7 @@ export default function CheckoutPage() {
|
||||
/>
|
||||
{savedAddresses.length > 0 && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/70">เลือกดึงที่อยู่จัดส่งจากโปรไฟล์:</p>
|
||||
<p className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/70">{t('checkout.selectProfileAddress')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{savedAddresses.map(addr => {
|
||||
const formatted = `${addr.addressLine} ต.${addr.subdistrict} อ.${addr.district} จ.${addr.province} ${addr.zipcode}`
|
||||
@@ -410,7 +412,7 @@ export default function CheckoutPage() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
<span>Confirm Order</span>
|
||||
<span>{t('checkout.confirmOrder')}</span>
|
||||
</div>
|
||||
<span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span>
|
||||
</button>
|
||||
@@ -425,7 +427,7 @@ export default function CheckoutPage() {
|
||||
onClick={() => setIsSummaryExpanded(!isSummaryExpanded)}
|
||||
className={`w-full flex items-center justify-between text-xl font-bold uppercase tracking-widest text-spa-forest pb-4 ${isSummaryExpanded ? 'border-b border-spa-stone/10 mb-6' : ''} lg:pointer-events-none lg:border-b lg:border-spa-stone/10 lg:mb-6 transition-all`}
|
||||
>
|
||||
<span>Order Summary</span>
|
||||
<span>{t('checkout.orderSummary')}</span>
|
||||
<span className="lg:hidden flex items-center gap-3">
|
||||
{!isSummaryExpanded && <span className="text-sm font-bold text-rose-500 normal-case">{formatPrice(total)}</span>}
|
||||
<span className="text-spa-stone">
|
||||
@@ -439,13 +441,13 @@ export default function CheckoutPage() {
|
||||
<div className="mb-6 pb-6 border-b border-spa-stone/10">
|
||||
{!appliedCoupon ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">Discount Code</label>
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">{t('checkout.discountCode')}</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={e => setCouponCode(e.target.value.toUpperCase())}
|
||||
placeholder="e.g. SUMMER2026"
|
||||
placeholder={t('checkout.placeholderDiscount')}
|
||||
className="flex-1 bg-spa-light border border-spa-stone/20 rounded-lg px-3 text-sm text-pop-black focus:border-spa-forest outline-none uppercase tracking-widest placeholder:text-spa-stone/50"
|
||||
/>
|
||||
<button
|
||||
@@ -454,7 +456,7 @@ export default function CheckoutPage() {
|
||||
disabled={couponLoading || !couponCode.trim()}
|
||||
className="px-4 bg-spa-stone/10 hover:bg-spa-stone/20 border border-spa-stone/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all text-pop-black"
|
||||
>
|
||||
{couponLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Apply'}
|
||||
{couponLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : t('checkout.apply')}
|
||||
</button>
|
||||
</div>
|
||||
{couponError && <p className="text-xs text-rose-500 mt-1 font-medium">{couponError}</p>}
|
||||
@@ -467,7 +469,7 @@ export default function CheckoutPage() {
|
||||
</svg>
|
||||
<span className="text-sm font-bold tracking-widest text-spa-forest">{appliedCoupon}</span>
|
||||
</div>
|
||||
<button type="button" onClick={removeCoupon} className="text-xs text-spa-stone/80 hover:text-rose-500 uppercase tracking-widest font-bold">Remove</button>
|
||||
<button type="button" onClick={removeCoupon} className="text-xs text-spa-stone/80 hover:text-rose-500 uppercase tracking-widest font-bold">{t('checkout.remove')}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -477,9 +479,9 @@ export default function CheckoutPage() {
|
||||
<div className="mb-6 pb-6 border-b border-spa-stone/10 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80 flex items-center gap-2">
|
||||
<span className="text-purple-500 text-sm">⭐️</span> Use Loyalty Points
|
||||
<span className="text-purple-500 text-sm">⭐️</span> {t('checkout.usePoints')}
|
||||
</label>
|
||||
<span className="text-xs font-bold text-spa-forest">{customerPoints} Points Available</span>
|
||||
<span className="text-xs font-bold text-spa-forest">{t('checkout.pointsAvailable', { points: customerPoints })}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
@@ -492,7 +494,7 @@ export default function CheckoutPage() {
|
||||
}}
|
||||
className="w-4 h-4 text-spa-forest border-spa-stone/20 rounded focus:ring-spa-forest"
|
||||
/>
|
||||
<label htmlFor="use-points" className="text-sm text-pop-black font-medium">Apply points discount</label>
|
||||
<label htmlFor="use-points" className="text-sm text-pop-black font-medium">{t('checkout.applyPoints')}</label>
|
||||
</div>
|
||||
{usePoints && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
@@ -504,7 +506,7 @@ export default function CheckoutPage() {
|
||||
onChange={e => setPointsToUse(Math.min(Number(e.target.value), customerPoints, maxPointsAllowed))}
|
||||
className="w-24 bg-spa-light border border-spa-stone/20 rounded-lg px-3 py-1.5 text-sm text-pop-black focus:border-spa-forest outline-none"
|
||||
/>
|
||||
<span className="text-xs text-spa-stone/80 font-bold uppercase tracking-widest">Points (-฿{pointsToUse})</span>
|
||||
<span className="text-xs text-spa-stone/80 font-bold uppercase tracking-widest">{t('checkout.pointsDiscount', { points: pointsToUse })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -524,18 +526,18 @@ export default function CheckoutPage() {
|
||||
|
||||
<div className="border-t border-spa-stone/10 pt-4 space-y-3 font-medium text-pop-black">
|
||||
<div className="flex justify-between text-sm text-spa-stone">
|
||||
<span>Subtotal</span>
|
||||
<span>{t('checkout.subtotal')}</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-spa-stone">
|
||||
<span>Shipping Fee</span>
|
||||
<span>{t('checkout.shippingFee')}</span>
|
||||
{loadingSettings ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
||||
<span className={isFreeShipping ? "line-through text-spa-stone/50" : ""}>{formatPrice(shippingFee)}</span>
|
||||
}
|
||||
</div>
|
||||
{isFreeShipping && (
|
||||
<div className="flex justify-between text-sm text-spa-forest">
|
||||
<span>Shipping Discount</span>
|
||||
<span>{t('checkout.shippingDiscount')}</span>
|
||||
<span>- {formatPrice(shippingFee)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -553,18 +555,18 @@ export default function CheckoutPage() {
|
||||
)}
|
||||
{discountAmount > 0 && (
|
||||
<div className="flex justify-between text-sm text-rose-500">
|
||||
<span>Coupon ({appliedCoupon})</span>
|
||||
<span>{t('checkout.coupon')} ({appliedCoupon})</span>
|
||||
<span>- {formatPrice(discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
{pointsDiscount > 0 && (
|
||||
<div className="flex justify-between text-sm text-purple-500">
|
||||
<span>Points Redeemed</span>
|
||||
<span>{t('checkout.pointsRedeemed')}</span>
|
||||
<span>- {formatPrice(pointsDiscount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-spa-stone/10 pt-3 mt-3 flex justify-between text-lg font-bold text-pop-black">
|
||||
<span>Total</span>
|
||||
<span>{t('checkout.total')}</span>
|
||||
<span className="text-rose-500">{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,7 +581,7 @@ export default function CheckoutPage() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{loading && <Loader2 className="w-5 h-5 animate-spin" />}
|
||||
<span>Confirm Order</span>
|
||||
<span>{t('checkout.confirmOrder')}</span>
|
||||
</div>
|
||||
<span className="text-sm border-l border-white/20 pl-4">{formatPrice(total)}</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export function getLocalizedField(field: any, currentLang: string): string {
|
||||
if (field === null || field === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof field === 'string') {
|
||||
return field;
|
||||
}
|
||||
if (typeof field === 'object') {
|
||||
if (field[currentLang] !== undefined) {
|
||||
return field[currentLang];
|
||||
}
|
||||
if (field['en'] !== undefined) {
|
||||
return field['en'];
|
||||
}
|
||||
const keys = Object.keys(field);
|
||||
if (keys.length > 0) {
|
||||
return field[keys[0]];
|
||||
}
|
||||
}
|
||||
return String(field);
|
||||
}
|
||||
Reference in new issue
Block a user