From 0dcb0f63a000f15e2055673971f895482bfbc95b Mon Sep 17 00:00:00 2001 From: chattie Date: Mon, 24 Aug 2026 15:40:01 +0200 Subject: [PATCH] feat(POS-FIX-6): completed feature --- backlog/features.json | 2 +- project/apps/admin/next.config.ts | 2 +- project/apps/admin/package.json | 4 +- .../admin/src/app/(dashboard)/orders/page.tsx | 6 +- .../admin/src/components/ServerLogViewer.tsx | 53 +++ project/apps/pos/.env.example | 8 + project/apps/pos/next.config.ts | 2 +- project/apps/pos/package.json | 4 +- project/apps/pos/public/images/logo-main.png | Bin 0 -> 4119 bytes project/apps/pos/src/app/(terminal)/page.tsx | 357 +++++++++++++++--- .../apps/pos/src/app/api/[...path]/route.ts | 13 +- .../apps/pos/src/components/ReceiptModal.tsx | 338 +++++++++-------- project/apps/pos/src/lib/api-client.ts | 9 +- project/frontend/next.config.ts | 2 +- .../060_inventory_allow_cancel_operation.js | 22 ++ project/scripts/monolith.sh | 86 ++++- project/src/app/build-app.ts | 5 + project/src/modules/pos/api/pos.routes.ts | 6 +- project/storefront/next.config.ts | 1 + 19 files changed, 687 insertions(+), 233 deletions(-) create mode 100644 project/apps/pos/.env.example create mode 100644 project/apps/pos/public/images/logo-main.png create mode 100644 project/migrations/060_inventory_allow_cancel_operation.js diff --git a/backlog/features.json b/backlog/features.json index 965d7fd..f410f13 100644 --- a/backlog/features.json +++ b/backlog/features.json @@ -7554,7 +7554,7 @@ "qa": true, "close": true }, - "completed_at": "2026-08-23T07:02:05Z" + "completed_at": "2026-08-24T13:40:01Z" }, { "id": "F-201", diff --git a/project/apps/admin/next.config.ts b/project/apps/admin/next.config.ts index 134d343..bb432c5 100644 --- a/project/apps/admin/next.config.ts +++ b/project/apps/admin/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - allowedDevOrigins: ['192.168.18.93', 'localhost'], + allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'admin-mv.rikrdo.com'], // Keep Turbopack rooted at this app. The repository also contains the // legacy project/frontend/package-lock.json; without an explicit root, // Next.js 16 may infer the wrong workspace during production builds. diff --git a/project/apps/admin/package.json b/project/apps/admin/package.json index ef2c57f..8562ac6 100644 --- a/project/apps/admin/package.json +++ b/project/apps/admin/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --port 3004", + "dev": "next dev --port 3001", "build": "next build", - "start": "next start --port 3004", + "start": "next start --port 3001", "lint": "eslint .", "typecheck": "tsc --noEmit" }, diff --git a/project/apps/admin/src/app/(dashboard)/orders/page.tsx b/project/apps/admin/src/app/(dashboard)/orders/page.tsx index 2a0d1af..17ac67f 100644 --- a/project/apps/admin/src/app/(dashboard)/orders/page.tsx +++ b/project/apps/admin/src/app/(dashboard)/orders/page.tsx @@ -153,7 +153,7 @@ export default function OrdersPage() { - {['ID', 'Fecha', 'Total', 'Estado'].map((h) => ( + {['ID', 'Fecha', 'Cliente', 'Total', 'Estado'].map((h) => ( diff --git a/project/apps/admin/src/components/ServerLogViewer.tsx b/project/apps/admin/src/components/ServerLogViewer.tsx index c304c59..1b246c8 100644 --- a/project/apps/admin/src/components/ServerLogViewer.tsx +++ b/project/apps/admin/src/components/ServerLogViewer.tsx @@ -7,6 +7,14 @@ interface LogEntry { levelName: string; msg: string; err?: Record; + method?: string; + url?: string; + statusCode?: number; + durationMs?: number; + ip?: string; + remoteIp?: string; + userAgent?: string; + referer?: string | null; raw: string; } @@ -39,6 +47,13 @@ function parseLine(line: string): LogEntry { levelName: LEVEL_NAMES[level] ?? 'INFO', msg: obj.msg ?? '', err: obj.err, + method: obj.method, + url: obj.url, + statusCode: obj.statusCode, + durationMs: obj.durationMs, + ip: obj.ip ?? obj.remoteIp, + userAgent: obj.userAgent, + referer: obj.referer, raw: line, }; } catch { @@ -206,6 +221,44 @@ export function ServerLogViewer({ backendUrl = 'http://192.168.18.93:3000' }: Se {/* Message */}
+ +

{o.email || o.customerName || 'โ€”'}

+ {o.email &&

{o.email}

} +

{formatPrice(o.totalCents)}

+ {/* Extra fields for HTTP request logs */} + {entry.method && entry.url && ( + + = 500 ? 'text-red-400' : + entry.statusCode && entry.statusCode >= 400 ? 'text-amber-400' : + entry.statusCode && entry.statusCode >= 300 ? 'text-blue-400' : + 'text-green-400' + }`}>{entry.method} + {entry.url} + {entry.statusCode && ( + = 500 ? 'text-red-400' : + entry.statusCode >= 400 ? 'text-amber-400' : + entry.statusCode >= 300 ? 'text-blue-400' : + 'text-green-400' + }`}>{entry.statusCode} + )} + {entry.durationMs !== undefined && ( + {entry.durationMs}ms + )} + ยท + {entry.ip && ( + ๐ŸŒ {entry.ip} + )} + {entry.referer && ( + โ† {entry.referer.replace(/^https?:\/\//, '').slice(0, 60)} + )} + {entry.userAgent && ( + + {entry.userAgent.includes('Postman') ? '๐Ÿ“ฎ Postman' : + entry.userAgent.includes('curl') ? '๐Ÿ“ก curl' : + entry.userAgent.includes('node') ? 'โš™๏ธ Node' : + '๐Ÿ–ฅ๏ธ Browser'} + + )} + + )} {entry.msg} {entry.err && ( diff --git a/project/apps/pos/.env.example b/project/apps/pos/.env.example new file mode 100644 index 0000000..d2a5e83 --- /dev/null +++ b/project/apps/pos/.env.example @@ -0,0 +1,8 @@ +# URL del backend para el proxy del POS. +# En producciรณn (detrรกs de Traefik): no es necesario, usa el mismo origen. +# En desarrollo local o por IP LAN: REQUIRED โ€” el proxy no sabe dรณnde estรก el backend. +# Copiar a .env.local y ajustar segรบn tu entorno: +# Dev local: POS_BACKEND_URL=http://localhost:3000 +# Dev por IP: POS_BACKEND_URL=http://192.168.18.93:3000 +# Producciรณn: dejar vacรญo (Traefik sirve todo en el mismo dominio) +# POS_BACKEND_URL=http://localhost:3000 diff --git a/project/apps/pos/next.config.ts b/project/apps/pos/next.config.ts index 1c6bf50..c4d8103 100644 --- a/project/apps/pos/next.config.ts +++ b/project/apps/pos/next.config.ts @@ -1,7 +1,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - allowedDevOrigins: ['192.168.18.93', 'localhost'], + allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'tpv-mv.rikrdo.com'], turbopack: { root: __dirname, }, diff --git a/project/apps/pos/package.json b/project/apps/pos/package.json index 30be720..2b06c5a 100644 --- a/project/apps/pos/package.json +++ b/project/apps/pos/package.json @@ -3,9 +3,9 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --port 3006", + "dev": "next dev --port 3002", "build": "next build", - "start": "next start --port 3006" + "start": "next start --port 3002" }, "dependencies": { "next": "^16.3.1", diff --git a/project/apps/pos/public/images/logo-main.png b/project/apps/pos/public/images/logo-main.png new file mode 100644 index 0000000000000000000000000000000000000000..ab3283d41dbce6a2949fd881b197ed4075ce3eb2 GIT binary patch literal 4119 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 V#0>7jJfQ#p002ovPDHLkV1nLguHXOw literal 0 HcmV?d00001 diff --git a/project/apps/pos/src/app/(terminal)/page.tsx b/project/apps/pos/src/app/(terminal)/page.tsx index 4d387a8..9d32691 100644 --- a/project/apps/pos/src/app/(terminal)/page.tsx +++ b/project/apps/pos/src/app/(terminal)/page.tsx @@ -111,7 +111,10 @@ export default function RegisterPage() { const [processing, setProcessing] = useState(false); const [error, setError] = useState(''); const [pendingSales, setPendingSales] = useState([]); + const [todaySales, setTodaySales] = useState([]); const [loadingPending, setLoadingPending] = useState(false); + const [salesTab, setSalesTab] = useState<'pending' | 'day'>('day'); + const [printingSaleId, setPrintingSaleId] = useState(null); const [restPaymentFor, setRestPaymentFor] = useState(null); const [processingRest, setProcessingRest] = useState(false); const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>( @@ -203,13 +206,38 @@ export default function RegisterPage() { } }, [config?.terminal?.id, config?.session?.status]); + // Load today's completed sales for the sidebar + const loadTodaySales = useCallback(async () => { + if (!config?.terminal) { + setTodaySales([]); + return; + } + try { + const data = await posApi.listSales<{ items: PosPendingSale[] }>({ + terminalId: config.terminal.id, + }); + // Filter to today's date (completed or any state) + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayItems = (data.items ?? []).filter((sale) => { + const saleDate = new Date(sale.createdAt); + saleDate.setHours(0, 0, 0, 0); + return saleDate.getTime() === today.getTime(); + }); + setTodaySales(todayItems); + } catch { + setTodaySales([]); + } + }, [config?.terminal?.id]); + // POS-FIX-9: removed 10s polling โ€” only manual refresh via button // POS-FIX-6: re-run when config loads (terminalId changes from undefined to real id) useEffect(() => { if (!config?.terminal?.id || config.session?.status !== 'OPEN') return; void loadPendingSales(); - }, [config?.terminal?.id, config?.session?.status, loadPendingSales]); + void loadTodaySales(); + }, [config?.terminal?.id, config?.session?.status, loadPendingSales, loadTodaySales]); useEffect(() => { if (!config?.session || config.session.status !== 'OPEN') return; @@ -580,6 +608,26 @@ export default function RegisterPage() { } }; + // POS-FIX-12: reprint ticket โ€” load receipt modal for printing + const reprintSale = async (sale: PosPendingSale) => { + setPrintingSaleId(sale.id); + setError(''); + try { + const result = await posApi.getReceipt<{ receipt: PosReceipt }>(sale.id); + // Show the receipt modal for printing + setReceipt(result.receipt); + // Also trigger print immediately after modal renders + setTimeout(() => { + const printBtn = document.querySelector('button[class*="bg-\[\#2D6A4F\]"]') as HTMLButtonElement | null; + if (printBtn) printBtn.click(); + }, 100); + } catch (err) { + setError(err instanceof Error ? err.message : 'No se pudo reimprimir el ticket'); + } finally { + setPrintingSaleId(null); + } + }; + // POS-FIX-8: recover a parked sale into the current cart const recoverSale = async (sale: PosPendingSale) => { // POS-FIX-9: if cart has items, show merge dialog instead of auto-merge @@ -855,71 +903,264 @@ export default function RegisterPage() { return (
{/* POS-FIX-4: product added toast */} diff --git a/project/apps/pos/src/app/api/[...path]/route.ts b/project/apps/pos/src/app/api/[...path]/route.ts index fd78a69..0380862 100644 --- a/project/apps/pos/src/app/api/[...path]/route.ts +++ b/project/apps/pos/src/app/api/[...path]/route.ts @@ -1,11 +1,16 @@ import { NextRequest, NextResponse } from 'next/server'; -// Use same host as browser to preserve cookies. In production, use the actual backend URL. +// Resuelve la URL del backend: +// 1. POS_BACKEND_URL: explรญcito (dev por IP LAN, prod con traefik en otro host) +// 2. Mismo origen que el navegador: funciona en dev local y prod detrรกs de traefik +// (traefik sirve frontend+backend en el mismo dominio, routando /api/* al backend) function getBackendUrl(request: NextRequest): string { - if (process.env.POS_BACKEND_URL) { - return `${process.env.POS_BACKEND_URL}/${apiPath(request)}${request.nextUrl.search}`; + const backendUrl = process.env.POS_BACKEND_URL; + if (backendUrl) { + const base = backendUrl.replace(/\/$/, ''); + return `${base}/${apiPath(request)}${request.nextUrl.search}`; } - // Dev: use same host/port as browser to keep cookies working + // Fallback: mismo origen que el navegador (dev local y prod traefik) const protocol = request.headers.get('x-forwarded-proto') ?? request.nextUrl.protocol; const host = request.headers.get('x-forwarded-host') ?? request.nextUrl.host; return `${protocol}//${host}/${apiPath(request)}${request.nextUrl.search}`; diff --git a/project/apps/pos/src/components/ReceiptModal.tsx b/project/apps/pos/src/components/ReceiptModal.tsx index 493ed2d..a96ade4 100644 --- a/project/apps/pos/src/components/ReceiptModal.tsx +++ b/project/apps/pos/src/components/ReceiptModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { posApi } from '@/lib/api-client'; import { formatPrice } from '@/lib/money'; import type { PosReceipt } from '@/types/checkout'; @@ -10,6 +10,7 @@ interface ReceiptModalProps { initialEmail?: string; onDelivered: () => void; onReturn?: (orderId: string, receipt: PosReceipt) => void; + showActions?: boolean; // false when used just for printing } export default function ReceiptModal({ @@ -17,13 +18,23 @@ export default function ReceiptModal({ initialEmail = '', onDelivered, onReturn, + showActions = true, }: ReceiptModalProps) { + const articleRef = useRef(null); const [email, setEmail] = useState(initialEmail); const [sending, setSending] = useState(false); const [error, setError] = useState(''); const printReceipt = () => { + // Hide everything except the ticket content before printing + document.querySelectorAll('.no-print').forEach((el) => { + (el as HTMLElement).style.visibility = 'hidden'; + }); window.print(); + // Restore after printing + document.querySelectorAll('.no-print').forEach((el) => { + (el as HTMLElement).style.visibility = ''; + }); onDelivered(); }; @@ -49,161 +60,194 @@ export default function ReceiptModal({ aria-labelledby="receipt-title" >
-
-
- {receipt.header &&

{receipt.header}

} -

- {receipt.company.name} -

- {receipt.company.address &&

{receipt.company.address}

} - {receipt.company.taxId &&

NIF/CIF: {receipt.company.taxId}

} - {(receipt.company.phone || receipt.company.email) && ( -

{[receipt.company.phone, receipt.company.email].filter(Boolean).join(' ยท ')}

- )} -
- -
-

- Ticket: {receipt.receiptNumber} -

-

- Fecha: {new Date(receipt.issuedAt).toLocaleString('es-ES')} -

-

- Terminal: {receipt.terminal.name} -

-

- Cajero: {receipt.cashier} -

-
- - - - - - - - - - - {receipt.items.map((item, index) => ( - - - - - - ))} - -
ArtรญculoCant.Subtotal
- {item.name} - - {formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' ยท libre' : ''} - - {item.discountCents > 0 && ( - - Dto. โˆ’{formatPrice(item.discountCents)} - - )} - {item.quantity}{formatPrice(item.totalCents)}
- -
-

- Subtotal - {formatPrice(receipt.subtotalCents)} -

- {receipt.discountCents > 0 && ( -

- Descuentos - โˆ’{formatPrice(receipt.discountCents)} -

- )} -

- IVA - {formatPrice(receipt.taxCents)} -

-

- Total - {formatPrice(receipt.totalCents)} -

-
- -
-

Formas de pago

- {receipt.payments.map((payment, index) => ( -
- {payment.methodLabel} - {formatPrice(payment.amountCents)} - {payment.tenderedCents !== null && ( - - Entregado {formatPrice(payment.tenderedCents)} - - )} - {payment.changeCents > 0 && ( - Cambio {formatPrice(payment.changeCents)} - )} + {/* Ticket content - only this gets printed */} +
+
+
+ {/* Logo */} +
+ Logo
- ))} - {receipt.changeCents > 0 && ( -

- Total a devolver - {formatPrice(receipt.changeCents)} + {receipt.header &&

{receipt.header}

} +

+ {receipt.company.name} +

+ {receipt.company.address &&

{receipt.company.address}

} + {receipt.company.taxId &&

NIF/CIF: {receipt.company.taxId}

} + {(receipt.company.phone || receipt.company.email) && ( +

{[receipt.company.phone, receipt.company.email].filter(Boolean).join(' ยท ')}

+ )} +
+ +
+

+ Ticket: {receipt.receiptNumber}

- )} -
+

+ Fecha: {new Date(receipt.issuedAt).toLocaleString('es-ES')} +

+

+ Terminal: {receipt.terminal.name} +

+

+ Cajero: {receipt.cashier} +

+
-
-

{receipt.returnPolicy}

- {receipt.footer &&

{receipt.footer}

} -
- + + + + + + + + + + {receipt.items.map((item, index) => ( + + + + + + ))} + +
ArtรญculoCant.Subtotal
+ {item.name} + + {formatPrice(item.unitPriceCents)}/ud{item.freeItem ? ' ยท libre' : ''} + + {item.discountCents > 0 && ( + + Dto. โˆ’{formatPrice(item.discountCents)} + + )} + {item.quantity}{formatPrice(item.totalCents)}
-
-

- Imprime o envรญa el ticket para preparar la siguiente venta. -

- - {onReturn && !receipt.isReturn && ( +
+

+ Subtotal + {formatPrice(receipt.subtotalCents)} +

+ {receipt.discountCents > 0 && ( +

+ Descuentos + โˆ’{formatPrice(receipt.discountCents)} +

+ )} +

+ IVA + {formatPrice(receipt.taxCents)} +

+

+ Total + {formatPrice(receipt.totalCents)} +

+
+ +
+

Formas de pago

+ {receipt.payments.map((payment, index) => ( +
+ {payment.methodLabel} + {formatPrice(payment.amountCents)} + {payment.tenderedCents !== null && ( + + Entregado {formatPrice(payment.tenderedCents)} + + )} + {payment.changeCents > 0 && ( + Cambio {formatPrice(payment.changeCents)} + )} +
+ ))} + {receipt.changeCents > 0 && ( +

+ Total a devolver + {formatPrice(receipt.changeCents)} +

+ )} +
+ +
+

{receipt.returnPolicy}

+ {receipt.footer &&

{receipt.footer}

} +
+ +
+ + {/* Actions - hidden when printing */} + {showActions && ( +
+

+ Imprime o envรญa el ticket para preparar la siguiente venta. +

- )} -
- - setEmail(event.target.value)} - placeholder="cliente@email.es" - className="min-h-14 min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]" - /> - -
- {error && ( -

- {error} -

- )} -
+ {onReturn && !receipt.isReturn && ( + + )} +
+ + setEmail(event.target.value)} + placeholder="cliente@email.es" + className="no-print min-h-14 min-w-0 flex-1 rounded-xl border-2 border-gray-200 px-4 outline-none focus:border-[#2D6A4F]" + /> + +
+ {error && ( +

+ {error} +

+ )} +
+ )} + + {/* Print-only styles */} + ); } diff --git a/project/apps/pos/src/lib/api-client.ts b/project/apps/pos/src/lib/api-client.ts index 112b2f9..d810102 100644 --- a/project/apps/pos/src/lib/api-client.ts +++ b/project/apps/pos/src/lib/api-client.ts @@ -3,11 +3,12 @@ const API = '/api'; async function apiFetch(path: string, init?: RequestInit): Promise { + const hasBody = init?.body !== undefined && init.body !== null && init.body !== ''; const res = await fetch(`${API}${path}`, { ...init, credentials: 'include', headers: { - 'Content-Type': 'application/json', + ...(hasBody ? { 'Content-Type': 'application/json' } : {}), ...(init?.headers ?? {}), }, }); @@ -102,6 +103,12 @@ export const posApi = { method: 'POST', body: JSON.stringify({ email }), }), + /** Print receipt for any sale (pending, completed, refunded). */ + printReceipt: (orderId: string) => + apiFetch<{ url: string }>(`/pos/sales/${encodeURIComponent(orderId)}/print`), + /** Get full receipt for a sale (used for returns). */ + getReceipt: (orderId: string) => + apiFetch(`/pos/sales/${encodeURIComponent(orderId)}/receipt`), }; export const authApi = { diff --git a/project/frontend/next.config.ts b/project/frontend/next.config.ts index 6d47eae..9fea957 100644 --- a/project/frontend/next.config.ts +++ b/project/frontend/next.config.ts @@ -5,7 +5,7 @@ import type { NextConfig } from 'next'; const frontendRoot = dirname(fileURLToPath(import.meta.url)); const nextConfig: NextConfig = { - allowedDevOrigins: ['192.168.18.93'], + allowedDevOrigins: ['192.168.18.93', 'localhost', '127.0.0.1', 'shop-mv.rikrdo.com'], turbopack: { root: frontendRoot, }, diff --git a/project/migrations/060_inventory_allow_cancel_operation.js b/project/migrations/060_inventory_allow_cancel_operation.js new file mode 100644 index 0000000..6bdbce3 --- /dev/null +++ b/project/migrations/060_inventory_allow_cancel_operation.js @@ -0,0 +1,22 @@ +'use strict'; + +exports.shorthands = undefined; + +exports.up = (pgm) => { + // Allow 'cancel' operation in inventory_movements for POS-FIX-8 (delete parked sale restores stock) + pgm.sql(` + ALTER TABLE inventory_movements + DROP CONSTRAINT IF EXISTS inventory_movements_operation_check, + ADD CONSTRAINT inventory_movements_operation_check + CHECK (operation = ANY (ARRAY['reserve'::text, 'release'::text, 'confirm'::text, 'set_available'::text, 'return'::text, 'cancel'::text])) + `); +}; + +exports.down = (pgm) => { + pgm.sql(` + ALTER TABLE inventory_movements + DROP CONSTRAINT IF EXISTS inventory_movements_operation_check, + ADD CONSTRAINT inventory_movements_operation_check + CHECK (operation = ANY (ARRAY['reserve'::text, 'release'::text, 'confirm'::text, 'set_available'::text, 'return'::text)) + `); +}; diff --git a/project/scripts/monolith.sh b/project/scripts/monolith.sh index 190684b..fe521eb 100755 --- a/project/scripts/monolith.sh +++ b/project/scripts/monolith.sh @@ -28,14 +28,15 @@ Commands: start Install/build when needed, migrate, and start every service restart Stop managed processes, then start every service status Show PID, process state, HTTP status, and URLs + check Verify all services are responding (exit 0 if all OK) stop Gracefully stop every managed HTTP process logs Follow all service logs (Ctrl-C exits without stopping services) urls Print localhost and LAN URLs watch Auto-respawn any dead service every WATCH_INTERVAL seconds Environment overrides: - LAN_IP, BACKEND_PORT, FRONTEND_PORT, ADMIN_PORT, STOREFRONT_PORT - MDV_RUNTIME_DIR, START_TIMEOUT + LAN_IP, BACKEND_PORT, ADMIN_PORT, TPV_PORT, FRONTEND_PORT, STOREFRONT_PORT + MDV_RUNTIME_DIR, START_TIMEOUT, WATCH_INTERVAL EOF } @@ -44,7 +45,7 @@ if [[ "$MODE" != "dev" && "$MODE" != "prod" ]]; then exit 2 fi case "$ACTION" in - start|restart|status|stop|logs|urls|watch) ;; + start|restart|status|check|stop|logs|urls|watch) ;; *) usage >&2; exit 2 ;; esac @@ -96,6 +97,17 @@ service_url() { printf 'http://%s:%s%s\n' "$host" "$(service_port "$service")" "$(service_path "$service")" } +service_domain() { + # Returns the subdomain for Traefik/production access + case "$1" in + backend) echo "api-mv.rikrdo.com" ;; + admin) echo "admin-mv.rikrdo.com" ;; + tpv) echo "tpv-mv.rikrdo.com" ;; + frontend) echo "shop-mv.rikrdo.com" ;; + storefront) echo "seo-mv.rikrdo.com" ;; + esac +} + pid_file() { printf '%s/%s.pid\n' "$RUNTIME_DIR" "$1"; } log_file() { printf '%s/%s.log\n' "$RUNTIME_DIR" "$1"; } @@ -387,20 +399,71 @@ watch_loop() { done } +check_services() { + # Verify all services are responding with HTTP 2xx or 3xx + # Exit 0 if all OK, exit 1 if any service is down + local service url code failed=0 + echo "Checking all services..." + for service in "${SERVICES[@]}"; do + url="$(service_url "$service")" + code="$(curl --max-time 5 -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)" + if [[ "$code" =~ ^[23] ]]; then + echo "[OK] $service ($code) โ€” $url" + else + echo "[FAIL] $service (HTTP $code) โ€” $url" + failed=1 + fi + done + echo + if [[ $failed -eq 0 ]]; then + echo "All services are up!" + return 0 + else + echo "Some services are down." + return 1 + fi +} + print_urls() { + # Detect if Traefik/production domains are reachable + local api_domain="$(service_domain backend)" + local admin_domain="$(service_domain admin)" + local tpv_domain="$(service_domain tpv)" + local shop_domain="$(service_domain frontend)" + local seo_domain="$(service_domain storefront)" + cat <