diff --git a/NainOrder.Api/wwwroot/assets/css/views.css b/NainOrder.Api/wwwroot/assets/css/views.css
index 2cfad46..9051156 100644
--- a/NainOrder.Api/wwwroot/assets/css/views.css
+++ b/NainOrder.Api/wwwroot/assets/css/views.css
@@ -997,9 +997,259 @@
.drop-recent__item { cursor: pointer; }
+/* --------------------------------------------------------------------------
+ Drop landing: a wordless story, 12 s loop
+ -------------------------------------------------------------------------- */
+.landing {
+ display: grid;
+ justify-items: center;
+ gap: var(--space-5);
+ padding-block: var(--space-4);
+ text-align: center;
+}
+
+.landing__title {
+ font-size: var(--text-2xl);
+ font-weight: 750;
+ letter-spacing: -0.02em;
+ line-height: var(--leading-tight);
+ background: var(--brand-gradient);
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+}
+
+.landing__sub {
+ max-width: 46ch;
+ color: var(--text-secondary);
+ font-size: var(--text-md);
+}
+
+.landing__actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--space-3);
+}
+
+.story {
+ margin: 0;
+ width: min(100%, 760px);
+ padding: var(--space-4);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-subtle);
+ background: var(--surface-1);
+}
+
+.story__svg {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.story__cell {
+ fill: color-mix(in srgb, var(--success) 18%, transparent);
+ stroke: var(--success);
+ stroke-width: 1.5;
+}
+
+.story__cell--sold { animation: story-sold 12s infinite; }
+.story__cell--back { animation: story-back 12s infinite; }
+
+.story__crowd {
+ fill: var(--brand-3);
+ opacity: 0.16;
+}
+
+.story__dot {
+ fill: var(--brand-3);
+ opacity: 0;
+ animation: story-walk 12s var(--ease) infinite;
+ animation-delay: calc(var(--i) * 45ms);
+}
+
+@keyframes story-walk {
+ 0% { transform: translate(0, 0); opacity: 0; }
+ 4% { opacity: 1; }
+ 22% { transform: translate(var(--dx), var(--dy)); opacity: 1; }
+ 27%, 100% { transform: translate(var(--dx), var(--dy)); opacity: 0; }
+}
+
+@keyframes story-sold {
+ 0%, 22% { fill: color-mix(in srgb, var(--success) 18%, transparent); stroke: var(--success); }
+ 28%, 70% { fill: color-mix(in srgb, var(--warning) 38%, transparent); stroke: var(--warning); }
+ 76%, 96% { fill: var(--brand-2); stroke: transparent; }
+ 100% { fill: color-mix(in srgb, var(--success) 18%, transparent); stroke: var(--success); }
+}
+
+@keyframes story-back {
+ 0%, 22% { fill: color-mix(in srgb, var(--success) 18%, transparent); stroke: var(--success); }
+ 28%, 50% { fill: color-mix(in srgb, var(--warning) 38%, transparent); stroke: var(--warning); }
+ 56%, 76% { fill: color-mix(in srgb, var(--success) 18%, transparent); stroke: var(--success); }
+ 82%, 96% { fill: var(--brand-2); stroke: transparent; }
+ 100% { fill: color-mix(in srgb, var(--success) 18%, transparent); stroke: var(--success); }
+}
+
+.story__captions {
+ position: relative;
+ height: 1.6em;
+ margin-top: var(--space-3);
+ font-weight: 650;
+ font-size: var(--text-md);
+}
+
+.story__caption {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ animation: story-caption 12s infinite;
+ animation-delay: calc(var(--n) * 3s);
+}
+
+@keyframes story-caption {
+ 0% { opacity: 0; transform: translateY(4px); }
+ 3%, 22% { opacity: 1; transform: none; }
+ 25%, 100% { opacity: 0; }
+}
+
+.btn--lg { min-height: 48px; padding-inline: var(--space-5); font-size: var(--text-md); }
+.btn--xl { min-height: 64px; padding-inline: var(--space-6); font-size: var(--text-lg); width: 100%; }
+
+/* Roles: one segmented control, two lenses on the same drop */
+.seg {
+ display: inline-flex;
+ padding: 3px;
+ border-radius: var(--radius-pill);
+ border: 1px solid var(--border-default);
+ background: var(--surface-2);
+}
+
+.seg__btn {
+ padding: var(--space-1) var(--space-4);
+ border: 0;
+ border-radius: var(--radius-pill);
+ background: transparent;
+ color: var(--text-secondary);
+ font: inherit;
+ font-size: var(--text-sm);
+ cursor: pointer;
+}
+
+.seg__btn[aria-pressed="true"] {
+ background: var(--brand-gradient);
+ color: #fff;
+ font-weight: 650;
+}
+
+.buyer {
+ display: grid;
+ gap: var(--space-5);
+ max-width: 520px;
+ margin-inline: auto;
+ width: 100%;
+}
+
+.buyer__scarcity {
+ display: grid;
+ justify-items: center;
+ gap: var(--space-2);
+}
+
+.buyer__count {
+ font-size: 4.5rem;
+ font-weight: 800;
+ line-height: 1;
+ color: var(--success);
+}
+
+.buyer__scarcity small { color: var(--text-muted); }
+.buyer__scarcity .drop__hold-bar { width: 100%; }
+.buyer__scarcity .drop__hold-bar i { background: var(--success); }
+
+.organizer {
+ display: grid;
+ gap: var(--space-5);
+}
+
+.organizer__pulse {
+ display: grid;
+ grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
+ gap: var(--space-4);
+}
+
+.organizer__chart,
+.organizer__next {
+ padding: var(--space-3) var(--space-4);
+ border-radius: var(--radius-md);
+ border: 1px solid var(--border-subtle);
+ background: var(--surface-2);
+}
+
+.organizer__label {
+ font-size: var(--text-xs);
+ color: var(--text-muted);
+}
+
+.organizer__value {
+ font-size: var(--text-xl);
+ font-weight: 650;
+}
+
+.spark {
+ display: block;
+ width: 100%;
+ height: 56px;
+}
+
+.spark__line {
+ fill: none;
+ stroke: var(--brand-2);
+ stroke-width: 1.8;
+ vector-effect: non-scaling-stroke;
+}
+
+.spark__area { fill: color-mix(in srgb, var(--brand-2) 20%, transparent); stroke: none; }
+
+/* Language switch in the top bar */
+.lang {
+ display: inline-flex;
+ border-radius: var(--radius-pill);
+ border: 1px solid var(--border-default);
+ overflow: hidden;
+}
+
+.lang__btn {
+ padding: var(--space-1) var(--space-3);
+ border: 0;
+ background: transparent;
+ color: var(--text-secondary);
+ font: inherit;
+ font-size: var(--text-xs);
+ font-weight: 650;
+ cursor: pointer;
+}
+
+.lang__btn[aria-pressed="true"] {
+ background: var(--surface-3);
+ color: var(--text-primary);
+}
+
+@media (max-width: 620px) {
+ .organizer__pulse { grid-template-columns: 1fr; }
+ .drop__head .seg { width: 100%; }
+ .seg__btn { flex: 1; }
+}
+
+/* Reduced motion: the story shows its end state, still */
@media (prefers-reduced-motion: reduce) {
.race__tile { animation: none; }
.drop__cell, .drop__hold-bar i { transition: none; }
+
+ .story__cell--sold,
+ .story__cell--back { animation: none; fill: var(--brand-2); stroke: transparent; }
+ .story__dot { animation: none; opacity: 0; }
+ .story__caption { animation: none; opacity: 0; }
+ .story__caption:last-child { opacity: 1; }
}
@media (max-width: 620px) {
diff --git a/NainOrder.Api/wwwroot/assets/i18n/en.json b/NainOrder.Api/wwwroot/assets/i18n/en.json
new file mode 100644
index 0000000..d2fdac5
--- /dev/null
+++ b/NainOrder.Api/wwwroot/assets/i18n/en.json
@@ -0,0 +1,173 @@
+{
+ "app.tagline": "Drop engine",
+ "app.loading": "Loading NainOrder…",
+
+ "nav.section": "Operations",
+ "nav.label": "Sections",
+ "nav.panel": "Dashboard",
+ "nav.drop": "Drop",
+ "nav.catalog": "Catalogue",
+ "nav.orders": "Orders",
+ "nav.race": "Stock race",
+ "nav.arch": "Architecture",
+
+ "shell.skip": "Skip to content",
+ "shell.search": "Search actions",
+ "shell.credit": "Built by",
+ "shell.menu": "Open navigation",
+ "shell.palette": "Open command palette",
+ "shell.theme": "Change theme",
+ "shell.theme.toLight": "Switch to light theme",
+ "shell.theme.toDark": "Switch to dark theme",
+ "shell.language": "Language",
+ "shell.health.ok": "API online",
+ "shell.health.down": "API unavailable",
+ "shell.health.browser": "Domain engine in the browser",
+ "shell.activeOrder": "Open active order {reference}",
+
+ "banner.title": "Demo mode.",
+ "banner.text": "The rules run in a domain engine inside the browser, not against the .NET API. The real backend, with SQLite and optimistic concurrency, starts with",
+ "banner.cta": "Run the backend",
+
+ "palette.placeholder": "Type an action…",
+ "palette.empty": "No matches",
+ "palette.label": "Command palette",
+ "palette.goto": "Go to {name}",
+ "palette.scenario": "Run guided scenario",
+ "palette.scenario.hint": "Complete purchase flow",
+ "palette.scenario.running": "Running scenario…",
+ "palette.scenario.runningText": "Walking through the complete flow.",
+ "palette.scenario.failed": "The scenario failed",
+ "palette.reset": "Reset demo data",
+ "palette.reset.hint": "Restores the initial catalogue and orders",
+ "palette.reset.done": "Data reset",
+ "palette.reset.doneText": "Catalogue and orders restored to their initial state.",
+ "palette.docs.code": "View code on GitHub",
+ "palette.docs.swagger": "Open Swagger documentation",
+ "palette.docs.codeHint": "C# repository",
+ "palette.theme": "Change theme",
+ "palette.theme.hint": "Light / dark",
+ "palette.activeOrder": "Open active order {reference}",
+ "palette.activeOrder.hint": "Details and transitions",
+
+ "drop.title": "Drop",
+ "drop.subtitle": "Limited units, a crowd, zero overselling",
+ "drop.demo.title": "This runs in the browser.",
+ "drop.demo.text": "Real collisions between simultaneous buyers show up with the .NET API running.",
+
+ "landing.title": "Sell a drop without overselling",
+ "landing.sub": "Exact units. Holds that expire. Nothing sold twice.",
+ "landing.buyer": "Try as a buyer",
+ "landing.organizer": "Try as an organizer",
+ "landing.recent": "Recent drops",
+ "story.arrive": "The crowd arrives",
+ "story.hold": "Each unit is held",
+ "story.expire": "Unconfirmed, it returns",
+ "story.sold": "Sold. Never twice.",
+ "story.label": "Animation: a crowd competes for limited units",
+
+ "role.buyer": "Buyer",
+ "role.organizer": "Organizer",
+ "role.switch": "Switch view",
+
+ "launch.failed": "The drop could not be launched",
+
+ "drop.grid": "One cell per unit",
+ "drop.take": "Get a unit",
+ "drop.crowd": "Release {n} buyers",
+ "drop.reset": "New drop",
+ "drop.confirm": "Confirm purchase",
+ "drop.yourHold": "Your hold",
+ "drop.opensIn": "Opens in {time}",
+ "drop.stat.available": "available",
+ "drop.stat.held": "held",
+ "drop.stat.sold": "sold",
+ "drop.legend.available": "available",
+ "drop.legend.held": "held",
+ "drop.legend.sold": "sold",
+ "drop.status.Scheduled": "Scheduled",
+ "drop.status.Live": "Live",
+ "drop.status.SoldOut": "Sold out",
+ "drop.bought": "Purchase confirmed",
+ "drop.boughtText": "Order {reference} · {total}",
+ "drop.takeFailed": "Could not hold the unit",
+ "drop.confirmFailed": "Could not confirm",
+
+ "drop.org.pace": "Cumulative sales",
+ "drop.org.next": "Next hold to expire",
+ "drop.org.none": "None",
+
+ "msg.drop_not_live": "The drop has not opened yet",
+ "msg.buyer_limit_reached": "You already have the maximum allowed",
+ "msg.insufficient_stock": "Sold out: someone was faster",
+ "msg.concurrency_conflict": "High demand, try again",
+ "msg.hold_expired": "Your hold expired",
+
+ "race.title": "Stock race",
+ "race.subtitle": "The anti-overselling guarantee, made visible",
+ "race.text": "Many buyers, few units, the same instant. Press and check that nothing is ever oversold.",
+ "race.demo.title": "This runs in sequence here.",
+ "race.demo.text": "The browser engine has a single thread and cannot truly compete. The real race shows with the .NET API running.",
+ "race.buyers": "Buyers",
+ "race.units": "Units in stock",
+ "race.run": "Start race",
+ "race.grid": "One square per buyer",
+ "race.legend.won": "got a unit",
+ "race.legend.rejected": "rejected: out of stock",
+ "race.legend.retries": "retries after a collision",
+ "race.stat.buyers": "buyers",
+ "race.stat.units": "units",
+ "race.stat.won": "got one",
+ "race.stat.rejected": "rejected · 409",
+ "race.verdict.ok": "Balanced to the unit: no unit was sold twice",
+ "race.verdict.bad": "The inventory does not balance",
+ "race.verdict.text": "{sold} sold + {remaining} left = {units} in stock.",
+ "race.verdict.retries": "{n} retries after losing a write race.",
+ "race.verdict.noRetries": "No collisions in this run.",
+ "race.failed": "The race could not run",
+
+ "arch.title": "Architecture",
+ "arch.subtitle": "Clean Architecture that is verified, not declared",
+ "arch.text": "Four projects whose dependencies always point towards the domain. Below, the real trace of the calls this interface makes to the API.",
+ "arch.github": "Code on GitHub",
+ "arch.swagger": "Open Swagger",
+ "arch.layer.domain": "Entities, invariants and the state machine. It references no external package: no EF Core, no ASP.NET, not even dependency injection.",
+ "arch.layer.application": "Use cases, DTOs and persistence contracts. It orchestrates aggregates and decides the transaction boundary, but does not know which engine lies underneath.",
+ "arch.layer.infrastructure": "EF Core, SQLite, Fluent API configuration and read queries. It implements the interfaces Application declares: the dependency points inwards.",
+ "arch.layer.api": "REST controllers, the RFC 7807 error contract, rate limiting and this very interface, served as static files from the same origin.",
+ "arch.dep.none": "No dependencies",
+ "arch.trace.title": "Live network trace",
+ "arch.trace.hint": "Every call leaving this interface, with its real latency",
+ "arch.trace.ping": "Send a request",
+ "arch.machine.title": "State machine",
+ "arch.machine.hint": "Served by",
+ "arch.machine.terminal": "final state",
+ "arch.decisions.title": "Technical decisions",
+ "arch.decisions.hint": "Why the code is the way it is, not just what it does",
+ "arch.decision.money.title": "Money is stored in integer cents",
+ "arch.decision.money.text": "SQLite has no decimal type and EF Core would persist it as text, so ORDER BY and SUM would work on strings. An INTEGER converter makes ordering and aggregation exact, and removes floating-point error in amounts.",
+ "arch.decision.concurrency.title": "Optimistic concurrency on stock",
+ "arch.decision.concurrency.text": "Product carries a version token. If two requests try to reserve the last unit at once, the second fails on the UPDATE and gets a 409 instead of overselling.",
+ "arch.decision.uow.title": "Explicit unit of work",
+ "arch.decision.uow.text": "Repositories no longer commit on their own. Adding a line touches two aggregates — order and product — and both are persisted in the same transaction, or neither.",
+ "arch.decision.totals.title": "Order totals are materialised",
+ "arch.decision.totals.text": "They are recalculated on every basket change instead of derived on read, so lists and metrics aggregate in the database without loading each order's lines.",
+ "arch.decision.errors.title": "Errors as a contract, not text",
+ "arch.decision.errors.text": "A single handler translates every exception into ProblemDetails with a stable code. A business failure is 4xx and a technical failure stays 500, never the reverse.",
+ "arch.decision.holds.title": "Holds that expire",
+ "arch.decision.holds.text": "A drop owns its units. An unconfirmed hold returns its own when it expires, and the clock is applied on every read: a stopped process never leaves stock locked.",
+ "arch.failed": "The state machine could not be read",
+
+ "status.PendingPayment": "Pending payment",
+ "status.Paid": "Paid",
+ "status.Processing": "Processing",
+ "status.Shipped": "Shipped",
+ "status.Cancelled": "Cancelled",
+ "stock.in_stock": "In stock",
+ "stock.low_stock": "Low stock",
+ "stock.out_of_stock": "Out of stock",
+ "transition.pay": "Collect payment",
+ "transition.process": "Send to processing",
+ "transition.ship": "Mark as shipped",
+ "transition.cancel": "Cancel order"
+}
diff --git a/NainOrder.Api/wwwroot/assets/i18n/en.views.json b/NainOrder.Api/wwwroot/assets/i18n/en.views.json
new file mode 100644
index 0000000..536b2b7
--- /dev/null
+++ b/NainOrder.Api/wwwroot/assets/i18n/en.views.json
@@ -0,0 +1,204 @@
+{
+ "dash.title": "Dashboard",
+ "dash.subtitle": "Live state of the order engine",
+ "dash.intro.demo": "Metrics computed by the in-browser domain engine. With the .NET API running, SQLite aggregates them over amounts stored as whole cents.",
+ "dash.intro.live": "Metrics aggregated in the database over amounts stored as whole cents, not computed in memory.",
+ "dash.refresh": "Refresh",
+ "dash.units": "{count} units",
+ "dash.sim.title": "Guided scenario completed",
+ "dash.sim.subtitle": "{count} operations in {duration}",
+ "dash.sim.order": "Resulting order",
+ "dash.sim.status": "Final status",
+ "dash.sim.stock": "Remaining product stock",
+ "dash.sim.amount": "Amount",
+ "dash.sim.hint": "Every rule was validated by the domain, not the controller.",
+ "dash.sim.ok": "Got it",
+ "dash.simFailed": "The guided scenario failed",
+ "dash.kpi.revenue": "Confirmed revenue",
+ "dash.kpi.average": "Average order {amount}",
+ "dash.kpi.pending": "Awaiting payment",
+ "dash.kpi.orders": "{count} orders in total",
+ "dash.kpi.customers": "Customers",
+ "dash.kpi.products": "{count} products in the catalogue",
+ "dash.kpi.stock": "Units in stock",
+ "dash.kpi.low": "{count} products below minimum",
+ "dash.kpi.noLow": "No products below minimum",
+ "dash.revenue.hint": "Last 14 days · paid, processing or shipped orders",
+ "dash.dist.title": "Distribution by status",
+ "dash.dist.hint": "Every state of the machine, including empty ones",
+ "dash.recent.title": "Recent orders",
+ "dash.recent.hint": "Click a row to open the details and act on the order",
+ "dash.col.reference": "Reference",
+ "dash.col.customer": "Customer",
+ "dash.col.status": "Status",
+ "dash.col.total": "Total",
+ "dash.col.created": "Created",
+ "dash.empty.title": "No orders yet",
+ "dash.empty.text": "Run the guided scenario or add catalogue products to a new order.",
+ "dash.top.title": "Best sellers",
+ "dash.top.hint": "By units, excluding cancelled orders",
+ "dash.top.empty": "No sales recorded yet.",
+ "dash.loadFailed": "The metrics could not be loaded",
+ "dash.noData.title": "No data available",
+ "dash.noData.text": "The API did not respond correctly. Check that the service is running.",
+ "dash.retry": "Try again",
+
+ "catalog.title": "Catalogue",
+ "catalog.subtitle": "Products, prices and available stock",
+ "catalog.intro": "Stock is governed by the {entity} entity: no upper layer can push it below zero, not even by bypassing the service.",
+ "catalog.new": "New product",
+ "catalog.noDescription": "No description.",
+ "catalog.edit": "Edit {name}",
+ "catalog.restock": "Adjust stock of {name}",
+ "catalog.add": "Add",
+ "catalog.searchLabel": "Search the catalogue",
+ "catalog.searchPlaceholder": "Search by name or SKU…",
+ "catalog.all": "All",
+ "catalog.count": "{count} products",
+ "catalog.empty.title": "No results",
+ "catalog.empty.text": "No product matches the applied filter.",
+ "catalog.empty.clear": "Clear filters",
+ "catalog.form.skuHint": "Unique identifier. Normalised to uppercase.",
+ "catalog.form.name": "Name",
+ "catalog.form.price": "Price (€)",
+ "catalog.form.initialStock": "Initial stock",
+ "catalog.form.category": "Category",
+ "catalog.form.categoryPlaceholder": "General",
+ "catalog.form.description": "Description",
+ "catalog.form.descriptionPlaceholder": "Detail shown in the catalogue",
+ "catalog.form.unexpectedSave": "Unexpected error while saving.",
+ "catalog.modal.editTitle": "Edit product",
+ "catalog.modal.createSubtitle": "Add to the catalogue",
+ "catalog.modal.save": "Save changes",
+ "catalog.modal.create": "Create product",
+ "catalog.toast.updated": "Product updated",
+ "catalog.toast.created": "Product created",
+ "catalog.toast.added": "Added to the order",
+ "catalog.toast.duplicate": "Already in the order",
+ "catalog.toast.duplicateText": "Change the quantity from the order details.",
+ "catalog.stock.title": "Adjust stock",
+ "catalog.stock.subtitle": "{name} · {count} units currently",
+ "catalog.stock.delta": "Change in units",
+ "catalog.stock.hint": "A negative value removes units. The domain refuses to take stock below zero.",
+ "catalog.stock.apply": "Apply",
+ "catalog.stock.nonZero": "Enter a non-zero change.",
+ "catalog.stock.updated": "Stock updated",
+ "catalog.stock.updatedText": "{name}: {count} units",
+ "catalog.stock.unexpected": "Unexpected error.",
+ "catalog.loadFailed": "The catalogue could not be loaded",
+ "catalog.addFailed": "The product could not be added to the order",
+
+ "orders.title": "Orders",
+ "orders.subtitle": "Full lifecycle, from basket to shipment",
+ "orders.intro": "Filters and pagination are resolved in the database over the already materialised totals, without loading the lines of each order.",
+ "orders.open": "Open order",
+ "orders.searchLabel": "Search by reference",
+ "orders.searchPlaceholder": "Reference NO-…",
+ "orders.filter.status": "Filter by status",
+ "orders.filter.allStatuses": "All statuses",
+ "orders.filter.customer": "Filter by customer",
+ "orders.filter.allCustomers": "All customers",
+ "orders.count": "{count} orders",
+ "orders.col.reference": "Reference",
+ "orders.col.customer": "Customer",
+ "orders.col.status": "Status",
+ "orders.col.lines": "Lines",
+ "orders.col.units": "Units",
+ "orders.col.total": "Total",
+ "orders.col.created": "Created",
+ "orders.page": "Page {page} of {total}",
+ "orders.prevPage": "Previous page",
+ "orders.nextPage": "Next page",
+ "orders.empty.title": "No orders to show",
+ "orders.empty.text": "Adjust the filters or open a new order to get started.",
+ "orders.empty.clear": "Clear filters",
+ "orders.loadFailed": "The orders could not be loaded",
+ "orders.noCustomers": "No customers available",
+ "orders.opened": "Order opened",
+ "orders.openFailed": "The order could not be opened",
+
+ "panel.title": "Order {reference}",
+ "panel.pending": "Pending",
+ "panel.qty.down": "Decrease quantity",
+ "panel.qty.up": "Increase quantity",
+ "panel.remove": "Remove {name}",
+ "panel.lifecycle": "Lifecycle",
+ "panel.lines.title": "Order lines",
+ "panel.units": "{count} units",
+ "panel.empty.title": "Empty basket",
+ "panel.empty.text": "Add products from the catalogue so the order can be charged.",
+ "panel.totals.lines": "Lines",
+ "panel.totals.units": "Units",
+ "panel.totals.total": "Total",
+ "panel.customer": "Customer",
+ "panel.noEmail": "No email on file",
+ "panel.actions.title": "Available actions",
+ "panel.actions.hint": "The buttons come from {field}, published by the domain itself: if a transition is not allowed, it does not appear here.",
+ "panel.actions.final": "This order is in a final state and accepts no further transitions.",
+ "panel.openFailed": "The order could not be opened",
+ "panel.toast.cancelled": "Order cancelled and stock returned to the catalogue",
+ "panel.toast.transition": "Transition applied",
+ "panel.toast.removed": "Line removed and stock returned",
+ "panel.cancel.title": "Cancel the order",
+ "panel.cancel.message": "Every reserved unit will be returned to the catalogue. This action cannot be undone.",
+ "panel.cancel.back": "Go back",
+ "panel.noCustomers": "No customers",
+ "panel.noCustomersText": "Create a customer before opening an order.",
+
+ "overlay.close": "Close",
+ "overlay.confirm": "Confirm",
+ "overlay.cancel": "Cancel",
+
+ "toast.close": "Dismiss notification",
+ "toast.failed": "The operation could not be completed",
+ "toast.unexpected": "Unexpected client error.",
+
+ "charts.noData": "No revenue data",
+ "charts.empty": "No activity recorded yet",
+ "charts.orders.one": "{count} order",
+ "charts.orders.other": "{count} orders",
+ "charts.revenueByDay": "Confirmed revenue per day",
+
+ "mock.drop.productNotFound": "Product not found.",
+ "mock.drop.invalid": "Invalid drop configuration.",
+ "mock.drop.notExist": "Drop {id} does not exist.",
+ "mock.drop.noVisitor": "The visitor identifier is missing.",
+ "mock.drop.badQuantity": "Invalid quantity.",
+ "mock.drop.notLive": "The drop has not opened yet.",
+ "mock.drop.limit": "Limit of {count} per buyer.",
+ "mock.drop.onlyLeft": "Only {count} units left.",
+ "mock.drop.holdNotFound": "Reservation not found.",
+ "mock.drop.holdConfirmed": "The reservation is already confirmed.",
+ "mock.drop.holdExpired": "The reservation has expired.",
+ "mock.notFound.product": "Product not found",
+ "mock.notFound.productDetail": "Product {id} does not exist.",
+ "mock.notFound.order": "Order not found",
+ "mock.notFound.orderDetail": "Order {id} does not exist.",
+ "mock.notFound.line": "Line not found",
+ "mock.notFound.lineDetail": "The product is not in the order.",
+ "mock.notFound.customer": "Customer not found",
+ "mock.notFound.customerDetail": "You must select a valid customer.",
+ "mock.notFound.route": "Not found",
+ "mock.notFound.routeDetail": "Route {method} {path} is not implemented.",
+ "mock.insufficient.title": "Insufficient stock",
+ "mock.insufficient.negative": "The resulting stock ({stock}) cannot be negative.",
+ "mock.insufficient.add": "Not enough stock to add {count} more units.",
+ "mock.insufficient.left": "Only {count} units of {name} left.",
+ "mock.validation.title": "Validation failed",
+ "mock.validation.skuName": "SKU and Name are required fields.",
+ "mock.validation.short": "Validation",
+ "mock.validation.nameEmail": "Name and email are required.",
+ "mock.duplicateSku.title": "Duplicate SKU",
+ "mock.duplicateSku.detail": "SKU {sku} already exists in the catalogue.",
+ "mock.locked.title": "Order locked",
+ "mock.locked.detail": "Only orders awaiting payment can be modified.",
+ "mock.transition.title": "Invalid transition",
+ "mock.transition.detail": "The order cannot move from {from} to {to}.",
+ "mock.sim.step1": "The domain validates SKU, price and initial stock",
+ "mock.sim.step2": "Order starts as PendingPayment with a readable reference",
+ "mock.sim.step3": "The aggregate freezes the price and Product deducts stock",
+ "mock.sim.step4": "Transition PendingPayment -> Paid validated by the domain",
+ "mock.sim.step5": "Transition Paid -> Processing",
+ "mock.sim.step6": "Transition Processing -> Shipped, final state",
+ "mock.sim.message": "Full flow executed on the in-memory domain engine, honouring the same Clean Architecture invariants."
+}
diff --git a/NainOrder.Api/wwwroot/assets/i18n/es.json b/NainOrder.Api/wwwroot/assets/i18n/es.json
new file mode 100644
index 0000000..3c5b02c
--- /dev/null
+++ b/NainOrder.Api/wwwroot/assets/i18n/es.json
@@ -0,0 +1,173 @@
+{
+ "app.tagline": "Motor de drops",
+ "app.loading": "Cargando NainOrder…",
+
+ "nav.section": "Operación",
+ "nav.label": "Secciones",
+ "nav.panel": "Panel",
+ "nav.drop": "Drop",
+ "nav.catalog": "Catálogo",
+ "nav.orders": "Pedidos",
+ "nav.race": "Carrera de stock",
+ "nav.arch": "Arquitectura",
+
+ "shell.skip": "Saltar al contenido",
+ "shell.search": "Buscar acción",
+ "shell.credit": "Desarrollado por",
+ "shell.menu": "Abrir navegación",
+ "shell.palette": "Abrir paleta de comandos",
+ "shell.theme": "Cambiar tema",
+ "shell.theme.toLight": "Activar tema claro",
+ "shell.theme.toDark": "Activar tema oscuro",
+ "shell.language": "Idioma",
+ "shell.health.ok": "API operativa",
+ "shell.health.down": "API no disponible",
+ "shell.health.browser": "Motor de dominio en el navegador",
+ "shell.activeOrder": "Abrir pedido activo {reference}",
+
+ "banner.title": "Modo demostración.",
+ "banner.text": "Las reglas se ejecutan en un motor de dominio dentro del navegador, no contra la API .NET. El backend real, con SQLite y concurrencia optimista, se levanta con",
+ "banner.cta": "Ejecutar el backend",
+
+ "palette.placeholder": "Escribe una acción…",
+ "palette.empty": "Sin coincidencias",
+ "palette.label": "Paleta de comandos",
+ "palette.goto": "Ir a {name}",
+ "palette.scenario": "Ejecutar escenario guiado",
+ "palette.scenario.hint": "Flujo completo de compra",
+ "palette.scenario.running": "Ejecutando escenario…",
+ "palette.scenario.runningText": "Se está recorriendo el flujo completo.",
+ "palette.scenario.failed": "El escenario falló",
+ "palette.reset": "Reiniciar datos de la demo",
+ "palette.reset.hint": "Restaura el catálogo y pedidos iniciales",
+ "palette.reset.done": "Datos reiniciados",
+ "palette.reset.doneText": "Catálogo y pedidos restaurados al estado inicial.",
+ "palette.docs.code": "Ver código en GitHub",
+ "palette.docs.swagger": "Abrir documentación Swagger",
+ "palette.docs.codeHint": "Repositorio C#",
+ "palette.theme": "Cambiar tema",
+ "palette.theme.hint": "Claro / oscuro",
+ "palette.activeOrder": "Abrir pedido activo {reference}",
+ "palette.activeOrder.hint": "Detalle y transiciones",
+
+ "drop.title": "Drop",
+ "drop.subtitle": "Unidades limitadas, una multitud, cero sobreventa",
+ "drop.demo.title": "Aquí corre en el navegador.",
+ "drop.demo.text": "Las colisiones reales entre compradores simultáneos se ven con la API .NET en marcha.",
+
+ "landing.title": "Vende un drop sin sobreventa",
+ "landing.sub": "Unidades exactas. Reservas que caducan. Ninguna vendida dos veces.",
+ "landing.buyer": "Probar como comprador",
+ "landing.organizer": "Probar como organizador",
+ "landing.recent": "Drops recientes",
+ "story.arrive": "La multitud llega",
+ "story.hold": "Cada unidad se reserva",
+ "story.expire": "Si no se confirma, vuelve",
+ "story.sold": "Vendido. Nunca dos veces.",
+ "story.label": "Animación: una multitud compite por unidades limitadas",
+
+ "role.buyer": "Comprador",
+ "role.organizer": "Organizador",
+ "role.switch": "Cambiar de vista",
+
+ "launch.failed": "No se pudo lanzar el drop",
+
+ "drop.grid": "Una celda por unidad",
+ "drop.take": "Conseguir una unidad",
+ "drop.crowd": "Soltar {n} compradores",
+ "drop.reset": "Otro drop",
+ "drop.confirm": "Confirmar compra",
+ "drop.yourHold": "Tu reserva",
+ "drop.opensIn": "Abre en {time}",
+ "drop.stat.available": "disponibles",
+ "drop.stat.held": "reservadas",
+ "drop.stat.sold": "vendidas",
+ "drop.legend.available": "disponible",
+ "drop.legend.held": "reservada",
+ "drop.legend.sold": "vendida",
+ "drop.status.Scheduled": "Programado",
+ "drop.status.Live": "En vivo",
+ "drop.status.SoldOut": "Agotado",
+ "drop.bought": "Compra confirmada",
+ "drop.boughtText": "Pedido {reference} · {total}",
+ "drop.takeFailed": "No se pudo reservar",
+ "drop.confirmFailed": "No se pudo confirmar",
+
+ "drop.org.pace": "Ventas acumuladas",
+ "drop.org.next": "Próxima reserva que caduca",
+ "drop.org.none": "Ninguna",
+
+ "msg.drop_not_live": "El drop aún no ha abierto",
+ "msg.buyer_limit_reached": "Ya tienes el máximo permitido",
+ "msg.insufficient_stock": "Agotado: otra persona fue más rápida",
+ "msg.concurrency_conflict": "Mucha demanda, inténtalo otra vez",
+ "msg.hold_expired": "Tu reserva caducó",
+
+ "race.title": "Carrera de stock",
+ "race.subtitle": "La garantía anti-sobreventa, a la vista",
+ "race.text": "Muchos compradores, pocas unidades, el mismo instante. Pulsa y comprueba que nunca se vende de más.",
+ "race.demo.title": "Aquí corre en secuencia.",
+ "race.demo.text": "El motor del navegador tiene un solo hilo y no puede competir de verdad. La carrera real se ve con la API .NET en marcha.",
+ "race.buyers": "Compradores",
+ "race.units": "Unidades en stock",
+ "race.run": "Lanzar carrera",
+ "race.grid": "Un cuadro por comprador",
+ "race.legend.won": "consiguió una unidad",
+ "race.legend.rejected": "rechazado por falta de stock",
+ "race.legend.retries": "reintentos tras una colisión",
+ "race.stat.buyers": "compradores",
+ "race.stat.units": "unidades",
+ "race.stat.won": "consiguieron una",
+ "race.stat.rejected": "rechazados · 409",
+ "race.verdict.ok": "Cuadra al céntimo: ninguna unidad se vendió dos veces",
+ "race.verdict.bad": "El inventario no cuadra",
+ "race.verdict.text": "{sold} vendidas + {remaining} restantes = {units} en stock.",
+ "race.verdict.retries": "{n} reintentos tras perder una carrera de escritura.",
+ "race.verdict.noRetries": "Sin colisiones en esta ejecución.",
+ "race.failed": "La carrera no se pudo ejecutar",
+
+ "arch.title": "Arquitectura",
+ "arch.subtitle": "Clean Architecture verificable, no declarativa",
+ "arch.text": "Cuatro proyectos con dependencias que apuntan siempre hacia el dominio. Debajo, la traza real de las llamadas que esta interfaz hace contra la API.",
+ "arch.github": "Código en GitHub",
+ "arch.swagger": "Abrir Swagger",
+ "arch.layer.domain": "Entidades, invariantes y máquina de estados. No referencia ningún paquete externo: ni EF Core, ni ASP.NET, ni siquiera inyección de dependencias.",
+ "arch.layer.application": "Casos de uso, DTOs y contratos de persistencia. Orquesta agregados y decide la frontera transaccional, pero no sabe qué motor hay debajo.",
+ "arch.layer.infrastructure": "EF Core, SQLite, configuración Fluent API y consultas de lectura. Implementa las interfaces que declara Application: la dependencia apunta hacia dentro.",
+ "arch.layer.api": "Controladores REST, contrato de errores RFC 7807, limitación de tráfico y esta misma interfaz servida como estáticos desde el mismo origen.",
+ "arch.dep.none": "Sin dependencias",
+ "arch.trace.title": "Traza de red en vivo",
+ "arch.trace.hint": "Cada llamada que sale de esta interfaz, con su latencia real",
+ "arch.trace.ping": "Lanzar petición",
+ "arch.machine.title": "Máquina de estados",
+ "arch.machine.hint": "Servida por",
+ "arch.machine.terminal": "estado final",
+ "arch.decisions.title": "Decisiones técnicas",
+ "arch.decisions.hint": "Por qué el código es como es, no solo qué hace",
+ "arch.decision.money.title": "El dinero se guarda en céntimos enteros",
+ "arch.decision.money.text": "SQLite no tiene tipo decimal y EF Core lo persistiría como texto, con lo que ORDER BY y SUM operarían sobre cadenas. Un conversor a INTEGER hace exactas la ordenación y la agregación, y elimina el error de coma flotante en importes.",
+ "arch.decision.concurrency.title": "Concurrencia optimista sobre el stock",
+ "arch.decision.concurrency.text": "Product lleva un token de versión. Si dos peticiones intentan reservar la última unidad a la vez, la segunda falla en el UPDATE y recibe un 409 en lugar de sobrevender.",
+ "arch.decision.uow.title": "Unidad de trabajo explícita",
+ "arch.decision.uow.text": "Los repositorios ya no confirman por su cuenta. Añadir una línea toca dos agregados —pedido y producto— y ambos se persisten dentro de la misma transacción o ninguno.",
+ "arch.decision.totals.title": "Los totales del pedido se materializan",
+ "arch.decision.totals.text": "Se recalculan en cada cambio de la cesta en lugar de derivarse al leer, de modo que listados y métricas agregan en base de datos sin cargar las líneas de cada pedido.",
+ "arch.decision.errors.title": "Errores como contrato, no como texto",
+ "arch.decision.errors.text": "Un único manejador traduce cada excepción a ProblemDetails con un código estable. Un fallo de negocio es 4xx y un fallo técnico sigue siendo 500, nunca al revés.",
+ "arch.decision.holds.title": "Reservas que caducan",
+ "arch.decision.holds.text": "Un drop es dueño de sus unidades. Una reserva sin confirmar devuelve las suyas al caducar, y el reloj se aplica en cada lectura: un proceso parado nunca deja stock bloqueado.",
+ "arch.failed": "No se pudo leer la máquina de estados",
+
+ "status.PendingPayment": "Pendiente de pago",
+ "status.Paid": "Pagado",
+ "status.Processing": "En preparación",
+ "status.Shipped": "Enviado",
+ "status.Cancelled": "Cancelado",
+ "stock.in_stock": "En stock",
+ "stock.low_stock": "Stock bajo",
+ "stock.out_of_stock": "Sin stock",
+ "transition.pay": "Cobrar pedido",
+ "transition.process": "Enviar a preparación",
+ "transition.ship": "Marcar como enviado",
+ "transition.cancel": "Cancelar pedido"
+}
diff --git a/NainOrder.Api/wwwroot/assets/i18n/es.views.json b/NainOrder.Api/wwwroot/assets/i18n/es.views.json
new file mode 100644
index 0000000..8c99e4e
--- /dev/null
+++ b/NainOrder.Api/wwwroot/assets/i18n/es.views.json
@@ -0,0 +1,204 @@
+{
+ "dash.title": "Panel de control",
+ "dash.subtitle": "Estado del motor de pedidos en tiempo real",
+ "dash.intro.demo": "Métricas calculadas por el motor de dominio del navegador. Con la API .NET en marcha, las agrega SQLite sobre importes en céntimos enteros.",
+ "dash.intro.live": "Métricas agregadas en base de datos sobre importes almacenados en céntimos enteros, no calculadas en memoria.",
+ "dash.refresh": "Actualizar",
+ "dash.units": "{count} uds.",
+ "dash.sim.title": "Escenario guiado ejecutado",
+ "dash.sim.subtitle": "{count} operaciones en {duration}",
+ "dash.sim.order": "Pedido resultante",
+ "dash.sim.status": "Estado final",
+ "dash.sim.stock": "Stock restante del producto",
+ "dash.sim.amount": "Importe",
+ "dash.sim.hint": "Todas las reglas las ha validado el dominio, no el controlador.",
+ "dash.sim.ok": "Entendido",
+ "dash.simFailed": "El escenario guiado falló",
+ "dash.kpi.revenue": "Ingresos confirmados",
+ "dash.kpi.average": "Ticket medio {amount}",
+ "dash.kpi.pending": "Pendiente de cobro",
+ "dash.kpi.orders": "{count} pedidos en total",
+ "dash.kpi.customers": "Clientes",
+ "dash.kpi.products": "{count} referencias en catálogo",
+ "dash.kpi.stock": "Unidades en stock",
+ "dash.kpi.low": "{count} referencias bajo mínimos",
+ "dash.kpi.noLow": "Sin referencias bajo mínimos",
+ "dash.revenue.hint": "Últimos 14 días · pedidos pagados, en preparación o enviados",
+ "dash.dist.title": "Distribución por estado",
+ "dash.dist.hint": "Todos los estados de la máquina, incluidos los vacíos",
+ "dash.recent.title": "Pedidos recientes",
+ "dash.recent.hint": "Pulsa una fila para abrir el detalle y operar sobre el pedido",
+ "dash.col.reference": "Referencia",
+ "dash.col.customer": "Cliente",
+ "dash.col.status": "Estado",
+ "dash.col.total": "Total",
+ "dash.col.created": "Creado",
+ "dash.empty.title": "Todavía no hay pedidos",
+ "dash.empty.text": "Ejecuta el escenario guiado o añade productos del catálogo a un pedido nuevo.",
+ "dash.top.title": "Más vendidos",
+ "dash.top.hint": "Por unidades, excluyendo pedidos cancelados",
+ "dash.top.empty": "Sin ventas registradas todavía.",
+ "dash.loadFailed": "No se pudieron cargar las métricas",
+ "dash.noData.title": "No hay datos disponibles",
+ "dash.noData.text": "La API no respondió correctamente. Comprueba que el servicio está en marcha.",
+ "dash.retry": "Reintentar",
+
+ "catalog.title": "Catálogo",
+ "catalog.subtitle": "Referencias, precios y stock disponible",
+ "catalog.intro": "El stock lo gobierna la entidad {entity}: ninguna capa superior puede dejarlo en negativo, ni siquiera saltándose el servicio.",
+ "catalog.new": "Nuevo producto",
+ "catalog.noDescription": "Sin descripción.",
+ "catalog.edit": "Editar {name}",
+ "catalog.restock": "Reponer stock de {name}",
+ "catalog.add": "Añadir",
+ "catalog.searchLabel": "Buscar en el catálogo",
+ "catalog.searchPlaceholder": "Buscar por nombre o SKU…",
+ "catalog.all": "Todas",
+ "catalog.count": "{count} referencias",
+ "catalog.empty.title": "Sin resultados",
+ "catalog.empty.text": "Ningún producto coincide con el filtro aplicado.",
+ "catalog.empty.clear": "Quitar filtros",
+ "catalog.form.skuHint": "Identificador único. Se normaliza a mayúsculas.",
+ "catalog.form.name": "Nombre",
+ "catalog.form.price": "Precio (€)",
+ "catalog.form.initialStock": "Stock inicial",
+ "catalog.form.category": "Categoría",
+ "catalog.form.categoryPlaceholder": "General",
+ "catalog.form.description": "Descripción",
+ "catalog.form.descriptionPlaceholder": "Detalle visible en el catálogo",
+ "catalog.form.unexpectedSave": "Error inesperado al guardar.",
+ "catalog.modal.editTitle": "Editar producto",
+ "catalog.modal.createSubtitle": "Alta en el catálogo",
+ "catalog.modal.save": "Guardar cambios",
+ "catalog.modal.create": "Crear producto",
+ "catalog.toast.updated": "Producto actualizado",
+ "catalog.toast.created": "Producto creado",
+ "catalog.toast.added": "Añadido al pedido",
+ "catalog.toast.duplicate": "Ya está en el pedido",
+ "catalog.toast.duplicateText": "Ajusta la cantidad desde el detalle del pedido.",
+ "catalog.stock.title": "Ajustar stock",
+ "catalog.stock.subtitle": "{name} · {count} unidades actuales",
+ "catalog.stock.delta": "Variación de unidades",
+ "catalog.stock.hint": "Un valor negativo retira unidades. El dominio rechaza dejar el stock por debajo de cero.",
+ "catalog.stock.apply": "Aplicar",
+ "catalog.stock.nonZero": "Introduce una variación distinta de cero.",
+ "catalog.stock.updated": "Stock actualizado",
+ "catalog.stock.updatedText": "{name}: {count} unidades",
+ "catalog.stock.unexpected": "Error inesperado.",
+ "catalog.loadFailed": "No se pudo cargar el catálogo",
+ "catalog.addFailed": "No se pudo añadir al pedido",
+
+ "orders.title": "Pedidos",
+ "orders.subtitle": "Ciclo de vida completo, del carrito al envío",
+ "orders.intro": "Los filtros y la paginación se resuelven en base de datos sobre los totales ya materializados, sin cargar las líneas de cada pedido.",
+ "orders.open": "Abrir pedido",
+ "orders.searchLabel": "Buscar por referencia",
+ "orders.searchPlaceholder": "Referencia NO-…",
+ "orders.filter.status": "Filtrar por estado",
+ "orders.filter.allStatuses": "Todos los estados",
+ "orders.filter.customer": "Filtrar por cliente",
+ "orders.filter.allCustomers": "Todos los clientes",
+ "orders.count": "{count} pedidos",
+ "orders.col.reference": "Referencia",
+ "orders.col.customer": "Cliente",
+ "orders.col.status": "Estado",
+ "orders.col.lines": "Líneas",
+ "orders.col.units": "Unidades",
+ "orders.col.total": "Total",
+ "orders.col.created": "Creado",
+ "orders.page": "Página {page} de {total}",
+ "orders.prevPage": "Página anterior",
+ "orders.nextPage": "Página siguiente",
+ "orders.empty.title": "No hay pedidos que mostrar",
+ "orders.empty.text": "Ajusta los filtros o abre un pedido nuevo para empezar.",
+ "orders.empty.clear": "Quitar filtros",
+ "orders.loadFailed": "No se pudieron cargar los pedidos",
+ "orders.noCustomers": "No hay clientes disponibles",
+ "orders.opened": "Pedido abierto",
+ "orders.openFailed": "No se pudo abrir el pedido",
+
+ "panel.title": "Pedido {reference}",
+ "panel.pending": "Pendiente",
+ "panel.qty.down": "Reducir cantidad",
+ "panel.qty.up": "Aumentar cantidad",
+ "panel.remove": "Quitar {name}",
+ "panel.lifecycle": "Ciclo de vida",
+ "panel.lines.title": "Líneas del pedido",
+ "panel.units": "{count} unidades",
+ "panel.empty.title": "Cesta vacía",
+ "panel.empty.text": "Añade productos desde el catálogo para poder cobrar el pedido.",
+ "panel.totals.lines": "Líneas",
+ "panel.totals.units": "Unidades",
+ "panel.totals.total": "Total",
+ "panel.customer": "Cliente",
+ "panel.noEmail": "Sin email registrado",
+ "panel.actions.title": "Acciones disponibles",
+ "panel.actions.hint": "Los botones salen de {field}, que publica el propio dominio: si una transición no está permitida, aquí no aparece.",
+ "panel.actions.final": "Este pedido está en un estado final y no admite más transiciones.",
+ "panel.openFailed": "No se pudo abrir el pedido",
+ "panel.toast.cancelled": "Pedido cancelado y stock repuesto en el catálogo",
+ "panel.toast.transition": "Transición aplicada",
+ "panel.toast.removed": "Línea eliminada y stock repuesto",
+ "panel.cancel.title": "Cancelar el pedido",
+ "panel.cancel.message": "Se devolverán al catálogo todas las unidades reservadas. Esta acción no se puede deshacer.",
+ "panel.cancel.back": "Volver",
+ "panel.noCustomers": "No hay clientes",
+ "panel.noCustomersText": "Crea un cliente antes de abrir un pedido.",
+
+ "overlay.close": "Cerrar",
+ "overlay.confirm": "Confirmar",
+ "overlay.cancel": "Cancelar",
+
+ "toast.close": "Cerrar aviso",
+ "toast.failed": "No se pudo completar la operación",
+ "toast.unexpected": "Error inesperado en el cliente.",
+
+ "charts.noData": "Sin datos de ingresos",
+ "charts.empty": "Sin actividad registrada todavía",
+ "charts.orders.one": "{count} pedido",
+ "charts.orders.other": "{count} pedidos",
+ "charts.revenueByDay": "Ingresos confirmados por día",
+
+ "mock.drop.productNotFound": "Producto no encontrado.",
+ "mock.drop.invalid": "Configuración de drop no válida.",
+ "mock.drop.notExist": "Drop {id} no existe.",
+ "mock.drop.noVisitor": "Falta el identificador de visitante.",
+ "mock.drop.badQuantity": "Cantidad no válida.",
+ "mock.drop.notLive": "El drop aún no ha abierto.",
+ "mock.drop.limit": "Límite de {count} por comprador.",
+ "mock.drop.onlyLeft": "Solo quedan {count} unidades.",
+ "mock.drop.holdNotFound": "Reserva no encontrada.",
+ "mock.drop.holdConfirmed": "La reserva ya está confirmada.",
+ "mock.drop.holdExpired": "La reserva ha caducado.",
+ "mock.notFound.product": "Producto no encontrado",
+ "mock.notFound.productDetail": "El producto {id} no existe.",
+ "mock.notFound.order": "Pedido no encontrado",
+ "mock.notFound.orderDetail": "Pedido {id} no existe.",
+ "mock.notFound.line": "Línea no encontrada",
+ "mock.notFound.lineDetail": "Producto no está en el pedido.",
+ "mock.notFound.customer": "Cliente no encontrado",
+ "mock.notFound.customerDetail": "Debes seleccionar un cliente válido.",
+ "mock.notFound.route": "No encontrado",
+ "mock.notFound.routeDetail": "Ruta {method} {path} no implementada.",
+ "mock.insufficient.title": "Stock insuficiente",
+ "mock.insufficient.negative": "El stock resultante ({stock}) no puede ser negativo.",
+ "mock.insufficient.add": "No hay suficiente stock para añadir {count} unidades más.",
+ "mock.insufficient.left": "Solo quedan {count} unidades de {name}.",
+ "mock.validation.title": "Validación fallida",
+ "mock.validation.skuName": "SKU y Nombre son campos requeridos.",
+ "mock.validation.short": "Validación",
+ "mock.validation.nameEmail": "Nombre y correo son requeridos.",
+ "mock.duplicateSku.title": "SKU duplicado",
+ "mock.duplicateSku.detail": "El SKU {sku} ya existe en el catálogo.",
+ "mock.locked.title": "Pedido bloqueado",
+ "mock.locked.detail": "Solo los pedidos pendientes de cobro pueden modificarse.",
+ "mock.transition.title": "Transición inválida",
+ "mock.transition.detail": "No se puede pasar el pedido de {from} a {to}.",
+ "mock.sim.step1": "Domain valida SKU, precio y stock inicial",
+ "mock.sim.step2": "Order nace en PendingPayment con referencia legible",
+ "mock.sim.step3": "El agregado congela el precio y Product descuenta stock",
+ "mock.sim.step4": "Transición PendingPayment -> Paid validada por el dominio",
+ "mock.sim.step5": "Transición Paid -> Processing",
+ "mock.sim.step6": "Transición Processing -> Shipped, estado final",
+ "mock.sim.message": "Flujo completo ejecutado sobre el motor de dominio en memoria, respetando las mismas invariantes de Clean Architecture."
+}
diff --git a/NainOrder.Api/wwwroot/assets/js/app.js b/NainOrder.Api/wwwroot/assets/js/app.js
index 3a0ba68..40514c2 100644
--- a/NainOrder.Api/wwwroot/assets/js/app.js
+++ b/NainOrder.Api/wwwroot/assets/js/app.js
@@ -1,4 +1,4 @@
-/** Arranque de la aplicación: shell, navegación, tema, paleta de comandos y estado global. */
+/** Application start-up: shell, navigation, theme, language, command palette and global state. */
import { html, icon, raw, $, delegate } from "./dom.js";
import { api, isDemoMode, resetDemoDatabase, onTrace } from "./api.js";
@@ -7,6 +7,7 @@ import { getState, setState, subscribe, setActiveOrder, clearActiveOrder } from
import { openOrderPanel } from "./order-panel.js";
import { toast } from "./toast.js";
import { fmtMoney } from "./format.js";
+import { applyStatic, getLocale, loadI18n, locales, setLocale, t } from "./i18n.js";
import { dashboardView } from "./views/dashboard.js";
import { catalogView } from "./views/catalog.js";
@@ -16,16 +17,16 @@ import { raceView } from "./views/race.js";
import { dropView } from "./views/drop.js";
const NAV = [
- { path: "/panel", label: "Panel", icon: "dashboard", view: dashboardView },
- { path: "/drop", label: "Drop", icon: "ticket", view: dropView },
- { path: "/catalogo", label: "Catálogo", icon: "catalog", view: catalogView },
- { path: "/pedidos", label: "Pedidos", icon: "orders", view: ordersView },
- { path: "/carrera", label: "Carrera de stock", icon: "race", view: raceView },
- { path: "/arquitectura", label: "Arquitectura", icon: "architecture", view: architectureView }
+ { path: "/drop", labelKey: "nav.drop", icon: "ticket", view: dropView },
+ { path: "/panel", labelKey: "nav.panel", icon: "dashboard", view: dashboardView },
+ { path: "/catalogo", labelKey: "nav.catalog", icon: "catalog", view: catalogView },
+ { path: "/pedidos", labelKey: "nav.orders", icon: "orders", view: ordersView },
+ { path: "/carrera", labelKey: "nav.race", icon: "race", view: raceView },
+ { path: "/arquitectura", labelKey: "nav.arch", icon: "architecture", view: architectureView }
];
/* --------------------------------------------------------------------------
- Tema
+ Theme
-------------------------------------------------------------------------- */
const systemPrefersLight = () => window.matchMedia("(prefers-color-scheme: light)").matches;
@@ -37,28 +38,26 @@ const applyTheme = (theme) => {
if (!button) return;
button.innerHTML = icon(resolved === "dark" ? "sun" : "moon", { size: 16 });
- button.setAttribute("aria-label", resolved === "dark" ? "Activar tema claro" : "Activar tema oscuro");
+ button.setAttribute("aria-label", t(resolved === "dark" ? "shell.theme.toLight" : "shell.theme.toDark"));
};
/* --------------------------------------------------------------------------
- Aviso de modo demostración
+ Demo-mode notice
- En un host estático no hay API .NET detrás: el panel habla con el motor de
- dominio del navegador. Decirlo de forma visible es parte del contrato de la
- demo — quien la evalúa debe saber qué está ejecutando antes de sacar
- conclusiones sobre el backend.
+ On a static host there is no .NET API behind the panel: it talks to the in-browser domain
+ engine. Saying so visibly is part of the demo's contract — whoever evaluates it must know
+ what is running before drawing conclusions about the backend.
-------------------------------------------------------------------------- */
const demoBanner = () => html`
${raw(icon("info", { size: 15 }))}
- Modo demostración. Las reglas se ejecutan en un motor de dominio
- dentro del navegador, no contra la API .NET. El backend real, con SQLite y
- concurrencia optimista, se levanta con docker run.
+
+ docker run.
`;
};
const close = () => {
@@ -316,25 +327,31 @@ const toggleTheme = () => {
};
/* --------------------------------------------------------------------------
- Salud del servicio
+ Service health
-------------------------------------------------------------------------- */
-const monitorHealth = () => {
+let health = "ok";
+const paintHealth = () => {
const node = $("[data-health]");
+ if (!node) return;
+
+ const key = { ok: "shell.health.ok", down: "shell.health.down", browser: "shell.health.browser" }[health];
+ node.innerHTML = html` ${t(key)}`;
+};
+
+const monitorHealth = () => {
const banner = $("[data-demo-banner]");
- // El modo demostración también se activa a posteriori, cuando una petición falla
- // y el cliente conmuta al motor del navegador. El aviso se reevalúa en cada
- // comprobación para que no dependa de lo que se supiera al primer pintado.
+ // Demo mode can also kick in later, when a request fails and the client falls back to the
+ // browser engine. The notice is re-evaluated on every check so it never depends on what was
+ // known at first paint.
const syncBanner = () => { banner.hidden = !isDemoMode(); };
const check = async () => {
syncBanner();
if (isDemoMode()) {
- node.dataset.painted = "true";
- node.innerHTML = html`
- Motor de dominio en el navegador
- `;
+ health = "browser";
+ paintHealth();
setState({ apiHealthy: true });
return;
}
@@ -347,40 +364,45 @@ const monitorHealth = () => {
healthy = false;
}
- if (healthy === getState().apiHealthy && node.dataset.painted) return;
-
+ health = healthy ? "ok" : "down";
+ paintHealth();
setState({ apiHealthy: healthy });
- node.dataset.painted = "true";
- node.innerHTML = html`
-
- ${healthy ? "API operativa" : "API no disponible"}
- `;
};
check();
- // La conmutación al motor del navegador ocurre dentro de una petición, así que el
- // aviso se reevalúa con cada traza en lugar de esperar al siguiente sondeo.
+ // The switch to the browser engine happens inside a request, so the notice is re-evaluated
+ // on every trace instead of waiting for the next poll.
onTrace(syncBanner);
- // Un sondeo espaciado basta para un panel: no conviene añadir ruido a la traza.
+ // A sparse poll is enough for a panel: no point adding noise to the trace.
setInterval(check, 30_000);
};
/* --------------------------------------------------------------------------
- Arranque
+ Start-up
-------------------------------------------------------------------------- */
const boot = async () => {
+ await loadI18n();
+
document.body.innerHTML = shell();
applyTheme(getState().theme);
+ applyStatic(document);
const outlet = $("#main-content");
const app = $(".app");
+ let currentView = null;
NAV.forEach((item) => defineRoute(item.path, item.view));
+ const paintTitles = () => {
+ if (!currentView) return;
+ $("[data-topbar-title]").textContent = currentView.title;
+ $("[data-topbar-subtitle]").textContent = currentView.subtitle ?? "";
+ document.title = `${currentView.title} · NainOrder`;
+ };
+
onRouteChange((path, view) => {
- $("[data-topbar-title]").textContent = view.title;
- $("[data-topbar-subtitle]").textContent = view.subtitle ?? "";
- document.title = `${view.title} · NainOrder`;
+ currentView = view;
+ paintTitles();
document.querySelectorAll("[data-nav]").forEach((link) => {
if (link.dataset.nav === path) link.setAttribute("aria-current", "page");
@@ -392,6 +414,7 @@ const boot = async () => {
delegate(document.body, "click", '[data-action="theme"]', toggleTheme);
delegate(document.body, "click", '[data-action="palette"]', openPalette);
+ delegate(document.body, "click", '[data-lang]', (event, button) => setLocale(button.dataset.lang));
delegate(document.body, "click", '[data-action="toggle-nav"]', () => {
app.dataset.navOpen = app.dataset.navOpen === "true" ? "false" : "true";
});
@@ -417,15 +440,28 @@ const boot = async () => {
const rerender = startRouter(outlet);
- // Los clientes se cargan una vez: el catálogo los necesita para abrir un pedido.
+ // A language switch repaints the shell's static text and re-renders the current view.
+ window.addEventListener("nain:locale", () => {
+ document.querySelectorAll("[data-lang]").forEach((button) => {
+ button.setAttribute("aria-pressed", String(button.dataset.lang === getLocale()));
+ });
+ applyStatic(document);
+ applyTheme(document.documentElement.dataset.theme);
+ paintTitles();
+ paintHealth();
+ renderActiveOrder();
+ rerender();
+ });
+
+ // Customers load once: the catalogue needs them to open an order.
try {
setState({ customers: await api.customers.list() });
} catch {
- // Sin clientes la interfaz sigue siendo navegable; el aviso llega al intentar operar.
+ // Without customers the interface stays navigable; the warning arrives when trying to operate.
}
- // El pedido activo guardado puede haber desaparecido —base efímera reiniciada— o
- // haberse cobrado; en ambos casos deja de servir como cesta de trabajo.
+ // The saved active order may have disappeared (ephemeral database reset) or been paid; in
+ // both cases it no longer works as a working basket.
const { activeOrderId } = getState();
if (activeOrderId) {
try {
@@ -440,5 +476,5 @@ const boot = async () => {
boot();
-// Superficie mínima para navegar por consola o desde un enlace profundo externo.
+// Minimal surface to navigate from the console or from an external deep link.
window.NainOrder = { navigate, currentPath, isDemoMode, resetDemoDatabase };
diff --git a/NainOrder.Api/wwwroot/assets/js/charts.js b/NainOrder.Api/wwwroot/assets/js/charts.js
index 2217de7..ce34791 100644
--- a/NainOrder.Api/wwwroot/assets/js/charts.js
+++ b/NainOrder.Api/wwwroot/assets/js/charts.js
@@ -5,6 +5,7 @@
import { html, raw } from "./dom.js";
import { fmtMoney, fmtDateShort } from "./format.js";
+import { t } from "./i18n.js";
const VIEW_WIDTH = 720;
const VIEW_HEIGHT = 220;
@@ -36,9 +37,9 @@ const buildSmoothPath = (points) => {
export const areaChart = (series) => {
if (!series?.length) {
return html``;
}
@@ -77,7 +78,7 @@ export const areaChart = (series) => {
const hitWidth = stepX || innerWidth;
const markers = points
.map((point) => {
- const tooltip = `${fmtDateShort(point.date)} · ${fmtMoney(point.amount)} · ${point.orders} pedido${point.orders === 1 ? "" : "s"}`;
+ const tooltip = `${fmtDateShort(point.date)} · ${fmtMoney(point.amount)} · ${t(point.orders === 1 ? "charts.orders.one" : "charts.orders.other", { count: point.orders })}`;
return `
{
return html`