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);
|
||||
}
|
||||
@@ -83,8 +83,10 @@
|
||||
"dependencies": {
|
||||
"@homaroma/types": "workspace:*",
|
||||
"@line/liff": "^2.28.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"i18next": "^26.0.4",
|
||||
"lucide-react": "^1.17.0",
|
||||
"marked": "^18.0.5",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^17.0.2",
|
||||
@@ -95,6 +97,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/dompurify": "^3.2.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -591,6 +594,8 @@
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||
@@ -607,6 +612,8 @@
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||
|
||||
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
|
||||
|
||||
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/type-utils": "8.60.1", "@typescript-eslint/utils": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg=="],
|
||||
@@ -865,6 +872,8 @@
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dompurify": ["dompurify@3.4.10", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w=="],
|
||||
|
||||
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
@@ -1247,6 +1256,8 @@
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"mcp-server": ["mcp-server@workspace:apps/mcp-server"],
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Order Confirmation Template
|
||||
|
||||
## English Version
|
||||
**Subject:** 🌟 Thank you for choosing HomAroma! Your order [Order ID] is confirmed.
|
||||
|
||||
Hi [Customer Name],
|
||||
|
||||
Thank you for shopping with **HomAroma**! We've received your order and are preparing it with care. Your space is about to smell wonderful!
|
||||
|
||||
**Order Summary (Order #[Order ID])**
|
||||
- **Date:** [Order Date]
|
||||
- **Shipping Address:** [Shipping Address]
|
||||
|
||||
[Items List - Image, Name, Qty, Price]
|
||||
|
||||
**Total:** [Total Amount]
|
||||
|
||||
We will send you another update as soon as your package ships. If you need any assistance, feel free to reply to this email or contact us via LINE OA.
|
||||
|
||||
Relax, Breathe, and Enjoy,
|
||||
**The HomAroma Team**
|
||||
|
||||
---
|
||||
|
||||
## Thai Version
|
||||
**หัวข้อ/Subject:** 🌟 ขอบคุณที่เลือก HomAroma! คำสั่งซื้อของคุณ [Order ID] ได้รับการยืนยันแล้ว
|
||||
|
||||
สวัสดีคุณ [Customer Name],
|
||||
|
||||
ขอบคุณที่ช้อปกับ **HomAroma**! เราได้รับคำสั่งซื้อของคุณเรียบร้อยแล้ว และกำลังจัดเตรียมสินค้าด้วยความใส่ใจ เพื่อให้พื้นที่ของคุณอบอวลไปด้วยความหอมในเร็วๆ นี้!
|
||||
|
||||
**สรุปคำสั่งซื้อ (หมายเลขคำสั่งซื้อ #[Order ID])**
|
||||
- **วันที่:** [Order Date]
|
||||
- **ที่อยู่จัดส่ง:** [Shipping Address]
|
||||
|
||||
[รายการสินค้า - รูปภาพ, ชื่อ, จำนวน, ราคา]
|
||||
|
||||
**ยอดรวมทั้งสิ้น:** [Total Amount]
|
||||
|
||||
เราจะแจ้งให้คุณทราบอีกครั้งทันทีที่สินค้าถูกจัดส่ง หากคุณต้องการความช่วยเหลือเพิ่มเติม สามารถตอบกลับอีเมลนี้หรือติดต่อเราผ่านทาง LINE OA ได้เลยค่ะ
|
||||
|
||||
สัมผัสความหอม ผ่อนคลายไปกับเรา,
|
||||
**ทีมงาน HomAroma**
|
||||
@@ -0,0 +1,41 @@
|
||||
# Shipping Tracking Notification Template
|
||||
|
||||
## English Version
|
||||
**Subject:** 🚚 Good news! Your HomAroma order #[Order ID] is on its way.
|
||||
|
||||
Hi [Customer Name],
|
||||
|
||||
Get ready to elevate your space! The items from order #[Order ID] have been packed and handed over to our delivery partner.
|
||||
|
||||
**Tracking Details:**
|
||||
- **Carrier:** [Carrier Name]
|
||||
- **Tracking Number:** [Tracking Number]
|
||||
- **Track Your Package:** [Tracking Link/Button]
|
||||
|
||||
*Please note: It may take up to 24 hours for the tracking information to update in the carrier's system.*
|
||||
|
||||
If you have any questions about your delivery, we’re always here to help.
|
||||
|
||||
Relax, Breathe, and Enjoy,
|
||||
**The HomAroma Team**
|
||||
|
||||
---
|
||||
|
||||
## Thai Version
|
||||
**หัวข้อ/Subject:** 🚚 ข่าวดี! คำสั่งซื้อ HomAroma ของคุณกำลังเดินทางไปหาแล้ว (หมายเลข #[Order ID])
|
||||
|
||||
สวัสดีคุณ [Customer Name],
|
||||
|
||||
เตรียมพร้อมยกระดับบรรยากาศในพื้นที่ของคุณได้เลย! สินค้าจากคำสั่งซื้อหมายเลข #[Order ID] ได้ถูกแพ็คอย่างดีและส่งมอบให้บริษัทขนส่งเรียบร้อยแล้ว
|
||||
|
||||
**ข้อมูลการจัดส่ง:**
|
||||
- **บริษัทขนส่ง:** [Carrier Name]
|
||||
- **หมายเลขพัสดุ:** [Tracking Number]
|
||||
- **ติดตามพัสดุของคุณ:** [Tracking Link/Button]
|
||||
|
||||
*หมายเหตุ: ข้อมูลการติดตามอาจใช้เวลาถึง 24 ชั่วโมงในการอัปเดตเข้าระบบของบริษัทขนส่ง*
|
||||
|
||||
หากคุณมีคำถามหรือข้อสงสัยเกี่ยวกับการจัดส่ง สามารถติดต่อเราได้เสมอ เราพร้อมดูแลคุณ
|
||||
|
||||
สัมผัสความหอม ผ่อนคลายไปกับเรา,
|
||||
**ทีมงาน HomAroma**
|
||||
@@ -0,0 +1,45 @@
|
||||
# Privacy Policy | นโยบายความเป็นส่วนตัว
|
||||
|
||||
## English
|
||||
**Last Updated: [Date]**
|
||||
|
||||
Welcome to **HomAroma**. We respect your privacy and are committed to protecting your personal data. This privacy policy explains how we collect, use, and share information about you when you use our website, shop for our products, or interact with us.
|
||||
|
||||
### 1. Information We Collect
|
||||
- **Personal Information:** Name, email address, phone number, shipping and billing address.
|
||||
- **Payment Information:** Processed securely via our payment gateways. We do not store full credit card details.
|
||||
- **Usage Data:** IP address, browser type, interaction with our website.
|
||||
|
||||
### 2. How We Use Your Information
|
||||
- To process and fulfill your orders.
|
||||
- To communicate with you about your order, new products, and promotions.
|
||||
- To improve our website, customer service, and overall shopping experience.
|
||||
|
||||
### 3. Sharing of Information
|
||||
We do not sell your personal information. We may share information with trusted third-party service providers (e.g., shipping partners, payment processors) strictly for the purpose of fulfilling our services to you.
|
||||
|
||||
### 4. Your Rights
|
||||
You have the right to access, correct, or delete your personal information. Please contact us at support@homaroma.com if you wish to exercise these rights.
|
||||
|
||||
---
|
||||
|
||||
## ภาษาไทย
|
||||
**อัปเดตล่าสุดเมื่อ: [วันที่]**
|
||||
|
||||
ยินดีต้อนรับสู่ **HomAroma** เราให้ความสำคัญกับความเป็นส่วนตัวของคุณและมุ่งมั่นที่จะปกป้องข้อมูลส่วนบุคคลของคุณ นโยบายความเป็นส่วนตัวนี้อธิบายถึงวิธีการที่เราเก็บรวบรวม นำไปใช้ และเปิดเผยข้อมูลของคุณเมื่อคุณใช้งานเว็บไซต์ เลือกซื้อสินค้า หรือติดต่อกับเรา
|
||||
|
||||
### 1. ข้อมูลที่เราเก็บรวบรวม
|
||||
- **ข้อมูลส่วนบุคคล:** ชื่อ, อีเมล, เบอร์โทรศัพท์, ที่อยู่สำหรับจัดส่งและออกใบเสร็จ
|
||||
- **ข้อมูลการชำระเงิน:** ดำเนินการอย่างปลอดภัยผ่านผู้ให้บริการรับชำระเงินของเรา เราไม่เก็บข้อมูลบัตรเครดิตฉบับเต็มของคุณ
|
||||
- **ข้อมูลการใช้งาน:** หมายเลข IP, ประเภทของเบราว์เซอร์, และพฤติกรรมการใช้งานบนเว็บไซต์ของเรา
|
||||
|
||||
### 2. วิธีการนำข้อมูลไปใช้
|
||||
- เพื่อดำเนินการและจัดส่งคำสั่งซื้อของคุณ
|
||||
- เพื่อติดต่อสื่อสารเกี่ยวกับคำสั่งซื้อ สินค้าใหม่ และโปรโมชั่นต่างๆ
|
||||
- เพื่อพัฒนาเว็บไซต์ การบริการลูกค้า และประสบการณ์การช้อปปิ้งโดยรวม
|
||||
|
||||
### 3. การเปิดเผยข้อมูล
|
||||
เราไม่มีนโยบายขายข้อมูลส่วนบุคคลของคุณ เราอาจเปิดเผยข้อมูลแก่ผู้ให้บริการบุคคลที่สามที่เชื่อถือได้ (เช่น บริษัทขนส่ง, ผู้ให้บริการชำระเงิน) เพื่อวัตถุประสงค์ในการให้บริการแก่คุณเท่านั้น
|
||||
|
||||
### 4. สิทธิของคุณ
|
||||
คุณมีสิทธิในการเข้าถึง แก้ไข หรือลบข้อมูลส่วนบุคคลของคุณ โปรดติดต่อเราที่ support@homaroma.com หากต้องการใช้สิทธิดังกล่าว
|
||||
@@ -0,0 +1,33 @@
|
||||
# Return & Refund Policy | นโยบายการคืนสินค้าและการคืนเงิน
|
||||
|
||||
## English
|
||||
We want you to love your **HomAroma** products. If you are not completely satisfied, we are here to help.
|
||||
|
||||
### 1. Returns
|
||||
You have 14 days to return an item from the date you received it.
|
||||
To be eligible for a return, your item must be unused, in the same condition that you received it, and in its original packaging. Please keep the receipt or proof of purchase.
|
||||
|
||||
*Note: Custom-made or personalized items, and items bought on final sale cannot be returned.*
|
||||
|
||||
### 2. Refunds
|
||||
Once we receive your item, we will inspect it and notify you of the status of your refund. If your return is approved, we will initiate a refund to your original method of payment. You will receive the credit within a certain amount of days, depending on your card issuer's policies.
|
||||
|
||||
### 3. Return Shipping
|
||||
You will be responsible for paying for your own shipping costs for returning your item. Shipping costs are non-refundable.
|
||||
|
||||
---
|
||||
|
||||
## ภาษาไทย
|
||||
เราอยากให้คุณหลงรักผลิตภัณฑ์จาก **HomAroma** แต่หากคุณไม่ประทับใจ เราพร้อมให้ความช่วยเหลืออย่างเต็มที่
|
||||
|
||||
### 1. การคืนสินค้า
|
||||
คุณสามารถขอคืนสินค้าได้ภายใน 14 วัน นับจากวันที่คุณได้รับสินค้า
|
||||
เพื่อให้เข้าเงื่อนไขการคืน สินค้าของคุณจะต้องยังไม่ถูกใช้งาน อยู่ในสภาพเดิมตามที่คุณได้รับ และอยู่ในบรรจุภัณฑ์เดิม โปรดเก็บใบเสร็จหรือหลักฐานการซื้อไว้
|
||||
|
||||
*หมายเหตุ: สินค้าสั่งทำพิเศษ สินค้าที่ปรับแต่งเฉพาะบุคคล หรือสินค้าลดล้างสต็อก (Final Sale) ไม่สามารถขอคืนได้*
|
||||
|
||||
### 2. การคืนเงิน
|
||||
เมื่อเราได้รับสินค้าของคุณแล้ว เราจะตรวจสอบและแจ้งสถานะการคืนเงินให้คุณทราบ หากการคืนสินค้าได้รับการอนุมัติ เราจะดำเนินการคืนเงินกลับไปยังช่องทางการชำระเงินเดิมของคุณ โดยคุณจะได้รับยอดเงินคืนภายในระยะเวลาที่กำหนด ขึ้นอยู่กับนโยบายของผู้ออกบัตร/ธนาคารของคุณ
|
||||
|
||||
### 3. ค่าจัดส่งในการคืนสินค้า
|
||||
คุณจะเป็นผู้รับผิดชอบค่าใช้จ่ายในการจัดส่งสำหรับการคืนสินค้าด้วยตนเอง ค่าจัดส่งสินค้าไม่สามารถขอคืนเงินได้
|
||||
@@ -0,0 +1,37 @@
|
||||
# Shipping Policy | นโยบายการจัดส่งสินค้า
|
||||
|
||||
## English
|
||||
At **HomAroma**, we strive to deliver your sensory experiences as quickly and safely as possible.
|
||||
|
||||
### 1. Processing Time
|
||||
All orders are processed within 1-2 business days (excluding weekends and holidays) after receiving your order confirmation email. You will receive another notification when your order has shipped.
|
||||
|
||||
### 2. Shipping Rates & Delivery Estimates
|
||||
Shipping charges for your order will be calculated and displayed at checkout.
|
||||
- **Standard Shipping (Domestic):** 3-5 business days. Free for orders over 1,500 THB. Otherwise, a flat rate applies.
|
||||
- **Express Shipping (Domestic):** 1-2 business days for an additional fee.
|
||||
|
||||
### 3. Order Tracking
|
||||
Once your order has shipped, you will receive an email or LINE notification with your tracking number. Please allow up to 24 hours for the tracking portal to update.
|
||||
|
||||
### 4. Damages
|
||||
If you receive a damaged item, please contact our customer support immediately with a photo of the package and the damaged product, and we will arrange a replacement.
|
||||
|
||||
---
|
||||
|
||||
## ภาษาไทย
|
||||
ที่ **HomAroma** เรามุ่งมั่นที่จะส่งมอบประสบการณ์แห่งความหอมถึงมือคุณอย่างรวดเร็วและปลอดภัยที่สุด
|
||||
|
||||
### 1. ระยะเวลาดำเนินการ
|
||||
คำสั่งซื้อทั้งหมดจะถูกดำเนินการภายใน 1-2 วันทำการ (ไม่รวมวันหยุดสุดสัปดาห์และวันหยุดนักขัตฤกษ์) หลังจากที่คุณได้รับอีเมลยืนยันคำสั่งซื้อ คุณจะได้รับการแจ้งเตือนอีกครั้งเมื่อสินค้าถูกจัดส่ง
|
||||
|
||||
### 2. ค่าจัดส่งและระยะเวลาโดยประมาณ
|
||||
ค่าจัดส่งจะถูกคำนวณและแสดงให้เห็นในขั้นตอนการชำระเงิน
|
||||
- **จัดส่งแบบมาตรฐาน (ในประเทศ):** 3-5 วันทำการ จัดส่งฟรี! สำหรับคำสั่งซื้อที่มียอดตั้งแต่ 1,500 บาทขึ้นไป (หากต่ำกว่า จะมีค่าจัดส่งราคาเหมาจ่าย)
|
||||
- **จัดส่งแบบด่วน (ในประเทศ):** 1-2 วันทำการ (มีค่าใช้จ่ายเพิ่มเติม)
|
||||
|
||||
### 3. การติดตามสถานะการจัดส่ง
|
||||
เมื่อคำสั่งซื้อของคุณถูกจัดส่งแล้ว คุณจะได้รับอีเมลหรือข้อความทาง LINE พร้อมหมายเลขพัสดุ (Tracking Number) โปรดรอประมาณ 24 ชั่วโมงเพื่อให้ระบบขนส่งอัปเดตสถานะ
|
||||
|
||||
### 4. สินค้าเสียหาย
|
||||
หากคุณได้รับสินค้าที่ชำรุดหรือเสียหาย โปรดติดต่อฝ่ายบริการลูกค้าของเราทันทีพร้อมภาพถ่ายของกล่องพัสดุและสินค้าที่เสียหาย เรายินดีดำเนินการเปลี่ยนสินค้าให้คุณใหม่
|
||||
Reference in new issue
Block a user