x
This commit is contained in:
1 parent
20f43bd58d
commit
1ac727f4e5
10 files changed
+306
-122
No files matched your search
@@ -37,6 +37,21 @@ description: Main Agent / Multi-Agent Orchestrator ทำหน้าที่
|
||||
- **RULE #4: รายงานความคืบหน้าให้ User เป็นระยะ**
|
||||
ไม่หายเงียบไปนานๆ เมื่อสั่งงาน Sub Agent หรือรัน Task ยาวๆ ควรรายงานสถานะให้คุณกิตติศักดิ์ทราบ
|
||||
|
||||
> [!CAUTION]
|
||||
> **RULE #5: ห้ามมักง่าย ทำทางลัด หรือเบี่ยงเบนเป้าหมายเด็ดขาด (No Shortcuts & No Bypassing)**
|
||||
> หาก User สั่งให้ทำงานผ่านเครื่องมือใด (เช่น สั่งให้ทดสอบการใช้ MCP Tool) **ต้องใช้เครื่องมือนั้นจริงๆ ตามที่ถูกออกแบบมาเท่านั้น** ห้ามมักง่ายเขียนสคริปต์ยิง API ตรงเพื่อเลี่ยงการใช้ Tool (Bypass), ห้ามคิดอ้อม และต้องโฟกัสที่เป้าหมายที่ User กำหนดไว้อย่างเคร่งครัด หาก Tool มีปัญหา ให้หาวิธีแก้ไขที่ต้นตอของ Tool ไม่ใช่หาทางลัดเพื่อทำงานให้เสร็จไปเฉยๆ
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **RULE #6: ห้ามคาดเดาเองเด็ดขาดเมื่อข้อมูลไม่เพียงพอ (Ask, Don't Guess)**
|
||||
> หากได้รับโจทย์แล้วรู้สึกว่ามีข้อมูล บริบท หรือ Requirements ไม่ครบถ้วนชัดเจน **ห้ามคิดไปเอง หรือเดาสุ่มเด็ดขาด** ให้หยุดทำงานนั้นทันที แล้วสอบถามข้อมูลเพิ่มเติมหรือขอคำชี้แจงจาก User ทันที เพื่อป้องกันการพัฒนาระบบไปผิดทาง
|
||||
|
||||
> [!TIP]
|
||||
> **RULE #7: กระบวนการอัปเดต Taskboard (Taskboard Updates & Closures)**
|
||||
> หาก User สั่งให้ "อัปเดต Taskboard" หรือคำสั่งที่มีความหมายทำนองเดียวกัน ให้เข้าใจตรงกันว่า:
|
||||
> 1. คุณต้องสรุปผลงานที่ทำไปอย่างเข้าใจง่าย
|
||||
> 2. นำไปสร้างหรืออัปเดตเป็น Main Task ใหม่ตามความเหมาะสมผ่าน Taskboard MCP
|
||||
> 3. ทำการปิด (Complete) Task ที่เสร็จสิ้นแล้วผ่านเครื่องมือ Taskboard MCP ของโปรเจกต์ `homaroma` เสมอ
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Task Delegation Workflow (ขั้นตอนการทำงาน)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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 as any).role !== "ADMIN") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Admin access required" };
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
|
||||
export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
.get("/", async () => {
|
||||
@@ -26,6 +27,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
}
|
||||
return menu;
|
||||
})
|
||||
.use(adminOnly)
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const { name, items } = body as { name: string; items?: { label: string; url: string; order: number }[] };
|
||||
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
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 () => {
|
||||
@@ -118,10 +126,52 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
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 as { email: string };
|
||||
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 as any;
|
||||
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";
|
||||
@@ -165,17 +215,6 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
return { error: "Failed to delete page" };
|
||||
}
|
||||
})
|
||||
.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("/:slug/sections", async ({ params: { slug }, body, set }) => {
|
||||
try {
|
||||
const pageId = Number(slug);
|
||||
@@ -280,30 +319,6 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
}))
|
||||
})
|
||||
})
|
||||
.post("/newsletter", async ({ body, set }) => {
|
||||
try {
|
||||
const { email } = body as { email: string };
|
||||
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()
|
||||
})
|
||||
})
|
||||
.delete("/sections/:sectionId", async ({ params: { sectionId }, set }) => {
|
||||
try {
|
||||
await prisma.pageSection.delete({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 }) => {
|
||||
@@ -17,6 +18,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
return { error: "Failed to fetch settings" };
|
||||
}
|
||||
})
|
||||
.use(adminOnly)
|
||||
.put("/", async ({ body, set }) => {
|
||||
try {
|
||||
const data = body as any;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
// Visit https://aka.ms/tsconfig to read more about this file
|
||||
"compilerOptions": {
|
||||
// File Layout
|
||||
// "rootDir": "./src",
|
||||
// "outDir": "./dist",
|
||||
|
||||
// Environment Settings
|
||||
// See also https://aka.ms/tsconfig/module
|
||||
"module": "nodenext",
|
||||
"target": "esnext",
|
||||
"types": [],
|
||||
// For nodejs:
|
||||
// "lib": ["esnext"],
|
||||
// "types": ["node"],
|
||||
// and npm install -D @types/node
|
||||
|
||||
// Other Outputs
|
||||
"sourceMap": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
|
||||
// Stricter Typechecking Options
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
||||
// Style Options
|
||||
// "noImplicitReturns": true,
|
||||
// "noImplicitOverride": true,
|
||||
// "noUnusedLocals": true,
|
||||
// "noUnusedParameters": true,
|
||||
// "noFallthroughCasesInSwitch": true,
|
||||
// "noPropertyAccessFromIndexSignature": true,
|
||||
|
||||
// Recommended Options
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"moduleDetection": "force",
|
||||
"skipLibCheck": true,
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,54 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
},
|
||||
required: ["sectionId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_footer_content",
|
||||
description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
brandDescription: { type: "string", description: "Text for Block 1 (Brand tagline)" },
|
||||
socialLinks: {
|
||||
type: "object",
|
||||
description: "URLs for facebook, instagram, tiktok (e.g. { facebookUrl: '...' })",
|
||||
properties: {
|
||||
facebookUrl: { type: "string" },
|
||||
instagramUrl: { type: "string" },
|
||||
tiktokUrl: { type: "string" }
|
||||
}
|
||||
},
|
||||
exploreLinks: {
|
||||
type: "array",
|
||||
description: "Array of { label, url, order } for Block 2 (Explore)",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
|
||||
}
|
||||
},
|
||||
collectionLinks: {
|
||||
type: "array",
|
||||
description: "Array of { label, url, order } for Block 3 (Collections)",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
|
||||
}
|
||||
},
|
||||
supportLinks: {
|
||||
type: "array",
|
||||
description: "Array of { label, url, order } for Block 4 (Support/Legal)",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "audit_missing_pages",
|
||||
description: "Audit all menus and find links that do not have a corresponding dynamic page or hardcoded route",
|
||||
inputSchema: { type: "object", properties: {} }
|
||||
}
|
||||
],
|
||||
};
|
||||
@@ -421,6 +469,98 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "update_footer_content": {
|
||||
const results = [];
|
||||
|
||||
if (params.brandDescription !== undefined || params.socialLinks) {
|
||||
const settingsPayload: any = {};
|
||||
if (params.brandDescription !== undefined) settingsPayload.footerDescription = params.brandDescription;
|
||||
if (params.socialLinks?.facebookUrl !== undefined) settingsPayload.facebookUrl = params.socialLinks.facebookUrl;
|
||||
if (params.socialLinks?.instagramUrl !== undefined) settingsPayload.instagramUrl = params.socialLinks.instagramUrl;
|
||||
if (params.socialLinks?.tiktokUrl !== undefined) settingsPayload.tiktokUrl = params.socialLinks.tiktokUrl;
|
||||
|
||||
if (Object.keys(settingsPayload).length > 0) {
|
||||
// First fetch existing settings because the backend requires full payload validation
|
||||
const currentSettingsRes = await api.get("/settings");
|
||||
const currentSettings = currentSettingsRes.data || {};
|
||||
|
||||
const mergedSettings = { ...currentSettings, ...settingsPayload };
|
||||
// Remove null values so Elysia validation doesn't fail on t.Optional(t.String())
|
||||
for (const key in mergedSettings) {
|
||||
if (mergedSettings[key] === null) {
|
||||
delete mergedSettings[key];
|
||||
}
|
||||
}
|
||||
await api.put("/settings", mergedSettings);
|
||||
results.push({ type: "settings", status: "success" });
|
||||
}
|
||||
}
|
||||
|
||||
if (params.exploreLinks) {
|
||||
await api.put("/menus/footer-explore", { items: params.exploreLinks });
|
||||
results.push({ type: "explore_links", status: "success" });
|
||||
}
|
||||
|
||||
if (params.collectionLinks) {
|
||||
await api.put("/menus/footer-collections", { items: params.collectionLinks });
|
||||
results.push({ type: "collection_links", status: "success" });
|
||||
}
|
||||
|
||||
if (params.supportLinks) {
|
||||
await api.put("/menus/footer-support", { items: params.supportLinks });
|
||||
results.push({ type: "support_links", status: "success" });
|
||||
}
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
||||
}
|
||||
case "audit_missing_pages": {
|
||||
// 1. Fetch all menus
|
||||
const menusRes = await api.get("/menus");
|
||||
const menus = menusRes.data || [];
|
||||
|
||||
// 2. Fetch all dynamic pages
|
||||
const pagesRes = await api.get("/pages");
|
||||
const pages = pagesRes.data || [];
|
||||
const createdSlugs = pages.map((p: any) => `/${p.slug}`);
|
||||
|
||||
// 3. Known SPA Hardcoded Routes
|
||||
const hardcodedRoutes = [
|
||||
"/", "/shop", "/login", "/cart", "/checkout", "/account", "/search", "/contact"
|
||||
];
|
||||
|
||||
const missingLinks: any[] = [];
|
||||
|
||||
// 4. Audit
|
||||
menus.forEach((menu: any) => {
|
||||
if (menu.items && Array.isArray(menu.items)) {
|
||||
menu.items.forEach((item: any) => {
|
||||
const url = item.url;
|
||||
if (!url) return;
|
||||
|
||||
// Skip external links
|
||||
if (url.startsWith("http") || url.startsWith("mailto:") || url.startsWith("tel:")) return;
|
||||
|
||||
// Skip SPA Hardcoded routes
|
||||
if (hardcodedRoutes.includes(url)) return;
|
||||
|
||||
// Skip dynamic collection routes natively handled by SPA (e.g. /collections/...)
|
||||
if (url.startsWith("/collections/")) return;
|
||||
|
||||
// Check if it exists in created dynamic pages
|
||||
if (!createdSlugs.includes(url)) {
|
||||
missingLinks.push({
|
||||
menuName: menu.name,
|
||||
label: item.label,
|
||||
url: url,
|
||||
suggestedSlug: url.startsWith("/") ? url.substring(1) : url
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { content: [{ type: "text", text: JSON.stringify({ missingPages: missingLinks }, null, 2) }] };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${request.params.name}`);
|
||||
}
|
||||
|
||||
@@ -52,8 +52,8 @@ function App() {
|
||||
<Route path="/article/:slug" element={<ArticleReaderPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/pages/:slug" element={<CustomPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/:slug" element={<CustomPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -9,41 +9,9 @@ interface FooterProps {
|
||||
export default function Footer({ categories = [] }: FooterProps) {
|
||||
const { t } = useTranslation()
|
||||
const [exploreMenu, setExploreMenu] = useState<MenuItem[]>([])
|
||||
const [supportMenu, setSupportMenu] = useState<MenuItem[]>([])
|
||||
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [subscribing, setSubscribing] = useState(false)
|
||||
const [subscribeStatus, setSubscribeStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [subscribeMessage, setSubscribeMessage] = useState('')
|
||||
|
||||
const handleSubscribe = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email) return
|
||||
setSubscribing(true)
|
||||
setSubscribeStatus('idle')
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/newsletter/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
})
|
||||
const data = await res.json()
|
||||
if (res.ok) {
|
||||
setSubscribeStatus('success')
|
||||
setSubscribeMessage(data.message || 'Subscribed!')
|
||||
setEmail('')
|
||||
} else {
|
||||
setSubscribeStatus('error')
|
||||
setSubscribeMessage(data.error || data.message || 'Failed to subscribe')
|
||||
}
|
||||
} catch (err) {
|
||||
setSubscribeStatus('error')
|
||||
setSubscribeMessage('An error occurred. Please try again.')
|
||||
} finally {
|
||||
setSubscribing(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/menus/footer-explore`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
@@ -52,6 +20,13 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
fetch(`${API_BASE}/menus/footer-support`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (data && data.items) setSupportMenu(data.items.sort((a: any, b: any) => a.order - b.order))
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
fetch(`${API_BASE}/settings`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
@@ -124,46 +99,19 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">
|
||||
{settings?.newsletterTitle || 'Newsletter'}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500">
|
||||
{settings?.newsletterDescription || 'Subscribe for exclusive releases and sensory wisdom.'}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.support', 'Support & Legal')}</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||
{supportMenu.map(item => (
|
||||
<Link key={item.id} to={item.url} className="hover:text-white transition-colors">{item.label}</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subscribeStatus === 'success' ? (
|
||||
<div className="p-4 bg-cyan-glow/10 border border-cyan-glow/20 rounded-lg text-cyan-glow text-sm text-center animate-fade-in">
|
||||
✨ {subscribeMessage}
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubscribe} className="space-y-2">
|
||||
<div className="flex bg-white/5 border border-white/10 p-1 rounded-sm focus-within:border-cyan-glow/50 transition-colors">
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder={settings?.newsletterPlaceholder || "Your email"}
|
||||
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-600"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={subscribing}
|
||||
className="bg-white text-black px-4 py-2 font-bold text-xs uppercase hover:bg-cyan-glow transition-colors shrink-0 disabled:opacity-50"
|
||||
>
|
||||
{subscribing ? '...' : (settings?.newsletterButtonText || 'Join')}
|
||||
</button>
|
||||
</div>
|
||||
{subscribeStatus === 'error' && (
|
||||
<p className="text-[10px] text-rose-500">{subscribeMessage}</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
|
||||
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
|
||||
</p>
|
||||
<div className="pt-4">
|
||||
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
|
||||
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
|
||||
import Navbar from '../components/Navbar';
|
||||
import Footer from '../components/Footer';
|
||||
import DynamicPageRenderer from '../components/DynamicPageRenderer';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
import { API_BASE, type Category, type PageSection } from '../types';
|
||||
|
||||
export default function CustomPage() {
|
||||
@@ -78,20 +79,7 @@ export default function CustomPage() {
|
||||
<div className="w-12 h-12 border-4 border-spa-forest border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="min-h-[60vh] flex flex-col items-center justify-center space-y-6 px-6 text-center">
|
||||
<h2 className="text-3xl font-serif font-light tracking-wide text-spa-forest">
|
||||
Sensory Page Not Found
|
||||
</h2>
|
||||
<p className="text-spa-stone/70 text-sm max-w-md font-light">
|
||||
The page you are looking for does not exist or may have been moved.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="px-8 py-3 bg-spa-forest text-white hover:bg-spa-forest/90 rounded-full font-bold text-xs uppercase tracking-widest transition-all"
|
||||
>
|
||||
Return to Home
|
||||
</Link>
|
||||
</div>
|
||||
<NotFoundPage />
|
||||
) : (
|
||||
<div className={`animate-fade-in ${announcementSections.length > 0 ? 'pt-[32px] md:pt-[40px]' : ''}`}>
|
||||
<DynamicPageRenderer
|
||||
|
||||
Reference in new issue
Block a user