This commit is contained in:
Your Name committed 2026-06-13 22:02:04 -04:00
1 parent 20f43bd58d
commit 1ac727f4e5
10 files changed
+306 -122

No files matched your search

+30
View File
@@ -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" };
}
});
+2
View File
@@ -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 }[] };
+50 -35
View File
@@ -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({
+2
View File
@@ -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;
+44
View File
@@ -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,
}
}
+140
View File
@@ -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}`);
}
+1 -1
View File
@@ -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>
+20 -72
View File
@@ -9,40 +9,8 @@ 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`)
@@ -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>
{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>
)}
<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>
<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>
+2 -14
View File
@@ -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