x
This commit is contained in:
1 parent
3965f4f4da
commit
70877882b0
82 files changed
+4353
-186
No files matched your search
@@ -0,0 +1,8 @@
|
||||
import prisma from './src/lib/prisma';
|
||||
|
||||
async function main() {
|
||||
const users = await prisma.user.findMany();
|
||||
console.log("Registered Users:", users);
|
||||
}
|
||||
|
||||
main().catch(console.error).finally(() => prisma.$disconnect());
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Menu" ADD COLUMN "rootImage" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "MenuItem" ADD COLUMN "columnImage" TEXT,
|
||||
ADD COLUMN "hoverImage" TEXT;
|
||||
@@ -639,6 +639,7 @@ model Menu {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique // e.g., "header", "footer"
|
||||
payload Json?
|
||||
rootImage String?
|
||||
isActive Boolean @default(true)
|
||||
items MenuItem[]
|
||||
createdAt DateTime @default(now())
|
||||
@@ -652,6 +653,8 @@ model MenuItem {
|
||||
label String
|
||||
url String
|
||||
order Int @default(0)
|
||||
columnImage String?
|
||||
hoverImage String?
|
||||
translations MenuItemTranslation[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -24,19 +24,30 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
}
|
||||
});
|
||||
if (!menu) {
|
||||
return { name, items: [], payload: null, isActive: false };
|
||||
return { name, items: [], payload: null, rootImage: null, isActive: false };
|
||||
}
|
||||
return menu;
|
||||
})
|
||||
.use(adminOnly)
|
||||
.post("/", async ({ body, set }) => {
|
||||
try {
|
||||
const { name, items } = body as { name: string; items?: { label: string; url: string; order: number }[] };
|
||||
const { name, rootImage, items } = body as {
|
||||
name: string;
|
||||
rootImage?: string | null;
|
||||
items?: { label: string; url: string; order: number; columnImage?: string | null; hoverImage?: string | null }[]
|
||||
};
|
||||
const menu = await prisma.menu.create({
|
||||
data: {
|
||||
name,
|
||||
rootImage,
|
||||
items: items ? {
|
||||
create: items
|
||||
create: items.map(item => ({
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
order: item.order,
|
||||
columnImage: item.columnImage,
|
||||
hoverImage: item.hoverImage
|
||||
}))
|
||||
} : undefined
|
||||
},
|
||||
include: { items: true }
|
||||
@@ -50,25 +61,38 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String(),
|
||||
rootImage: t.Optional(t.Nullable(t.String())),
|
||||
items: t.Optional(t.Array(t.Object({
|
||||
label: t.String(),
|
||||
url: t.String(),
|
||||
order: t.Number()
|
||||
order: t.Number(),
|
||||
columnImage: t.Optional(t.Nullable(t.String())),
|
||||
hoverImage: t.Optional(t.Nullable(t.String()))
|
||||
})))
|
||||
})
|
||||
})
|
||||
.put("/:name", async ({ params: { name }, body, set }) => {
|
||||
try {
|
||||
const { items, payload, isActive } = body as {
|
||||
const { items, payload, isActive, rootImage } = body as {
|
||||
payload?: any;
|
||||
isActive?: boolean;
|
||||
items: { id?: number; label: string; url: string; order: number; translations?: { langCode: string; label: string }[] }[]
|
||||
rootImage?: string | null;
|
||||
items: {
|
||||
id?: number;
|
||||
label: string;
|
||||
url: string;
|
||||
order: number;
|
||||
columnImage?: string | null;
|
||||
hoverImage?: string | null;
|
||||
translations?: { langCode: string; label: string }[]
|
||||
}[]
|
||||
};
|
||||
let menu = await prisma.menu.findUnique({ where: { name } });
|
||||
|
||||
const updateData: any = {};
|
||||
if (payload !== undefined) updateData.payload = payload;
|
||||
if (isActive !== undefined) updateData.isActive = isActive;
|
||||
if (rootImage !== undefined) updateData.rootImage = rootImage;
|
||||
|
||||
if (!menu) {
|
||||
menu = await prisma.menu.create({ data: { name, ...updateData } });
|
||||
@@ -90,7 +114,13 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
if (item.id && existingItemIds.includes(item.id)) {
|
||||
await tx.menuItem.update({
|
||||
where: { id: item.id },
|
||||
data: { label: item.label, url: item.url, order: item.order }
|
||||
data: {
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
order: item.order,
|
||||
columnImage: item.columnImage,
|
||||
hoverImage: item.hoverImage
|
||||
}
|
||||
});
|
||||
if (item.translations?.length) {
|
||||
for (const t of item.translations) {
|
||||
@@ -108,6 +138,8 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
order: item.order,
|
||||
columnImage: item.columnImage,
|
||||
hoverImage: item.hoverImage,
|
||||
translations: item.translations?.length ? {
|
||||
create: item.translations.map(t => ({
|
||||
langCode: t.langCode,
|
||||
@@ -133,11 +165,14 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
body: t.Object({
|
||||
payload: t.Optional(t.Any()),
|
||||
isActive: t.Optional(t.Boolean()),
|
||||
rootImage: t.Optional(t.Nullable(t.String())),
|
||||
items: t.Array(t.Object({
|
||||
id: t.Optional(t.Number()),
|
||||
label: t.String(),
|
||||
url: t.String(),
|
||||
order: t.Number(),
|
||||
columnImage: t.Optional(t.Nullable(t.String())),
|
||||
hoverImage: t.Optional(t.Nullable(t.String())),
|
||||
translations: t.Optional(t.Array(t.Object({
|
||||
langCode: t.String(),
|
||||
label: t.String()
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { createHmac } from "crypto";
|
||||
|
||||
function base64url(buf: Buffer): string {
|
||||
return buf.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function signJWT(payload: object, secret: string): string {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const headerStr = base64url(Buffer.from(JSON.stringify(header)));
|
||||
const payloadStr = base64url(Buffer.from(JSON.stringify(payload)));
|
||||
const signature = createHmac("sha256", secret)
|
||||
.update(`${headerStr}.${payloadStr}`)
|
||||
.digest();
|
||||
return `${headerStr}.${payloadStr}.${base64url(signature)}`;
|
||||
}
|
||||
|
||||
const API_BASE = "http://localhost:3051/api";
|
||||
|
||||
async function run() {
|
||||
console.log("=== STEP 1: Login via Auth Route ===");
|
||||
const loginRes = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: "admin", password: "homaroma123" })
|
||||
});
|
||||
|
||||
const loginData = await loginRes.json() as any;
|
||||
console.log("Login status:", loginRes.status);
|
||||
console.log("Login response:", loginData);
|
||||
|
||||
if (!loginData.success) {
|
||||
console.error("Login failed!");
|
||||
return;
|
||||
}
|
||||
|
||||
const loginToken = loginData.token;
|
||||
|
||||
console.log("\n=== STEP 2: Test POST /menus with login token ===");
|
||||
const testMenuPayload = {
|
||||
name: "challenger-test-menu",
|
||||
rootImage: "https://example.com/root.jpg",
|
||||
items: [
|
||||
{
|
||||
label: "Item 1",
|
||||
url: "/item1",
|
||||
order: 1,
|
||||
columnImage: "https://example.com/column1.jpg",
|
||||
hoverImage: "https://example.com/hover1.jpg"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const postRes = await fetch(`${API_BASE}/menus`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${loginToken}`
|
||||
},
|
||||
body: JSON.stringify(testMenuPayload)
|
||||
});
|
||||
|
||||
console.log("POST /menus with login token status:", postRes.status);
|
||||
const postData = await postRes.json();
|
||||
console.log("POST /menus response:", postData);
|
||||
|
||||
// If unauthorized, let's sign tokens with different secret fallbacks to see which works!
|
||||
let workingToken = loginToken;
|
||||
if (postRes.status === 401) {
|
||||
console.log("\n⚠️ Detected JWT Secret Mismatch! Trying manually signed tokens...");
|
||||
const payload = {
|
||||
id: 1,
|
||||
username: "admin",
|
||||
role: "ADMIN"
|
||||
};
|
||||
|
||||
const secrets = [
|
||||
"homaroma_secret_2026_scent",
|
||||
"homaroma_secret_2026_brand",
|
||||
"fallback-secret"
|
||||
];
|
||||
|
||||
for (const secret of secrets) {
|
||||
const token = signJWT(payload, secret);
|
||||
const testRes = await fetch(`${API_BASE}/menus`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(testMenuPayload)
|
||||
});
|
||||
console.log(`Testing secret '${secret}': status = ${testRes.status}`);
|
||||
if (testRes.status !== 401) {
|
||||
console.log(`✅ Success with secret '${secret}'!`);
|
||||
workingToken = token;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== STEP 3: Perform correct Menu creation & retrieval ===");
|
||||
// Ensure the menu doesn't exist already by trying to query / delete it first
|
||||
// Let's create it clean
|
||||
const menuName = "challenger-test-" + Date.now();
|
||||
const menuPayload = {
|
||||
name: menuName,
|
||||
rootImage: "https://example.com/challenger-root.png",
|
||||
items: [
|
||||
{
|
||||
label: "Challenger Link",
|
||||
url: "/challenger-link",
|
||||
order: 2,
|
||||
columnImage: "https://example.com/challenger-column.png",
|
||||
hoverImage: "https://example.com/challenger-hover.png"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const createRes = await fetch(`${API_BASE}/menus`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${workingToken}`
|
||||
},
|
||||
body: JSON.stringify(menuPayload)
|
||||
});
|
||||
|
||||
console.log("Creation status:", createRes.status);
|
||||
const createdMenu = await createRes.json() as any;
|
||||
console.log("Created menu:", JSON.stringify(createdMenu, null, 2));
|
||||
|
||||
// Verify DB retrieval via GET /api/menus/:name
|
||||
const getRes = await fetch(`${API_BASE}/menus/${menuName}`);
|
||||
console.log("GET /menus/:name status:", getRes.status);
|
||||
const retrievedMenu = await getRes.json() as any;
|
||||
console.log("Retrieved menu:", JSON.stringify(retrievedMenu, null, 2));
|
||||
|
||||
// Verify that rootImage, columnImage, and hoverImage are correctly stored and retrieved
|
||||
const rootImageOk = retrievedMenu.rootImage === menuPayload.rootImage;
|
||||
const item = retrievedMenu.items?.[0];
|
||||
const columnImageOk = item?.columnImage === menuPayload.items[0].columnImage;
|
||||
const hoverImageOk = item?.hoverImage === menuPayload.items[0].hoverImage;
|
||||
|
||||
console.log(`\nVerification Results:`);
|
||||
console.log(`- rootImage correct: ${rootImageOk} (Expected: ${menuPayload.rootImage}, Got: ${retrievedMenu.rootImage})`);
|
||||
console.log(`- columnImage correct: ${columnImageOk} (Expected: ${menuPayload.items[0].columnImage}, Got: ${item?.columnImage})`);
|
||||
console.log(`- hoverImage correct: ${hoverImageOk} (Expected: ${menuPayload.items[0].hoverImage}, Got: ${item?.hoverImage})`);
|
||||
|
||||
console.log("\n=== STEP 4: Perform PUT update menu ===");
|
||||
const updatePayload = {
|
||||
rootImage: "https://example.com/challenger-root-updated.png",
|
||||
items: [
|
||||
{
|
||||
id: item?.id,
|
||||
label: "Challenger Link Updated",
|
||||
url: "/challenger-link-updated",
|
||||
order: 5,
|
||||
columnImage: "https://example.com/challenger-column-updated.png",
|
||||
hoverImage: "https://example.com/challenger-hover-updated.png"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const updateRes = await fetch(`${API_BASE}/menus/${menuName}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${workingToken}`
|
||||
},
|
||||
body: JSON.stringify(updatePayload)
|
||||
});
|
||||
|
||||
console.log("PUT status:", updateRes.status);
|
||||
const updatedMenu = await updateRes.json() as any;
|
||||
console.log("Updated menu retrieved:", JSON.stringify(updatedMenu, null, 2));
|
||||
|
||||
console.log("\n=== STEP 5: Verify Validation Errors for Incorrect Types ===");
|
||||
const invalidPayload = {
|
||||
name: "challenger-invalid-menu",
|
||||
rootImage: 12345, // Should fail: expected string
|
||||
items: [
|
||||
{
|
||||
label: "Invalid Link",
|
||||
url: "/invalid",
|
||||
order: "not-a-number", // Should fail: expected number
|
||||
columnImage: true // Should fail: expected string
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const invalidRes = await fetch(`${API_BASE}/menus`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${workingToken}`
|
||||
},
|
||||
body: JSON.stringify(invalidPayload)
|
||||
});
|
||||
|
||||
console.log("POST invalid payload status:", invalidRes.status);
|
||||
const invalidData = await invalidRes.json();
|
||||
console.log("POST invalid payload response (expected validation error):", JSON.stringify(invalidData, null, 2));
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
@@ -43,6 +43,7 @@ export const MENU_TOOL_DEFINITIONS = [
|
||||
description: "Optional menu payload for tree-based menus such as main"
|
||||
},
|
||||
isActive: { type: "boolean", description: "Optional active state for the menu" },
|
||||
rootImage: { type: "string", description: "Optional root image for the menu" },
|
||||
items: {
|
||||
type: "array",
|
||||
description: "Array of menu items",
|
||||
@@ -53,6 +54,8 @@ export const MENU_TOOL_DEFINITIONS = [
|
||||
label: { type: "string", description: "Label of the menu item" },
|
||||
url: { type: "string", description: "URL of the menu item" },
|
||||
order: { type: "number", description: "Order sequence" },
|
||||
columnImage: { type: "string", description: "Optional column image for the menu item" },
|
||||
hoverImage: { type: "string", description: "Optional hover image for the menu item" },
|
||||
translations: {
|
||||
type: "array",
|
||||
description: "Optional array of translations",
|
||||
@@ -230,6 +233,12 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
body.isActive = existingMenu.isActive;
|
||||
}
|
||||
|
||||
if (params.rootImage !== undefined) {
|
||||
body.rootImage = params.rootImage;
|
||||
} else if (existingMenu?.rootImage !== undefined) {
|
||||
body.rootImage = existingMenu.rootImage;
|
||||
}
|
||||
|
||||
if (params.payload !== undefined && !isPlainObject(params.payload) && !Array.isArray(params.payload)) {
|
||||
throw new Error("Invalid menu payload: payload must be an object or array");
|
||||
}
|
||||
@@ -249,7 +258,8 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
name: params.name,
|
||||
itemCount: items.length,
|
||||
hasPayload: !!body.payload,
|
||||
isActive: body.isActive
|
||||
isActive: body.isActive,
|
||||
rootImage: body.rootImage || null
|
||||
},
|
||||
warnings: itemCheck.warnings
|
||||
},
|
||||
@@ -273,7 +283,8 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a
|
||||
name: res.data.name,
|
||||
isActive: res.data.isActive,
|
||||
itemCount: res.data.items?.length || 0,
|
||||
hasPayload: !!res.data.payload
|
||||
hasPayload: !!res.data.payload,
|
||||
rootImage: res.data.rootImage || null
|
||||
},
|
||||
null,
|
||||
2
|
||||
|
||||
@@ -182,6 +182,14 @@ function validateMenuItems(items: any) {
|
||||
issues.push(`items[${index}].order must be a number when provided`);
|
||||
}
|
||||
|
||||
if (item.columnImage !== undefined && item.columnImage !== null && typeof item.columnImage !== "string") {
|
||||
issues.push(`items[${index}].columnImage must be a string or null when provided`);
|
||||
}
|
||||
|
||||
if (item.hoverImage !== undefined && item.hoverImage !== null && typeof item.hoverImage !== "string") {
|
||||
issues.push(`items[${index}].hoverImage must be a string or null when provided`);
|
||||
}
|
||||
|
||||
if (item.translations !== undefined) {
|
||||
if (!Array.isArray(item.translations)) {
|
||||
issues.push(`items[${index}].translations must be an array when provided`);
|
||||
|
||||
@@ -13,21 +13,41 @@ const getLocalizedText = (textObj: Record<string, string> | string | undefined,
|
||||
return textObj[lang] || textObj['en'] || Object.values(textObj)[0] || '';
|
||||
};
|
||||
|
||||
// Image block shared between Root and Hover areas
|
||||
function MenuImage({ src, alt, placeholder }: { src?: string | null; alt: string; placeholder?: string }) {
|
||||
if (!src) {
|
||||
if (!placeholder) return null;
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-pop-black/30 text-xs text-center px-3 leading-relaxed">{placeholder}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <img src={src} alt={alt} className="w-full h-full object-cover" loading="lazy" />;
|
||||
}
|
||||
|
||||
export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language ? i18n.language.substring(0, 2) : 'en';
|
||||
|
||||
// Open: cancel any pending close, then open this menu
|
||||
// Open: cancel any pending close, reset hover image when switching root menu
|
||||
const handleOpen = (id: string) => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
if (openId !== id) {
|
||||
setHoveredLinkImage(null);
|
||||
}
|
||||
setOpenId(id);
|
||||
};
|
||||
|
||||
// Close: wait 150ms — if mouse enters the dropdown in time, the close is cancelled
|
||||
const handleClose = () => {
|
||||
closeTimer.current = setTimeout(() => setOpenId(null), 150);
|
||||
closeTimer.current = setTimeout(() => {
|
||||
setOpenId(null);
|
||||
setHoveredLinkImage(null);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -37,6 +57,11 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
const isOpen = openId === uniqueId;
|
||||
const hasColumns = item.columns && item.columns.length > 0;
|
||||
|
||||
// Determine if any column has an icon OR any link has an image
|
||||
const hasAnyColumnIcon = item.columns?.some(col => !!col.iconUrl);
|
||||
const hasAnyLinkImage = item.columns?.some(col => col.links.some(l => !!l.imageUrl));
|
||||
const showRightPanel = item.rootImageUrl || hasAnyLinkImage;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uniqueId}
|
||||
@@ -66,9 +91,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Dropdown panel — fixed, full-width.
|
||||
It handles its own onMouseEnter/Leave so the delay timer is
|
||||
cancelled the moment the mouse enters the panel. */}
|
||||
{/* Dropdown panel — fixed, full-width */}
|
||||
{hasColumns && (
|
||||
<div
|
||||
className={`fixed left-0 right-0 z-50 transition-opacity duration-200 ${
|
||||
@@ -79,26 +102,65 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
||||
onMouseLeave={handleClose}
|
||||
>
|
||||
<div className="w-full bg-pop-bg border-b border-pop-black/10 shadow-sm cursor-default">
|
||||
<div className="flex justify-center gap-16 py-10 max-w-7xl mx-auto px-6">
|
||||
{item.columns!.map((col, idx) => (
|
||||
<div key={idx} className="flex flex-col min-w-[140px] text-left">
|
||||
<h4 className="text-[10px] uppercase tracking-widest text-pop-black/70 font-bold mb-4">
|
||||
{getLocalizedText(col.title, lang)}
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{col.links.map((link, lIdx) => (
|
||||
<li key={lIdx}>
|
||||
<Link
|
||||
to={link.url || '#'}
|
||||
className="text-sm font-normal normal-case tracking-normal text-pop-black/80 hover:text-pop-black hover:opacity-80 block transition-colors"
|
||||
>
|
||||
{getLocalizedText(link.label, lang)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-start gap-10 py-10 max-w-7xl mx-auto px-6">
|
||||
|
||||
{/* LEFT: Root menu image (1:1) — hidden on smaller screens */}
|
||||
{item.rootImageUrl && (
|
||||
<div className="w-56 h-56 shrink-0 rounded-xl overflow-hidden border border-pop-black/8 hidden lg:block">
|
||||
<MenuImage src={item.rootImageUrl} alt={getLocalizedText(item.label, lang)} />
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* CENTER: Columns */}
|
||||
<div className="flex gap-12 flex-1">
|
||||
{item.columns!.map((col, idx) => (
|
||||
<div key={idx} className="flex gap-4 min-w-[160px] text-left items-start">
|
||||
|
||||
{/* Column icon (1:1) — shown only if this column has an iconUrl */}
|
||||
{(hasAnyColumnIcon) && (
|
||||
<div className="w-14 h-14 shrink-0 rounded-lg overflow-hidden bg-pop-black/5 border border-pop-black/8 mt-0.5">
|
||||
<MenuImage
|
||||
src={col.iconUrl}
|
||||
alt={getLocalizedText(col.title, lang)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and links */}
|
||||
<div className="flex flex-col">
|
||||
<h4 className="text-[10px] uppercase tracking-widest text-pop-black/60 font-bold mb-3">
|
||||
{getLocalizedText(col.title, lang)}
|
||||
</h4>
|
||||
<ul className="space-y-2.5">
|
||||
{col.links.map((link, lIdx) => (
|
||||
<li key={lIdx}>
|
||||
<Link
|
||||
to={link.url || '#'}
|
||||
className="text-sm font-normal normal-case tracking-normal text-pop-black/75 hover:text-pop-black block transition-colors duration-150"
|
||||
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
|
||||
onMouseLeave={() => setHoveredLinkImage(null)}
|
||||
>
|
||||
{getLocalizedText(link.label, lang)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Hover preview image (1:1) — shown only if any link has an imageUrl */}
|
||||
{showRightPanel && (
|
||||
<div className="w-56 h-56 shrink-0 rounded-xl overflow-hidden border border-pop-black/8 bg-pop-black/5 hidden lg:flex items-center justify-center">
|
||||
<MenuImage
|
||||
src={hoveredLinkImage ?? item.rootImageUrl}
|
||||
alt="Preview"
|
||||
placeholder="Hover a link to preview"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,55 +1,179 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowRight, Home, Search, ShoppingBag, AlertCircle } from 'lucide-react'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, type Category } from '../types'
|
||||
|
||||
const copy = {
|
||||
th: {
|
||||
eyebrow: 'ไม่พบหน้านี้',
|
||||
title: 'เส้นทางนี้ยังไม่มีกลิ่นที่ตามหา',
|
||||
description: 'ลิงก์อาจถูกย้าย ลบ หรือยังไม่ได้เผยแพร่ ลองกลับไปเลือกสินค้าหรือค้นหากลิ่นที่เหมาะกับพื้นที่ของคุณ',
|
||||
home: 'กลับหน้าแรก',
|
||||
shop: 'เลือกสินค้า',
|
||||
search: 'ค้นหาสินค้า',
|
||||
guide: 'คู่มือเลือกกลิ่น',
|
||||
contact: 'ติดต่อเรา',
|
||||
hint: '404',
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'Page not found',
|
||||
title: 'This scent trail has faded',
|
||||
description: 'The link may have moved, expired, or is not published yet. Head back to the collection or search for the right aroma.',
|
||||
home: 'Back home',
|
||||
shop: 'Shop products',
|
||||
search: 'Search products',
|
||||
guide: 'Scent Guide',
|
||||
contact: 'Contact',
|
||||
hint: '404',
|
||||
},
|
||||
ja: {
|
||||
eyebrow: 'ページが見つかりません',
|
||||
title: 'お探しの香りのページは見つかりません',
|
||||
description: 'リンクが移動、削除、または未公開の可能性があります。コレクションに戻るか商品を検索してください。',
|
||||
home: 'ホームへ戻る',
|
||||
shop: '商品を見る',
|
||||
search: '商品を検索',
|
||||
guide: '香りガイド',
|
||||
contact: 'お問い合わせ',
|
||||
hint: '404',
|
||||
},
|
||||
zh: {
|
||||
eyebrow: '页面未找到',
|
||||
title: '这条香气路径暂时不存在',
|
||||
description: '链接可能已移动、删除或尚未发布。你可以返回首页、浏览商品或搜索合适的香氛。',
|
||||
home: '返回首页',
|
||||
shop: '浏览商品',
|
||||
search: '搜索商品',
|
||||
guide: '香氛指南',
|
||||
contact: '联系我们',
|
||||
hint: '404',
|
||||
},
|
||||
vi: {
|
||||
eyebrow: 'Không tìm thấy trang',
|
||||
title: 'Đường dẫn hương thơm này không còn tồn tại',
|
||||
description: 'Liên kết có thể đã được di chuyển, xóa hoặc chưa xuất bản. Hãy quay lại bộ sưu tập hoặc tìm sản phẩm phù hợp.',
|
||||
home: 'Về trang chủ',
|
||||
shop: 'Xem sản phẩm',
|
||||
search: 'Tìm sản phẩm',
|
||||
guide: 'Hướng dẫn mùi hương',
|
||||
contact: 'Liên hệ',
|
||||
hint: '404',
|
||||
},
|
||||
ko: {
|
||||
eyebrow: '페이지를 찾을 수 없습니다',
|
||||
title: '찾으시는 향의 경로가 사라졌습니다',
|
||||
description: '링크가 이동되었거나 삭제되었거나 아직 공개되지 않았을 수 있습니다. 컬렉션으로 돌아가거나 상품을 검색해 보세요.',
|
||||
home: '홈으로',
|
||||
shop: '상품 보기',
|
||||
search: '상품 검색',
|
||||
guide: '향 가이드',
|
||||
contact: '문의하기',
|
||||
hint: '404',
|
||||
},
|
||||
} as const
|
||||
|
||||
type CopyLang = keyof typeof copy
|
||||
|
||||
function getCopy(lang: string) {
|
||||
return copy[(lang in copy ? lang : 'th') as CopyLang]
|
||||
}
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const { i18n } = useTranslation()
|
||||
const text = getCopy(i18n.language)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
|
||||
.then(r => r.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
}, [i18n.language])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-6 bg-spa-light">
|
||||
{/* Background glow */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-spa-forest/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-spa-stone/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
<div className="min-h-screen bg-pop-bg flex flex-col">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<div className="relative z-10 text-center space-y-8 max-w-lg">
|
||||
{/* 404 Large */}
|
||||
<div className="relative">
|
||||
<div className="text-[10rem] font-bold leading-none text-transparent bg-clip-text bg-gradient-to-b from-white/20 to-white/5 select-none">
|
||||
404
|
||||
<main className="flex-1">
|
||||
<section className="px-6 py-16 text-pop-black md:py-24">
|
||||
<div className="mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<div>
|
||||
<div className="mb-8 inline-flex items-center gap-3 rounded-full border border-pop-black/10 bg-white/50 px-4 py-2 shadow-sm">
|
||||
<AlertCircle className="h-5 w-5 text-pop-pink" aria-hidden="true" />
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-pop-pink">{text.eyebrow}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-8xl font-black leading-none text-pop-pink/20 md:text-9xl">{text.hint}</p>
|
||||
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-pop-black md:text-6xl">
|
||||
{text.title}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-pop-black/60 md:text-lg">
|
||||
{text.description}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
to="/"
|
||||
className="ui-button-primary gap-2"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
{text.home}
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="ui-button-secondary gap-2 border border-pop-black/10 shadow-sm"
|
||||
>
|
||||
<ShoppingBag className="h-4 w-4" />
|
||||
{text.shop}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="brutal-box bg-white/80 p-6 backdrop-blur md:p-8">
|
||||
<div className="rounded-[1.5rem] bg-pop-bg p-6 shadow-inner border border-pop-black/5">
|
||||
<div className="flex items-center gap-3 border-b border-pop-black/10 pb-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-pop-pink shadow-sm border border-pop-black/5">
|
||||
<Search className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-pop-black/60">{text.hint}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-pop-black">{text.search}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-3">
|
||||
<Link
|
||||
to="/search"
|
||||
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink shadow-sm border border-pop-black/5"
|
||||
>
|
||||
<span>{text.search}</span>
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/scent-guide"
|
||||
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink shadow-sm border border-pop-black/5"
|
||||
>
|
||||
<span>{text.guide}</span>
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/contact"
|
||||
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink shadow-sm border border-pop-black/5"
|
||||
>
|
||||
<span>{text.contact}</span>
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-5xl animate-float">🌿</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold">Page Not Found</h1>
|
||||
<p className="text-spa-stone leading-relaxed">
|
||||
The page you're looking for seems to have drifted away like morning mist. Let's guide you back to serenity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="px-8 py-3.5 bg-spa-light text-spa-forest font-bold rounded-lg tracking-widest uppercase hover:bg-spa-forest text-spa-light hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 text-sm"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="px-8 py-3.5 bg-spa-light shadow-sm border border-spa-stone/10 text-spa-light font-bold rounded-lg tracking-widest uppercase hover:bg-spa-light transition-all duration-300 border border-spa-stone/10 text-sm"
|
||||
>
|
||||
Browse Products
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-spa-stone/10">
|
||||
<div className="flex justify-center gap-6 text-xs text-spa-stone">
|
||||
<Link to="/scent-guide" className="hover:text-spa-light transition-colors">Scent Guide</Link>
|
||||
<Link to="/faq" className="hover:text-spa-light transition-colors">FAQ</Link>
|
||||
<Link to="/contact" className="hover:text-spa-light transition-colors">Contact</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,11 +5,13 @@ export interface MenuLink {
|
||||
label: Record<string, string>;
|
||||
url?: string;
|
||||
links?: MenuLink[];
|
||||
imageUrl?: string | null; // รูปที่แสดงเมื่อ Hover ลิงก์
|
||||
}
|
||||
|
||||
export interface MenuColumn {
|
||||
title: Record<string, string>;
|
||||
links: MenuLink[];
|
||||
iconUrl?: string | null; // รูปประจำคอลัมน์ (1:1)
|
||||
}
|
||||
|
||||
export interface MegaMenuPayload {
|
||||
@@ -18,6 +20,7 @@ export interface MegaMenuPayload {
|
||||
label: Record<string, string>;
|
||||
url?: string;
|
||||
columns?: MenuColumn[];
|
||||
rootImageUrl?: string | null; // รูปหลักฝั่งซ้ายสุด (1:1)
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import Link from '../LocalizedLink'
|
||||
import Image from 'next/image'
|
||||
import { useState, useRef } from 'react'
|
||||
import type { MegaMenuPayload } from './menuTypes'
|
||||
import { getLocalizedMenuText } from './menuTypes'
|
||||
@@ -11,17 +12,56 @@ interface DesktopMegaMenuProps {
|
||||
currentPath: string
|
||||
}
|
||||
|
||||
// Render a 1:1 image using next/image for optimization (website uses CDN images)
|
||||
function MenuImage({
|
||||
src,
|
||||
alt,
|
||||
placeholder,
|
||||
className = '',
|
||||
}: {
|
||||
src?: string | null
|
||||
alt: string
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}) {
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
if (!src) {
|
||||
if (!placeholder) return null
|
||||
return (
|
||||
<span className="text-text-main/30 text-xs text-center px-3 leading-relaxed">{placeholder}</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
sizes="(max-width: 1024px) 0px, 224px"
|
||||
className={`object-cover transition-opacity duration-300 ${loaded ? 'opacity-100' : 'opacity-0'} ${className}`}
|
||||
onLoad={() => setLoaded(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuProps) {
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
const [hoveredLinkImage, setHoveredLinkImage] = useState<string | null>(null)
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleOpen = (id: string) => {
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current)
|
||||
if (openId !== id) setHoveredLinkImage(null)
|
||||
setOpenId(id)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
closeTimer.current = setTimeout(() => setOpenId(null), 150)
|
||||
closeTimer.current = setTimeout(() => {
|
||||
setOpenId(null)
|
||||
setHoveredLinkImage(null)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -32,6 +72,10 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
const hasColumns = !!item.columns?.length
|
||||
const isActive = item.url ? currentPath === item.url : false
|
||||
|
||||
const hasAnyColumnIcon = item.columns?.some(col => !!col.iconUrl)
|
||||
const hasAnyLinkImage = item.columns?.some(col => col.links.some(l => !!l.imageUrl))
|
||||
const showRightPanel = item.rootImageUrl || hasAnyLinkImage
|
||||
|
||||
return (
|
||||
<div
|
||||
key={uniqueId}
|
||||
@@ -70,26 +114,68 @@ export function DesktopMegaMenu({ items, lang, currentPath }: DesktopMegaMenuPro
|
||||
onMouseLeave={handleClose}
|
||||
>
|
||||
<div className="w-full cursor-default border-b border-black/5 bg-surface-card/95 shadow-card backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-7xl justify-center gap-16 px-6 py-10">
|
||||
{item.columns!.map((column, columnIndex) => (
|
||||
<div key={columnIndex} className="flex min-w-[140px] flex-col text-left">
|
||||
<h4 className="mb-4 text-[10px] font-bold uppercase tracking-widest text-text-main/50">
|
||||
{getLocalizedMenuText(column.title, lang)}
|
||||
</h4>
|
||||
<ul className="space-y-3">
|
||||
{column.links.map((link, linkIndex) => (
|
||||
<li key={linkIndex}>
|
||||
<Link
|
||||
href={link.url || '#'}
|
||||
className="block text-sm font-normal normal-case tracking-normal text-text-main/65 transition-colors hover:text-brand-primary"
|
||||
>
|
||||
{getLocalizedMenuText(link.label, lang)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mx-auto flex max-w-7xl items-start gap-10 px-6 py-10">
|
||||
|
||||
{/* LEFT: Root menu image (1:1) */}
|
||||
{item.rootImageUrl && (
|
||||
<div className="relative hidden h-56 w-56 shrink-0 overflow-hidden rounded-xl border border-black/8 lg:block">
|
||||
<MenuImage
|
||||
src={item.rootImageUrl}
|
||||
alt={getLocalizedMenuText(item.label, lang)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* CENTER: Columns */}
|
||||
<div className="flex flex-1 gap-12">
|
||||
{item.columns!.map((column, columnIndex) => (
|
||||
<div key={columnIndex} className="flex min-w-[160px] gap-4 text-left items-start">
|
||||
|
||||
{/* Column icon (1:1) */}
|
||||
{hasAnyColumnIcon && (
|
||||
<div className="relative h-14 w-14 shrink-0 mt-0.5 overflow-hidden rounded-lg border border-black/8 bg-surface-muted">
|
||||
<MenuImage
|
||||
src={column.iconUrl}
|
||||
alt={getLocalizedMenuText(column.title, lang)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and links */}
|
||||
<div className="flex flex-col">
|
||||
<h4 className="mb-3 text-[10px] font-bold uppercase tracking-widest text-text-main/50">
|
||||
{getLocalizedMenuText(column.title, lang)}
|
||||
</h4>
|
||||
<ul className="space-y-2.5">
|
||||
{column.links.map((link, linkIndex) => (
|
||||
<li key={linkIndex}>
|
||||
<Link
|
||||
href={link.url || '#'}
|
||||
className="block text-sm font-normal normal-case tracking-normal text-text-main/65 transition-colors duration-150 hover:text-brand-primary"
|
||||
onMouseEnter={() => setHoveredLinkImage(link.imageUrl || null)}
|
||||
onMouseLeave={() => setHoveredLinkImage(null)}
|
||||
>
|
||||
{getLocalizedMenuText(link.label, lang)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Link hover preview / Root fallback (1:1) */}
|
||||
{showRightPanel && (
|
||||
<div className="relative hidden h-56 w-56 shrink-0 overflow-hidden rounded-xl border border-black/8 bg-surface-muted lg:flex items-center justify-center">
|
||||
<MenuImage
|
||||
src={hoveredLinkImage ?? item.rootImageUrl}
|
||||
alt="Preview"
|
||||
placeholder="Hover a link to preview"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,11 +2,13 @@ export interface MenuLink {
|
||||
label: Record<string, string> | string
|
||||
url?: string
|
||||
links?: MenuLink[]
|
||||
imageUrl?: string | null // รูปที่แสดงเมื่อ Hover ลิงก์
|
||||
}
|
||||
|
||||
export interface MenuColumn {
|
||||
title: Record<string, string> | string
|
||||
links: MenuLink[]
|
||||
iconUrl?: string | null // รูปประจำคอลัมน์ (1:1)
|
||||
}
|
||||
|
||||
export interface MegaMenuPayload {
|
||||
@@ -15,6 +17,7 @@ export interface MegaMenuPayload {
|
||||
label: Record<string, string> | string
|
||||
url?: string
|
||||
columns?: MenuColumn[]
|
||||
rootImageUrl?: string | null // รูปหลักฝั่งซ้ายสุด (1:1)
|
||||
}
|
||||
|
||||
export function getLocalizedMenuText(text: Record<string, string> | string | undefined, lang: string) {
|
||||
|
||||
Reference in new issue
Block a user