diff --git a/project/apps/admin/src/app/(dashboard)/categories/page.tsx b/project/apps/admin/src/app/(dashboard)/categories/page.tsx
index 9a2d8e4..cbf442d 100644
--- a/project/apps/admin/src/app/(dashboard)/categories/page.tsx
+++ b/project/apps/admin/src/app/(dashboard)/categories/page.tsx
@@ -91,11 +91,41 @@ const EMPTY_FORM: FormState = {
};
const EMOJI_OPTIONS = [
- 'π₯¬', 'π₯', 'π', 'π₯', 'π', 'π§', 'π₯', 'π―', 'πΎ', 'π₯',
- 'π΅', 'β', 'π«', 'π§΄', 'π§Ό', 'πΏ', 'π±', 'πΈ', 'π', 'π',
- 'π§Ί', 'β»οΈ', 'πΊ', 'πΆ', 'π₯£', 'π§', 'π', 'π', 'π', 'π',
+ // Frutas y verduras
+ 'π₯¬', 'π₯¦', 'π₯', 'π', 'πΆοΈ', 'π₯', 'π₯', 'π«', 'π§
', 'π§',
+ 'π
', 'π₯', 'π₯', 'π', 'π₯', 'π«', 'π₯', 'π₯', 'π½', 'π ',
+ // Frutas
+ 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'π', 'π«',
+ 'π', 'π', 'π₯', 'π', 'π₯₯', 'π₯', 'π«', 'π
', 'π₯', 'π',
+ // LΓ‘cteos y huevos
+ 'π₯', 'π§', 'πΆ', 'π₯', 'π§', 'π§', 'π₯', 'π³', 'π§', 'π§',
+ // Aceites y condimentos
+ 'π«', 'π«', 'π§', 'πΆοΈ', 'π§', 'π―', 'π«', 'π§', 'π₯«', 'π«',
+ // Infusiones y bebidas
+ 'π΅', 'β', 'π«', 'πΊ', 'π·', 'π₯', 'πΎ', 'π§', 'π₯€', 'π§',
+ // Especias y semillas
+ 'πΏ', 'π±', 'πΎ', 'π₯', 'π°', 'π«', 'π«', 'π«', 'π', 'π¦΄',
+ // EcolΓ³gico y naturaleza
+ 'πΈ', 'π»', 'πΊ', 'πΉ', 'πͺ»', 'πΎ', 'π΅', 'π²', 'πͺ¨', 'π',
+ // Animales y eco
+ 'π', 'π', 'π¦', 'π', 'π', 'π¦', 'π', 'π¦', 'π―', 'π',
+ // Diversos alimentaciΓ³n
+ 'π«', 'π¬', 'π', 'π¦', 'π§', 'π©', 'πͺ', 'π', 'π°', 'π₯§',
+ 'π§', 'π§', 'π₯', 'π₯©', 'π', 'π―', 'π₯', 'π₯', 'π', 'π',
+ 'π', 'π₯ͺ', 'π₯', 'π«', 'π₯―', 'π§', 'π₯', 'π₯', 'π³', 'π§',
];
+// CategorΓas populares predefinidas
+const CATEGORY_EMOJI_SUGGESTIONS = [
+ 'π₯¬ Hortalizas', 'π Frutas', 'π§ LΓ‘cteos', 'π― Miel y Derivatives',
+ 'πΏ Hierbas aromΓ‘ticas', 'π« Aceites', 'β CafΓ© e infusiones',
+ 'π PanaderΓa', 'π₯ Frutos secos', 'π« Chocolate',
+ 'π§΄ CosmΓ©tica natural', 'π± Semillas y germinados', 'π Setas',
+ 'π₯¦ Colesterol y salud', 'π§Ί Compra a granel', 'β»οΈ Cero residuos',
+];
+
+const EMOJI_CODE_HINT = 'U+1F34E (π) β busca en emojipedia.org';
+
const CARD_PRESETS: Array<{ bg: string; text: string; label: string }> = [
{ bg: '#F1F8E9', text: '#2D6A4F', label: 'Verde' },
{ bg: '#FFF8E1', text: '#B07D2B', label: 'Miel' },
@@ -376,7 +406,7 @@ export default function CategoriesPage() {
className="text-xs text-gray-400 hover:text-red-500">Quitar
)}
-
+
{EMOJI_OPTIONS.map((emoji) => (
))}
-
Emoji identificativo que aparecerΓ‘ en las tarjetas de categorΓa.
+
+ setForm((f) => ({ ...f, emoji: e.target.value.slice(0, 8) }))}
+ placeholder="π o U+1F600"
+ maxLength={8}
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-xl text-sm text-2xl text-center"
+ title={EMOJI_CODE_HINT}
+ />
+
+ Escribe emoji o U+XXXX
+
+
+
Emoji identificativo que aparecerΓ‘ en las tarjetas de categorΓa. Escribe o pega cualquier emoji.
diff --git a/project/apps/admin/src/app/(dashboard)/pos/page.tsx b/project/apps/admin/src/app/(dashboard)/pos/page.tsx
index 3a024da..01e0391 100644
--- a/project/apps/admin/src/app/(dashboard)/pos/page.tsx
+++ b/project/apps/admin/src/app/(dashboard)/pos/page.tsx
@@ -93,7 +93,7 @@ export default function PosAdminPage() {
const [touchEnabled, setTouchEnabled] = useState(true);
const [discountsEnabled, setDiscountsEnabled] = useState(true);
const [quickSlots, setQuickSlots] = useState
>(
- Array.from({ length: 8 }, () => null),
+ Array.from({ length: 10 }, () => null),
);
const [savingTouch, setSavingTouch] = useState(false);
const [touchMessage, setTouchMessage] = useState('');
@@ -197,7 +197,7 @@ export default function PosAdminPage() {
setTouchEnabled(terminal.settings?.touchNavigationEnabled !== false);
setDiscountsEnabled(terminal.settings?.lineDiscountsEnabled !== false);
const configured = terminal.settings?.quickProductVariantIds ?? [];
- setQuickSlots(Array.from({ length: 8 }, (_, slot) => configured[slot] ?? null));
+ setQuickSlots(Array.from({ length: 10 }, (_, slot) => configured[slot] ?? null));
setTouchMessage('');
setSelfpayMode(terminal.settings?.selfpayMode ?? false);
setClosePinRequired(terminal.settings?.closeSessionRequiresPin ?? false);
diff --git a/project/apps/admin/src/components/BellNotifications.tsx b/project/apps/admin/src/components/BellNotifications.tsx
new file mode 100644
index 0000000..240cd54
--- /dev/null
+++ b/project/apps/admin/src/components/BellNotifications.tsx
@@ -0,0 +1,99 @@
+'use client';
+import { useState, useEffect, useRef } from 'react';
+
+interface Notification {
+ id: string;
+ type: 'pending_orders' | 'stale_shipped' | 'awaiting_payment';
+ count: number;
+ message: string;
+ href: string;
+}
+
+const ICONS: Record = {
+ pending_orders: 'π¦',
+ stale_shipped: 'π',
+ awaiting_payment: 'π³',
+};
+
+export function BellNotifications() {
+ const [notifications, setNotifications] = useState([]);
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ const load = async () => {
+ try {
+ const res = await fetch('/api/backoffice/notifications', { credentials: 'include' });
+ if (!res.ok) return;
+ const data = await res.json() as { items?: Notification[] };
+ setNotifications(data.items ?? []);
+ } catch { /* silent */ }
+ };
+
+ useEffect(() => { void load(); void setInterval(load, 60_000); }, []);
+
+ useEffect(() => {
+ const handler = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener('mousedown', handler);
+ return () => document.removeEventListener('mousedown', handler);
+ }, []);
+
+ const total = notifications.reduce((s, n) => s + n.count, 0);
+
+ return (
+
+
+
+ {open && (
+
+
+
Notificaciones
+
+
+
+ {total > 0 && (
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/project/apps/admin/src/components/OrderNotifications.tsx b/project/apps/admin/src/components/OrderNotifications.tsx
index c052455..20511cd 100644
--- a/project/apps/admin/src/components/OrderNotifications.tsx
+++ b/project/apps/admin/src/components/OrderNotifications.tsx
@@ -16,101 +16,185 @@ function money(cents: number): string {
return new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(cents / 100);
}
+type AwaitingPaymentItem = { id: string; totalCents: number; customerEmail: string | null; createdAt: string };
+
export function OrderNotifications() {
const [open, setOpen] = useState(false);
- const [items, setItems] = useState([]);
- const [total, setTotal] = useState(0);
+ const [staleItems, setStaleItems] = useState([]);
+ const [staleTotal, setStaleTotal] = useState(0);
+ const [awaitingItems, setAwaitingItems] = useState([]);
+ const [awaitingTotal, setAwaitingTotal] = useState(0);
+ const [tab, setTab] = useState<'pending' | 'awaiting'>('pending');
const [loading, setLoading] = useState(true);
- const [error, setError] = useState('');
const rootRef = useRef(null);
const load = useCallback(async () => {
- setError('');
try {
- const data = await ordersApi.staleNotifications();
- setItems(data.items);
- setTotal(data.total);
- } catch (err) {
- setError(err instanceof Error ? err.message : 'No se pudieron cargar las notificaciones');
- } finally {
- setLoading(false);
- }
+ const [staleData, awaitingData] = await Promise.all([
+ ordersApi.staleNotifications(24, 20),
+ ordersApi.awaitingPaymentNotifications(),
+ ]);
+ setStaleItems(staleData.items);
+ setStaleTotal(staleData.total);
+ setAwaitingItems(awaitingData.items);
+ setAwaitingTotal(awaitingData.total);
+ } catch { /* silent */ }
+ finally { setLoading(false); }
}, []);
- useEffect(() => {
- void load();
- const timer = window.setInterval(() => void load(), POLL_INTERVAL_MS);
- return () => window.clearInterval(timer);
- }, [load]);
+ useEffect(() => { void load(); void setInterval(load, POLL_INTERVAL_MS); }, [load]);
useEffect(() => {
if (!open) return;
void load();
- const close = (event: MouseEvent) => {
- if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
- };
- const key = (event: KeyboardEvent) => {
- if (event.key === 'Escape') setOpen(false);
- };
+ const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };
+ const key = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', close);
document.addEventListener('keydown', key);
- return () => {
- document.removeEventListener('mousedown', close);
- document.removeEventListener('keydown', key);
- };
+ return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', key); };
}, [open, load]);
+ const grandTotal = staleTotal + awaitingTotal;
+ const pendingItems = staleItems.filter(i => i.state === 'PENDING');
+ const shippedItems = staleItems.filter(i => i.state === 'SHIPPED');
+
+ const TabPending = () => (
+
+ {loading ? (
+
Cargandoβ¦
+ ) : pendingItems.length === 0 && shippedItems.length === 0 ? (
+
No hay pedidos pendientes o atrasados.
+ ) : (
+ <>
+ {pendingItems.length > 0 && (
+
+
+ π¦ {pendingItems.length} pendientes de procesar
+
+ {pendingItems.map((item) => (
+
setOpen(false)}
+ className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
+
+
+
Pedido #{item.id.slice(0, 8)}
+
{item.customerEmail || 'Sin email'}
+
+
Pendiente
+
+
+ {ageLabel(item.ageHours)}
+ {money(item.totalCents)}
+
+
+ ))}
+
+ )}
+ {shippedItems.length > 0 && (
+
+
+ π {shippedItems.length} en trΓ‘nsito sin actualizar
+
+ {shippedItems.map((item) => (
+
setOpen(false)}
+ className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
+
+
+
Pedido #{item.id.slice(0, 8)}
+
{item.customerEmail || 'Sin email'}
+
+
Enviado
+
+
+ {ageLabel(item.ageHours)}
+ {money(item.totalCents)}
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ );
+
+ const TabAwaiting = () => (
+
+ {loading ? (
+
Cargandoβ¦
+ ) : awaitingItems.length === 0 ? (
+
No hay pagos pendientes.
+ ) : (
+ <>
+
+ π³ {awaitingItems.length} esperando confirmaciΓ³n de pago
+
+ {awaitingItems.map((item) => (
+
setOpen(false)}
+ className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
+
+
+
Pedido #{item.id.slice(0, 8)}
+
{item.customerEmail || 'Sin email'}
+
+
Esperando pago
+
+
{money(item.totalCents)}
+
+ ))}
+ >
+ )}
+
+ );
+
return (
{open && (
-
+
-
-
Pedidos para revisar
-
Pendientes o enviados sin cambios en 24 h
-
+
Notificaciones
-
- {loading &&
Cargandoβ¦
}
- {!loading && error &&
{error}
}
- {!loading && !error && items.length === 0 &&
No hay pedidos atrasados.
}
- {!loading && !error && items.map((item) => (
-
setOpen(false)} className="block border-b border-gray-100 px-4 py-3 last:border-0 hover:bg-gray-50">
-
-
-
Pedido #{item.id.slice(0, 8)}
-
{item.customerEmail || 'Cliente sin email'}
-
-
- {item.state === 'PENDING' ? 'Pendiente' : 'Enviado'}
-
-
-
- {ageLabel(item.ageHours)}
- {money(item.totalCents)}
-
-
- ))}
+ {/* Tabs */}
+
+
+
- {total > items.length &&
setOpen(false)} className="block border-t border-gray-100 px-4 py-3 text-center text-sm font-medium text-[#2D6A4F] hover:bg-gray-50">Ver los {total} pedidos}
+
+ {tab === 'pending' ? : }
+
+ {grandTotal > 0 && (
+
setOpen(false)}
+ className="block border-t border-gray-100 px-4 py-3 text-center text-sm font-medium text-[#2D6A4F] hover:bg-gray-50">
+ Ver todos los pedidos β
+
+ )}
)}
diff --git a/project/apps/admin/src/lib/api-client.ts b/project/apps/admin/src/lib/api-client.ts
index 9c1f8b3..f3b1e68 100644
--- a/project/apps/admin/src/lib/api-client.ts
+++ b/project/apps/admin/src/lib/api-client.ts
@@ -142,6 +142,10 @@ export const ordersApi = {
api.get<{ total: number; thresholdHours: number; items: StaleOrderNotification[] }>(
`/api/orders/notifications/stale?hours=${hours}&limit=${limit}`,
),
+ awaitingPaymentNotifications: () =>
+ api.get<{ total: number; items: Array<{ id: string; totalCents: number; customerEmail: string | null; createdAt: string }> }>(
+ '/api/orders/notifications/awaiting-payment',
+ ),
};
// ββ Customers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
diff --git a/project/frontend/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/frontend/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg
new file mode 100644
index 0000000..c3e982a
Binary files /dev/null and b/project/frontend/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ
diff --git a/project/frontend/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/frontend/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg
new file mode 100644
index 0000000..6a6122c
Binary files /dev/null and b/project/frontend/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ
diff --git a/project/frontend/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/frontend/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg
new file mode 100644
index 0000000..6b57d90
Binary files /dev/null and b/project/frontend/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ
diff --git a/project/frontend/src/app/api/catalog-proxy/[...path]/route.ts b/project/frontend/src/app/api/catalog-proxy/[...path]/route.ts
new file mode 100644
index 0000000..725897a
--- /dev/null
+++ b/project/frontend/src/app/api/catalog-proxy/[...path]/route.ts
@@ -0,0 +1,39 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
+
+const HANDLERS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const;
+
+export async function GET(req: NextRequest) { return handle(req); }
+export async function POST(req: NextRequest) { return handle(req); }
+export async function PUT(req: NextRequest) { return handle(req); }
+export async function PATCH(req: NextRequest) { return handle(req); }
+export async function DELETE(req: NextRequest) { return handle(req); }
+
+async function handle(req: NextRequest): Promise {
+ const path = req.nextUrl.pathname.replace('/api/catalog-proxy/', '');
+ const url = `${API}/${path}${req.nextUrl.search}`;
+ const headers: Record = { accept: 'application/json' };
+ const cookie = req.headers.get('cookie');
+ if (cookie) headers.cookie = cookie;
+
+ const init: RequestInit = { headers };
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
+ init.method = req.method;
+ init.body = await req.text();
+ const ct = req.headers.get('content-type');
+ if (ct) headers['content-type'] = ct;
+ }
+
+ try {
+ const res = await fetch(url, init);
+ const data = await res.json().catch(() => ({}));
+ const setCookie = res.headers.get('set-cookie');
+ if (setCookie) {
+ return NextResponse.json(data, { status: res.status, headers: { 'set-cookie': setCookie } });
+ }
+ return NextResponse.json(data, { status: res.status });
+ } catch (err) {
+ return NextResponse.json({ error: { code: 'PROXY_ERROR', message: String(err) } }, { status: 502 });
+ }
+}
diff --git a/project/frontend/src/lib/api.ts b/project/frontend/src/lib/api.ts
index b8374b3..97667ff 100644
--- a/project/frontend/src/lib/api.ts
+++ b/project/frontend/src/lib/api.ts
@@ -8,15 +8,14 @@ interface CmsPage {
status: string;
}
-// Use env var if set, otherwise proxy through /api/catalog
-// In SSR: NEXT_PUBLIC_API_URL is http://127.0.0.1:3000 (backend direct)
-// In prod (relative mode): empty β /api/catalog (same-origin proxy)
+// Browser: use same-origin proxy route to avoid network/IP issues
+// SSR: use backend URL directly (same machine)
const BASE = (() => {
if (typeof window !== 'undefined') {
- // Browser: use same-origin relative URL (works from any network)
- return '';
+ // Browser: proxy through Next.js to avoid external IP issues
+ return '/api/catalog-proxy';
}
- // Server/SSR: use backend URL from env
+ // Server/SSR: use backend URL directly
return process.env.NEXT_PUBLIC_API_URL ?? 'http://127.0.0.1:3000';
})();
diff --git a/project/src/modules/admin/api/notifications.routes.ts b/project/src/modules/admin/api/notifications.routes.ts
new file mode 100644
index 0000000..814e295
--- /dev/null
+++ b/project/src/modules/admin/api/notifications.routes.ts
@@ -0,0 +1,65 @@
+import { FastifyInstance } from 'fastify';
+import { Pool } from 'pg';
+
+export function notificationsRoutes(fastify: FastifyInstance, pool: Pool) {
+
+ // GET /api/admin/notifications
+ // Returns pending counts for bell icon
+ fastify.get('/admin/notifications', async (req) => {
+ const [ordersRows] = await Promise.all([
+ pool.query<{ pending: string; shipped_24h: string; awaiting_payment: string }>(`
+ SELECT
+ COUNT(*) FILTER (WHERE state = 'PENDING') AS pending,
+ COUNT(*) FILTER (
+ WHERE state = 'SHIPPED'
+ AND updated_at < NOW() - INTERVAL '24 hours'
+ ) AS shipped_24h,
+ COUNT(*) FILTER (
+ WHERE payment_status = 'AWAITING'
+ AND state = 'PENDING'
+ ) AS awaiting_payment
+ FROM pos_orders
+ WHERE deleted_at IS NULL
+ AND created_at > NOW() - INTERVAL '30 days'
+ `),
+ ]);
+
+ const row = ordersRows.rows[0];
+ const items = [];
+
+ const pending = parseInt(row?.pending ?? '0', 10);
+ if (pending > 0) {
+ items.push({
+ id: 'pending_orders',
+ type: 'pending_orders',
+ count: pending,
+ message: 'Pedidos pendientes de procesar',
+ href: '/orders?state=PENDING',
+ });
+ }
+
+ const shipped24h = parseInt(row?.shipped_24h ?? '0', 10);
+ if (shipped24h > 0) {
+ items.push({
+ id: 'stale_shipped',
+ type: 'stale_shipped',
+ count: shipped24h,
+ message: 'Enviados sin actualizar en +24h',
+ href: '/orders?state=SHIPPED&stale=true',
+ });
+ }
+
+ const awaiting = parseInt(row?.awaiting_payment ?? '0', 10);
+ if (awaiting > 0) {
+ items.push({
+ id: 'awaiting_payment',
+ type: 'awaiting_payment',
+ count: awaiting,
+ message: 'Esperando confirmaciΓ³n de pago',
+ href: '/orders?payment_status=AWAITING',
+ });
+ }
+
+ return { items };
+ });
+}
diff --git a/project/src/modules/backoffice/api/backoffice.routes.ts b/project/src/modules/backoffice/api/backoffice.routes.ts
index e996ab4..f864191 100644
--- a/project/src/modules/backoffice/api/backoffice.routes.ts
+++ b/project/src/modules/backoffice/api/backoffice.routes.ts
@@ -157,6 +157,29 @@ export async function registerBackofficeRoutes(
throw error;
}
});
+
+ // Bell notifications for admin dashboard
+ app.get('/notifications', async (req) => {
+ const rows = await deps.pool.query<{
+ pending: string; shipped_24h: string; awaiting_payment: string;
+ }>(`
+ SELECT
+ COUNT(*) FILTER (WHERE state = 'PENDING') AS pending,
+ COUNT(*) FILTER (WHERE state = 'SHIPPED' AND updated_at < NOW() - INTERVAL '24 hours') AS shipped_24h,
+ COUNT(*) FILTER (WHERE payment_status = 'AWAITING' AND state = 'PENDING') AS awaiting_payment
+ FROM pos_orders
+ WHERE deleted_at IS NULL AND created_at > NOW() - INTERVAL '30 days'
+ `);
+ const r = rows.rows[0] ?? { pending: '0', shipped_24h: '0', awaiting_payment: '0' };
+ const items = [];
+ const p = parseInt(r.pending, 10);
+ if (p > 0) items.push({ id: 'pending_orders', type: 'pending_orders', count: p, message: 'Pedidos pendientes de procesar', href: '/orders?state=PENDING' });
+ const s = parseInt(r.shipped_24h, 10);
+ if (s > 0) items.push({ id: 'stale_shipped', type: 'stale_shipped', count: s, message: 'Enviados sin actualizar en +24h', href: '/orders?state=SHIPPED&stale=true' });
+ const a = parseInt(r.awaiting_payment, 10);
+ if (a > 0) items.push({ id: 'awaiting_payment', type: 'awaiting_payment', count: a, message: 'Esperando confirmaciΓ³n de pago', href: '/orders?payment_status=AWAITING' });
+ return { items };
+ });
}
function isSecureRequest(request: FastifyRequest): boolean {
diff --git a/project/src/modules/orders/api/orders.routes.ts b/project/src/modules/orders/api/orders.routes.ts
index 0b53fbd..6ab948c 100644
--- a/project/src/modules/orders/api/orders.routes.ts
+++ b/project/src/modules/orders/api/orders.routes.ts
@@ -530,6 +530,38 @@ export async function registerOrdersRoutes(
throw mapOrderError(error);
}
});
+
+ // Awaiting payment orders for admin notifications
+ app.get('/orders/notifications/awaiting-payment', async (request, reply) => {
+ const user = await deps.authenticate(request);
+ requireRole(user, 'admin');
+ const result = await deps.pool.query<{
+ id: string;
+ total_cents: number;
+ customer_email: string | null;
+ created_at: Date;
+ total_count: number;
+ }>(
+ `SELECT o.id, o.total_cents, u.email AS customer_email, o.created_at,
+ COUNT(*) OVER()::int AS total_count
+ FROM orders_orders o
+ LEFT JOIN identity_users u ON u.id = o.user_id
+ WHERE o.payment_status = 'AWAITING'
+ AND o.state = 'PENDING'
+ AND o.deleted_at IS NULL
+ ORDER BY o.created_at ASC
+ LIMIT 20`,
+ );
+ return reply.send({
+ total: result.rows[0]?.total_count ?? 0,
+ items: result.rows.map((row) => ({
+ id: row.id,
+ totalCents: row.total_cents,
+ customerEmail: row.customer_email,
+ createdAt: row.created_at.toISOString(),
+ })),
+ });
+ });
}
function mapOrderError(error: unknown): Error {
diff --git a/project/storefront/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/storefront/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg
new file mode 100644
index 0000000..c3e982a
Binary files /dev/null and b/project/storefront/public/uploads/200/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ
diff --git a/project/storefront/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/storefront/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg
new file mode 100644
index 0000000..6a6122c
Binary files /dev/null and b/project/storefront/public/uploads/40/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ
diff --git a/project/storefront/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg b/project/storefront/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg
new file mode 100644
index 0000000..6b57d90
Binary files /dev/null and b/project/storefront/public/uploads/84c00c52-af00-4b29-aefc-167b1d4eaf2d.jpg differ
diff --git a/work/runtime-status.json b/work/runtime-status.json
index 032f1ee..2cd4983 100644
--- a/work/runtime-status.json
+++ b/work/runtime-status.json
@@ -1,5 +1,5 @@
{
- "feature_id": "ADMIN-CLOSURES",
+ "feature_id": "FRONTEND-FIXES",
"stage": "build",
"agent": "implementer",
"action": "",