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` `; @@ -66,30 +65,42 @@ const demoBanner = () => html` /* -------------------------------------------------------------------------- Shell -------------------------------------------------------------------------- */ +const languageSwitch = () => html` +
+ ${raw( + locales() + .map( + (code) => html`` + ) + .join("") + )} +
+`; + const shell = () => html` - - +
@@ -118,19 +128,20 @@ const shell = () => html`
+ data-i18n-attr="aria-label:shell.menu">${raw(icon("menu", { size: 18 }))}
-

Panel de control

+

+ ${raw(languageSwitch())} + data-i18n-attr="aria-label:shell.palette">${raw(icon("command", { size: 16 }))} + data-i18n-attr="aria-label:shell.theme">
@@ -142,7 +153,7 @@ const shell = () => html` `; /* -------------------------------------------------------------------------- - Indicador del pedido activo + Active-order indicator -------------------------------------------------------------------------- */ const renderActiveOrder = () => { const button = $("[data-active-order]"); @@ -159,61 +170,61 @@ const renderActiveOrder = () => { ${activeOrder.reference} ${fmtMoney(activeOrder.totalAmount)} `; - button.setAttribute("aria-label", `Abrir pedido activo ${activeOrder.reference}`); + button.setAttribute("aria-label", t("shell.activeOrder", { reference: activeOrder.reference })); }; /* -------------------------------------------------------------------------- - Paleta de comandos + Command palette -------------------------------------------------------------------------- */ const commands = () => [ ...NAV.map((item) => ({ - label: `Ir a ${item.label}`, + label: t("palette.goto", { name: t(item.labelKey) }), icon: item.icon, hint: item.path, run: () => navigate(item.path) })), { - label: "Ejecutar escenario guiado", + label: t("palette.scenario"), icon: "play", - hint: "Flujo completo de compra", + hint: t("palette.scenario.hint"), run: async () => { navigate("/panel"); - toast({ title: "Ejecutando escenario…", text: "Se está recorriendo el flujo completo.", variant: "info" }); + toast({ title: t("palette.scenario.running"), text: t("palette.scenario.runningText"), variant: "info" }); try { await api.demo.simulate(); window.dispatchEvent(new HashChangeEvent("hashchange")); } catch { - toast({ title: "El escenario falló", variant: "error" }); + toast({ title: t("palette.scenario.failed"), variant: "error" }); } } }, { - label: "Reiniciar datos de la demo", + label: t("palette.reset"), icon: "refresh", - hint: "Restaura el catálogo y pedidos iniciales", + hint: t("palette.reset.hint"), run: () => { resetDemoDatabase(); - toast({ title: "Datos reiniciados", text: "Catálogo y pedidos restaurados al estado inicial.", variant: "info" }); + toast({ title: t("palette.reset.done"), text: t("palette.reset.doneText"), variant: "info" }); setTimeout(() => window.location.reload(), 450); } }, { - label: isDemoMode() ? "Ver código en GitHub" : "Abrir documentación Swagger", + label: isDemoMode() ? t("palette.docs.code") : t("palette.docs.swagger"), icon: "book", - hint: isDemoMode() ? "Repositorio C#" : "/swagger", + hint: isDemoMode() ? t("palette.docs.codeHint") : "/swagger", run: () => window.open(isDemoMode() ? "https://github.com/Nain9Dev/NainOrder" : "/swagger", "_blank", "noopener") }, { - label: "Cambiar tema", + label: t("palette.theme"), icon: "sun", - hint: "Claro / oscuro", + hint: t("palette.theme.hint"), run: () => toggleTheme() }, ...(getState().activeOrder ? [{ - label: `Abrir pedido activo ${getState().activeOrder.reference}`, + label: t("palette.activeOrder", { reference: getState().activeOrder.reference }), icon: "cart", - hint: "Detalle y transiciones", + hint: t("palette.activeOrder.hint"), run: () => openOrderPanel(getState().activeOrderId) }] : []) @@ -230,9 +241,9 @@ const openPalette = () => { const overlay = document.createElement("div"); overlay.className = "overlay overlay--palette"; overlay.innerHTML = html` -
-

Ciclo de vida

+

${t("panel.lifecycle")}

${raw(lifecycle(order))}
-

Líneas del pedido

- ${fmtNumber(order.totalUnits)} unidades +

${t("panel.lines.title")}

+ ${t("panel.units", { count: fmtNumber(order.totalUnits) })}
${raw( @@ -129,8 +130,8 @@ const body = (order) => { ? html`
${raw(icon("cart", { size: 22 }))} -

Cesta vacía

-

Añade productos desde el catálogo para poder cobrar el pedido.

+

${t("panel.empty.title")}

+

${t("panel.empty.text")}

` : html`
-

Cliente

+

${t("panel.customer")}

${order.customerName ?? "—"}

-

${order.customerEmail ?? "Sin email registrado"}

+

${order.customerEmail ?? t("panel.noEmail")}

${raw( transitions ? html`
-

Acciones disponibles

-

Los botones salen de nextStates, que publica el - propio dominio: si una transición no está permitida, aquí no aparece.

+

${t("panel.actions.title")}

+

${raw(t("panel.actions.hint", { field: 'nextStates' }))}

${raw(transitions)}
` - : html`

Este pedido está en un estado final y no admite más transiciones.

` + : html`

${t("panel.actions.final")}

` )} `; }; @@ -177,14 +177,14 @@ export const openOrderPanel = async (orderId, { onChange } = {}) => { try { order = await api.orders.get(orderId); } catch (error) { - toastError(error, "No se pudo abrir el pedido"); + toastError(error, t("panel.openFailed")); // Un pedido activo que ya no existe (base efímera reiniciada) deja de estarlo. if (error.status === 404 && getState().activeOrderId === orderId) clearActiveOrder(); return; } const drawer = openDrawer({ - title: `Pedido ${order.reference}`, + title: t("panel.title", { reference: order.reference }), subtitle: fmtDateTime(order.createdAt), body: body(order) }); @@ -219,16 +219,16 @@ export const openOrderPanel = async (orderId, { onChange } = {}) => { const run = () => mutate(button, () => api.orders.transition(order.id, endpoint), endpoint === "cancel" - ? "Pedido cancelado y stock repuesto en el catálogo" - : "Transición aplicada"); + ? t("panel.toast.cancelled") + : t("panel.toast.transition")); if (endpoint !== "cancel") return run(); confirmAction({ - title: "Cancelar el pedido", - message: "Se devolverán al catálogo todas las unidades reservadas. Esta acción no se puede deshacer.", - confirmLabel: "Cancelar pedido", - cancelLabel: "Volver", + title: t("panel.cancel.title"), + message: t("panel.cancel.message"), + confirmLabel: t("transition.cancel"), + cancelLabel: t("panel.cancel.back"), danger: true }).then((confirmed) => confirmed && run()); }); @@ -246,7 +246,7 @@ export const openOrderPanel = async (orderId, { onChange } = {}) => { delegate(drawer.panel, "click", "[data-remove]", (event, button) => { const productId = button.closest("[data-product]").dataset.product; - mutate(button, () => api.orders.removeItem(order.id, productId), "Línea eliminada y stock repuesto"); + mutate(button, () => api.orders.removeItem(order.id, productId), t("panel.toast.removed")); }); return drawer; @@ -264,7 +264,7 @@ export const addProductToActiveOrder = async (product, quantity = 1) => { if (!orderId) { const customer = customers[0]; if (!customer) { - toast({ title: "No hay clientes", text: "Crea un cliente antes de abrir un pedido.", variant: "warning" }); + toast({ title: t("panel.noCustomers"), text: t("panel.noCustomersText"), variant: "warning" }); return null; } diff --git a/NainOrder.Api/wwwroot/assets/js/overlay.js b/NainOrder.Api/wwwroot/assets/js/overlay.js index 1a86d78..f85fb1b 100644 --- a/NainOrder.Api/wwwroot/assets/js/overlay.js +++ b/NainOrder.Api/wwwroot/assets/js/overlay.js @@ -5,6 +5,7 @@ */ import { html, icon, raw, $$ } from "./dom.js"; +import { t } from "./i18n.js"; const FOCUSABLE = 'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'; @@ -25,7 +26,7 @@ const openOverlay = (kind, { title, subtitle, body = "", footer = "", onMount, s ${subtitle ? raw(html`

${subtitle}

`) : ""}
+ aria-label="${t("overlay.close")}">${raw(icon("close", { size: 16 }))}
${raw(body)}
${footer ? raw(html``) : ""} @@ -101,8 +102,8 @@ export const openDrawer = (options) => openOverlay("drawer", options); export const confirmAction = ({ title, message, - confirmLabel = "Confirmar", - cancelLabel = "Cancelar", + confirmLabel = t("overlay.confirm"), + cancelLabel = t("overlay.cancel"), danger = false }) => new Promise((resolve) => { diff --git a/NainOrder.Api/wwwroot/assets/js/router.js b/NainOrder.Api/wwwroot/assets/js/router.js index d87cf9a..b21e2ec 100644 --- a/NainOrder.Api/wwwroot/assets/js/router.js +++ b/NainOrder.Api/wwwroot/assets/js/router.js @@ -18,7 +18,7 @@ export const onRouteChange = (handler) => { onChange = handler; }; -export const currentPath = () => window.location.hash.replace(/^#/, "") || "/panel"; +export const currentPath = () => window.location.hash.replace(/^#/, "") || "/drop"; export const navigate = (path) => { if (currentPath() === path) return; @@ -54,7 +54,7 @@ export const startRouter = (outlet) => { window.addEventListener("hashchange", run); - if (!window.location.hash) window.location.hash = "/panel"; + if (!window.location.hash) window.location.hash = "/drop"; run(); return run; diff --git a/NainOrder.Api/wwwroot/assets/js/toast.js b/NainOrder.Api/wwwroot/assets/js/toast.js index 7ecda97..a176658 100644 --- a/NainOrder.Api/wwwroot/assets/js/toast.js +++ b/NainOrder.Api/wwwroot/assets/js/toast.js @@ -2,6 +2,7 @@ import { html, icon, raw } from "./dom.js"; import { ApiError } from "./api.js"; +import { t } from "./i18n.js"; const VARIANT_ICONS = { success: "check", @@ -34,7 +35,7 @@ export const toast = ({ title, text = "", variant = "info", duration = 4600 }) =

${title}

${text ? raw(html`

${text}

`) : ""} - `; @@ -64,7 +65,7 @@ export const toastSuccess = (title, text) => toast({ title, text, variant: "succ * Traduce un fallo a un aviso legible. Los errores de negocio de la API ya llegan con * un mensaje pensado para el usuario; el resto se reduce a un texto genérico. */ -export const toastError = (error, fallbackTitle = "No se pudo completar la operación") => { +export const toastError = (error, fallbackTitle = t("toast.failed")) => { if (error instanceof ApiError) { const validation = error.validationErrors; const detail = validation @@ -75,5 +76,5 @@ export const toastError = (error, fallbackTitle = "No se pudo completar la opera } console.error(error); - return toast({ title: fallbackTitle, text: "Error inesperado en el cliente.", variant: "error" }); + return toast({ title: fallbackTitle, text: t("toast.unexpected"), variant: "error" }); }; diff --git a/NainOrder.Api/wwwroot/assets/js/views/architecture.js b/NainOrder.Api/wwwroot/assets/js/views/architecture.js index 88b292c..ec1f243 100644 --- a/NainOrder.Api/wwwroot/assets/js/views/architecture.js +++ b/NainOrder.Api/wwwroot/assets/js/views/architecture.js @@ -1,81 +1,51 @@ /** - * Vista de arquitectura: explica la estructura del proyecto y, sobre todo, la demuestra. - * La consola registra en vivo cada petición que hace la interfaz, así que el recorrido - * por las capas se ve en pantalla en lugar de quedarse en una afirmación del README. + * Architecture view: explains the project's structure and, above all, proves it. The console + * records every request the interface makes live, so the trip through the layers is seen on + * screen instead of staying a claim in the README. */ import { html, icon, raw, render, $, delegate } from "../dom.js"; import { api, onTrace, traceHistory, isDemoMode } from "../api.js"; import { toastError } from "../toast.js"; import { fmtDuration, statusLabel } from "../format.js"; +import { t } from "../i18n.js"; const LAYERS = [ { index: "01", name: "NainOrder.Domain", color: "var(--status-paid)", - text: "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.", - items: ["Order", "OrderItem", "Product", "Customer", "DomainException"], - dependsOn: "Sin dependencias" + textKey: "arch.layer.domain", + items: ["Order", "OrderItem", "Product", "Drop", "Hold", "DomainException"], + dependsOn: () => t("arch.dep.none") }, { index: "02", name: "NainOrder.Application", color: "var(--brand-1)", - text: "Casos de uso, DTOs y contratos de persistencia. Orquesta agregados y decide la " + - "frontera transaccional, pero no sabe qué motor hay debajo.", - items: ["OrderService", "ProductService", "IUnitOfWork", "IOrderRepository"], - dependsOn: "→ Domain" + textKey: "arch.layer.application", + items: ["OrderService", "DropService", "IUnitOfWork", "IOrderRepository"], + dependsOn: () => "→ Domain" }, { index: "03", name: "NainOrder.Infrastructure", color: "var(--status-processing)", - text: "EF Core, SQLite, configuración Fluent API y consultas de lectura. Implementa las " + - "interfaces que declara Application: la dependencia apunta hacia dentro.", - items: ["NainOrderDbContext", "UnitOfWork", "AnalyticsRepository", "MoneyConverter"], - dependsOn: "→ Application, Domain" + textKey: "arch.layer.infrastructure", + items: ["NainOrderDbContext", "UnitOfWork", "DropRepository", "MoneyConverter"], + dependsOn: () => "→ Application, Domain" }, { index: "04", name: "NainOrder.Api", color: "var(--status-shipped)", - text: "Controladores REST, contrato de errores RFC 7807, limitación de tráfico y esta " + - "misma interfaz servida como estáticos desde el mismo origen.", - items: ["OrdersController", "GlobalExceptionHandler", "wwwroot"], - dependsOn: "→ Application, Infrastructure" + textKey: "arch.layer.api", + items: ["DropsController", "GlobalExceptionHandler", "HoldExpirySweeper", "wwwroot"], + dependsOn: () => "→ Application, Infrastructure" } ]; -const DECISIONS = [ - { - title: "El dinero se guarda en céntimos enteros", - 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." - }, - { - title: "Concurrencia optimista sobre el stock", - 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." - }, - { - title: "Unidad de trabajo explícita", - 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." - }, - { - title: "Los totales del pedido se materializan", - 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." - }, - { - title: "Errores como contrato, no como texto", - 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." - } -]; +const DECISIONS = ["money", "concurrency", "uow", "totals", "errors", "holds"]; const traceRow = (entry) => html`
@@ -89,18 +59,15 @@ const traceRow = (entry) => html` const markup = (stateMachine) => html`
-

Arquitectura

-

- Cuatro proyectos con dependencias que apuntan siempre hacia el dominio. Debajo, la - traza real de las llamadas que esta interfaz hace contra la API. -

+

${t("arch.title")}

+

${t("arch.text")}

${raw(isDemoMode() ? html` - ${raw(icon("book", { size: 15 }))} Código en GitHub ${raw(icon("external", { size: 13 }))} + ${raw(icon("book", { size: 15 }))} ${t("arch.github")} ${raw(icon("external", { size: 13 }))} ` : html` - ${raw(icon("book", { size: 15 }))} Abrir Swagger ${raw(icon("external", { size: 13 }))} + ${raw(icon("book", { size: 15 }))} ${t("arch.swagger")} ${raw(icon("external", { size: 13 }))} `)}
@@ -111,11 +78,11 @@ const markup = (stateMachine) => html`
${layer.index}

${layer.name}

-

${layer.text}

+

${t(layer.textKey)}

${raw(layer.items.map((item) => `${item}`).join(""))}
-

${raw(icon("layers", { size: 12 }))} ${layer.dependsOn}

+

${raw(icon("layers", { size: 12 }))} ${layer.dependsOn()}

` ).join("") @@ -126,11 +93,11 @@ const markup = (stateMachine) => html`
-

Traza de red en vivo

-

Cada llamada que sale de esta interfaz, con su latencia real

+

${t("arch.trace.title")}

+

${t("arch.trace.hint")}

@@ -146,8 +113,8 @@ const markup = (stateMachine) => html`
-

Máquina de estados

-

Servida por /api/meta/order-state-machine

+

${t("arch.machine.title")}

+

${t("arch.machine.hint")} /api/meta/order-state-machine

@@ -160,7 +127,7 @@ const markup = (stateMachine) => html` ${statusLabel(state.name)} ${raw( state.isTerminal - ? html`estado final` + ? html`${t("arch.machine.terminal")}` : html`→ ${raw( state.transitions @@ -180,20 +147,20 @@ const markup = (stateMachine) => html`
-

Decisiones técnicas

-

Por qué el código es como es, no solo qué hace

+

${t("arch.decisions.title")}

+

${t("arch.decisions.hint")}

${raw( DECISIONS.map( - (decision, index) => html` + (key, index) => html`

- ${decision.title} + ${t(`arch.decision.${key}.title`)}

-

${decision.text}

+

${t(`arch.decision.${key}.text`)}

` ).join("") @@ -203,10 +170,10 @@ const markup = (stateMachine) => html` `; export const architectureView = { - title: "Arquitectura", - subtitle: "Clean Architecture verificable, no declarativa", + get title() { return t("arch.title"); }, + get subtitle() { return t("arch.subtitle"); }, - /** Baja de la traza al abandonar la vista, para no dejar suscripciones vivas. */ + /** Unsubscribes from the trace when leaving the view, so no subscription stays alive. */ destroy() { this._unsubscribe?.(); this._unsubscribe = null; @@ -221,7 +188,7 @@ export const architectureView = { try { stateMachine = await api.meta.stateMachine(); } catch (error) { - toastError(error, "No se pudo leer la máquina de estados"); + toastError(error, t("arch.failed")); return; } @@ -230,7 +197,7 @@ export const architectureView = { const appendTrace = (entry) => { consoleNode.insertAdjacentHTML("beforeend", traceRow(entry)); - // Se conserva una ventana corta: la consola es un monitor, no un registro histórico. + // A short window is kept: the console is a monitor, not a historical log. while (consoleNode.children.length > 60) consoleNode.firstElementChild.remove(); consoleNode.scrollTop = consoleNode.scrollHeight; }; diff --git a/NainOrder.Api/wwwroot/assets/js/views/catalog.js b/NainOrder.Api/wwwroot/assets/js/views/catalog.js index a61c8b0..7a6060a 100644 --- a/NainOrder.Api/wwwroot/assets/js/views/catalog.js +++ b/NainOrder.Api/wwwroot/assets/js/views/catalog.js @@ -6,6 +6,7 @@ import { openModal, withBusy } from "../overlay.js"; import { toastError, toastSuccess, toast } from "../toast.js"; import { addProductToActiveOrder, openOrderPanel } from "../order-panel.js"; import { getState } from "../state.js"; +import { t } from "../i18n.js"; import { fmtMoney, fmtNumber, stockLabel } from "../format.js"; const filters = { search: "", category: "" }; @@ -23,7 +24,7 @@ const productCard = (product, index) => html` ${fmtNumber(product.stockQuantity)}
-

${product.description || "Sin descripción."}

+

${product.description || t("catalog.noDescription")}

${product.category} @@ -34,12 +35,12 @@ const productCard = (product, index) => html` ${fmtMoney(product.price)}
+ aria-label="${t("catalog.edit", { name: product.name })}">${raw(icon("cog", { size: 14 }))} + aria-label="${t("catalog.restock", { name: product.name })}">${raw(icon("plus", { size: 14 }))}
@@ -49,29 +50,28 @@ const productCard = (product, index) => html` const markup = (products, categories) => html`
-

Catálogo

+

${t("catalog.title")}

- El stock lo gobierna la entidad Product: ninguna capa superior - puede dejarlo en negativo, ni siquiera saltándose el servicio. + ${raw(t("catalog.intro", { entity: 'Product' }))}

+ aria-pressed="${filters.category === ""}">${t("catalog.all")} ${raw( categories .map( @@ -85,7 +85,7 @@ const markup = (products, categories) => html`
- ${fmtNumber(products.length)} referencias + ${t("catalog.count", { count: fmtNumber(products.length) })}
@@ -96,9 +96,9 @@ const markup = (products, categories) => html`
` : html`
${raw(icon("catalog", { size: 22 }))} -

Sin resultados

-

Ningún producto coincide con el filtro aplicado.

- +

${t("catalog.empty.title")}

+

${t("catalog.empty.text")}

+
` )} `; @@ -112,19 +112,19 @@ const productForm = (product) => html` SKU - Identificador único. Se normaliza a mayúsculas. + ${t("catalog.form.skuHint")} ` )}
@@ -133,23 +133,23 @@ const productForm = (product) => html` product ? "" : html`` )}
@@ -169,8 +169,8 @@ const openProductModal = ({ product, categories, onSaved }) => { const isEdit = Boolean(product); openModal({ - title: isEdit ? "Editar producto" : "Nuevo producto", - subtitle: isEdit ? product.sku : "Alta en el catálogo", + title: isEdit ? t("catalog.modal.editTitle") : t("catalog.new"), + subtitle: isEdit ? product.sku : t("catalog.modal.createSubtitle"), body: html` ${raw(productForm(product))} @@ -178,10 +178,10 @@ const openProductModal = ({ product, categories, onSaved }) => { `, footer: html` - + `, onMount: ({ panel, close }) => { @@ -200,7 +200,7 @@ const openProductModal = ({ product, categories, onSaved }) => { const saved = await withBusy(submit, () => isEdit ? api.products.update(product.id, payload) : api.products.create(payload)); - toastSuccess(isEdit ? "Producto actualizado" : "Producto creado", saved.sku); + toastSuccess(isEdit ? t("catalog.toast.updated") : t("catalog.toast.created"), saved.sku); close(); onSaved(); } catch (error) { @@ -208,7 +208,7 @@ const openProductModal = ({ product, categories, onSaved }) => { errorNode.textContent = error instanceof ApiError ? Object.values(error.validationErrors ?? {}).flat().join(" ") || error.message - : "Error inesperado al guardar."; + : t("catalog.form.unexpectedSave"); errorNode.hidden = false; } }); @@ -218,26 +218,26 @@ const openProductModal = ({ product, categories, onSaved }) => { const openRestockModal = ({ product, onSaved }) => openModal({ - title: "Ajustar stock", - subtitle: `${product.name} · ${fmtNumber(product.stockQuantity)} unidades actuales`, + title: t("catalog.stock.title"), + subtitle: t("catalog.stock.subtitle", { name: product.name, count: fmtNumber(product.stockQuantity) }), size: 440, body: html`
`, footer: html` - + - + `, onMount: ({ panel, close }) => { const form = $("#stock-form", panel); @@ -250,18 +250,21 @@ const openRestockModal = ({ product, onSaved }) => const delta = Number(new FormData(form).get("delta")); if (!Number.isFinite(delta) || delta === 0) { - errorNode.textContent = "Introduce una variación distinta de cero."; + errorNode.textContent = t("catalog.stock.nonZero"); errorNode.hidden = false; return; } try { const saved = await withBusy(submit, () => api.products.adjustStock(product.id, delta)); - toastSuccess("Stock actualizado", `${saved.name}: ${fmtNumber(saved.stockQuantity)} unidades`); + toastSuccess( + t("catalog.stock.updated"), + t("catalog.stock.updatedText", { name: saved.name, count: fmtNumber(saved.stockQuantity) }) + ); close(); onSaved(); } catch (error) { - errorNode.textContent = error instanceof ApiError ? error.message : "Error inesperado."; + errorNode.textContent = error instanceof ApiError ? error.message : t("catalog.stock.unexpected"); errorNode.hidden = false; } }); @@ -269,8 +272,8 @@ const openRestockModal = ({ product, onSaved }) => }); export const catalogView = { - title: "Catálogo", - subtitle: "Referencias, precios y stock disponible", + get title() { return t("catalog.title"); }, + get subtitle() { return t("catalog.subtitle"); }, async render(outlet) { if (!outlet.firstChild) { @@ -288,7 +291,7 @@ export const catalogView = { api.products.categories() ]); } catch (error) { - toastError(error, "No se pudo cargar el catálogo"); + toastError(error, t("catalog.loadFailed")); return; } @@ -334,7 +337,7 @@ export const catalogView = { if (!order) return; toast({ - title: "Añadido al pedido", + title: t("catalog.toast.added"), text: `${product.name} · ${order.reference} · ${fmtMoney(order.totalAmount)}`, variant: "success" }); @@ -344,15 +347,15 @@ export const catalogView = { // Duplicar una línea es una regla del dominio, no un fallo: se sugiere la alternativa. if (error instanceof ApiError && error.code === "duplicate_order_item") { toast({ - title: "Ya está en el pedido", - text: "Ajusta la cantidad desde el detalle del pedido.", + title: t("catalog.toast.duplicate"), + text: t("catalog.toast.duplicateText"), variant: "warning" }); openOrderPanel(getState().activeOrderId, { onChange: reload }); return; } - toastError(error, "No se pudo añadir al pedido"); + toastError(error, t("catalog.addFailed")); reload(); } }); diff --git a/NainOrder.Api/wwwroot/assets/js/views/dashboard.js b/NainOrder.Api/wwwroot/assets/js/views/dashboard.js index 80399d2..9bc4912 100644 --- a/NainOrder.Api/wwwroot/assets/js/views/dashboard.js +++ b/NainOrder.Api/wwwroot/assets/js/views/dashboard.js @@ -6,6 +6,7 @@ import { areaChart, distributionBars } from "../charts.js"; import { openModal, withBusy } from "../overlay.js"; import { toastError } from "../toast.js"; import { openOrderPanel } from "../order-panel.js"; +import { t } from "../i18n.js"; import { fmtMoney, fmtMoneyCompact, fmtNumber, fmtRelative, fmtDuration, statusLabel } from "../format.js"; @@ -38,8 +39,11 @@ const kpiCard = ({ modifier, label, iconName, value, meta, rawValue, format }) = const simulationModal = (result) => openModal({ - title: "Escenario guiado ejecutado", - subtitle: `${result.steps.length} operaciones en ${fmtDuration(result.elapsedMilliseconds)}`, + title: t("dash.sim.title"), + subtitle: t("dash.sim.subtitle", { + count: result.steps.length, + duration: fmtDuration(result.elapsedMilliseconds) + }), size: 620, body: html`

${result.message}

@@ -64,16 +68,16 @@ const simulationModal = (result) =>
-
Pedido resultante${result.finalOrder.reference}
-
Estado final${statusLabel(result.finalOrder.status)}
-
Stock restante del producto${fmtNumber(result.finalProduct.stockQuantity)} uds.
-
Importe${fmtMoney(result.finalOrder.totalAmount)}
+
${t("dash.sim.order")}${result.finalOrder.reference}
+
${t("dash.sim.status")}${statusLabel(result.finalOrder.status)}
+
${t("dash.sim.stock")}${t("dash.units", { count: fmtNumber(result.finalProduct.stockQuantity) })}
+
${t("dash.sim.amount")}${fmtMoney(result.finalOrder.totalAmount)}
`, footer: html` - Todas las reglas las ha validado el dominio, no el controlador. + ${t("dash.sim.hint")} - + ` }); @@ -90,19 +94,17 @@ const markup = (stats, orders) => { return html`
-

Panel de control

+

${t("dash.title")}

- ${isDemoMode() - ? "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." - : "Métricas agregadas en base de datos sobre importes almacenados en céntimos enteros, no calculadas en memoria."} + ${isDemoMode() ? t("dash.intro.demo") : t("dash.intro.live")}

@@ -112,41 +114,41 @@ const markup = (stats, orders) => { [ kpiCard({ modifier: "kpi--revenue", - label: "Ingresos confirmados", + label: t("dash.kpi.revenue"), iconName: "euro", value: fmtMoneyCompact(stats.confirmedRevenue), rawValue: stats.confirmedRevenue, format: "money", - meta: `Ticket medio ${fmtMoney(stats.averageOrderValue)}` + meta: t("dash.kpi.average", { amount: fmtMoney(stats.averageOrderValue) }) }), kpiCard({ modifier: "kpi--pending", - label: "Pendiente de cobro", + label: t("dash.kpi.pending"), iconName: "clock", value: fmtMoneyCompact(stats.pendingRevenue), rawValue: stats.pendingRevenue, format: "money", - meta: `${fmtNumber(stats.totalOrders)} pedidos en total` + meta: t("dash.kpi.orders", { count: fmtNumber(stats.totalOrders) }) }), kpiCard({ modifier: "kpi--orders", - label: "Clientes", + label: t("dash.kpi.customers"), iconName: "users", value: fmtNumber(stats.totalCustomers), rawValue: stats.totalCustomers, format: "number", - meta: `${fmtNumber(stats.totalProducts)} referencias en catálogo` + meta: t("dash.kpi.products", { count: fmtNumber(stats.totalProducts) }) }), kpiCard({ modifier: "kpi--stock", - label: "Unidades en stock", + label: t("dash.kpi.stock"), iconName: "boxes", value: fmtNumber(stats.totalStockUnits), rawValue: stats.totalStockUnits, format: "number", meta: stats.lowStockProducts > 0 - ? `${stats.lowStockProducts} referencias bajo mínimos` - : "Sin referencias bajo mínimos" + ? t("dash.kpi.low", { count: stats.lowStockProducts }) + : t("dash.kpi.noLow") }) ].join("") )} @@ -156,8 +158,8 @@ const markup = (stats, orders) => {
-

Ingresos confirmados

-

Últimos 14 días · pedidos pagados, en preparación o enviados

+

${t("dash.kpi.revenue")}

+

${t("dash.revenue.hint")}

${raw(icon("trending", { size: 13 }))}
@@ -167,8 +169,8 @@ const markup = (stats, orders) => {
-

Distribución por estado

-

Todos los estados de la máquina, incluidos los vacíos

+

${t("dash.dist.title")}

+

${t("dash.dist.hint")}

${raw(distributionBars(distribution))} @@ -179,8 +181,8 @@ const markup = (stats, orders) => {
-

Pedidos recientes

-

Pulsa una fila para abrir el detalle y operar sobre el pedido

+

${t("dash.recent.title")}

+

${t("dash.recent.hint")}

@@ -190,8 +192,8 @@ const markup = (stats, orders) => { - - + + @@ -215,8 +217,8 @@ const markup = (stats, orders) => { ` : html`
${raw(icon("orders", { size: 22 }))} -

Todavía no hay pedidos

-

Ejecuta el escenario guiado o añade productos del catálogo a un pedido nuevo.

+

${t("dash.empty.title")}

+

${t("dash.empty.text")}

` )} @@ -224,8 +226,8 @@ const markup = (stats, orders) => {
-

Más vendidos

-

Por unidades, excluyendo pedidos cancelados

+

${t("dash.top.title")}

+

${t("dash.top.hint")}

@@ -240,7 +242,7 @@ const markup = (stats, orders) => { ${index + 1}

${product.name}

-

${product.sku} · ${product.unitsSold} uds.

+

${product.sku} · ${t("dash.units", { count: product.unitsSold })}

${fmtMoney(product.revenue)} @@ -251,7 +253,7 @@ const markup = (stats, orders) => { ` : html`
${raw(icon("trending", { size: 22 }))} -

Sin ventas registradas todavía.

+

${t("dash.top.empty")}

` )}
@@ -268,8 +270,8 @@ const animateCounters = (container) => { }; export const dashboardView = { - title: "Panel de control", - subtitle: "Estado del motor de pedidos en tiempo real", + get title() { return t("dash.title"); }, + get subtitle() { return t("dash.subtitle"); }, async render(outlet) { render(outlet, skeleton()); @@ -283,13 +285,13 @@ export const dashboardView = { api.orders.list({ page: 1, pageSize: 6 }) ]); } catch (error) { - toastError(error, "No se pudieron cargar las métricas"); + toastError(error, t("dash.loadFailed")); const errorRoot = render(outlet, html`
${raw(icon("alert", { size: 22 }))} -

No hay datos disponibles

-

La API no respondió correctamente. Comprueba que el servicio está en marcha.

- +

${t("dash.noData.title")}

+

${t("dash.noData.text")}

+
`); delegate(errorRoot, "click", '[data-action="refresh"]', () => this.render(outlet)); @@ -308,7 +310,7 @@ export const dashboardView = { // El escenario altera stock y pedidos: el panel deja de ser válido. await this.render(outlet); } catch (error) { - toastError(error, "El escenario guiado falló"); + toastError(error, t("dash.simFailed")); } }); diff --git a/NainOrder.Api/wwwroot/assets/js/views/drop.js b/NainOrder.Api/wwwroot/assets/js/views/drop.js index 4b79978..6428217 100644 --- a/NainOrder.Api/wwwroot/assets/js/views/drop.js +++ b/NainOrder.Api/wwwroot/assets/js/views/drop.js @@ -1,7 +1,8 @@ /** - * Drop: venta de unidades limitadas a una multitud. La vista pinta lo que devuelve la API - * (disponibles, reservadas, vendidas, estado) y no calcula ninguna regla: quién puede comprar, - * cuándo caduca una reserva y si el drop está agotado lo decide el servidor. + * Drop: a limited sale to a crowd, seen from two sides — the buyer and the organizer. + * The view draws what the API returns (available, held, sold, status, next expiry) and computes + * no rule: who may buy, when a hold expires and whether the drop is sold out are decided by the + * server. With no drop open, a short wordless animation explains the product. */ import { html, icon, raw, render, $, delegate } from "../dom.js"; @@ -9,34 +10,39 @@ import { api, ApiError, isDemoMode } from "../api.js"; import { toast, toastError } from "../toast.js"; import { withBusy } from "../overlay.js"; import { fmtMoney, fmtNumber } from "../format.js"; +import { t } from "../i18n.js"; const LAST_DROP_KEY = "nainorder.drop"; +const ROLE_KEY = "nainorder.role"; const POLL_MS = 1000; const CROWD = 40; +const WINDOW_MS = 60_000; -const STATUS_LABEL = { Scheduled: "Programado", Live: "En vivo", SoldOut: "Agotado" }; +/** Defaults for a drop started from the landing: nothing to configure to see it work. */ +const QUICK_DROP = { totalUnits: 48, perBuyerLimit: 4, holdSeconds: 30, opensInSeconds: 0 }; const MESSAGES = { - drop_not_live: "El drop aún no ha abierto", - buyer_limit_reached: "Ya tienes el máximo permitido", - insufficient_stock: "Agotado: otra persona fue más rápida", - concurrency_conflict: "Mucha demanda, inténtalo otra vez", - hold_expired: "Tu reserva caducó" + drop_not_live: "msg.drop_not_live", + buyer_limit_reached: "msg.buyer_limit_reached", + insufficient_stock: "msg.insufficient_stock", + concurrency_conflict: "msg.concurrency_conflict", + hold_expired: "msg.hold_expired" }; -const remember = (id) => { - try { - id ? localStorage.setItem(LAST_DROP_KEY, id) : localStorage.removeItem(LAST_DROP_KEY); - } catch { - /* El almacenamiento puede estar bloqueado: se pierde solo el atajo. */ - } -}; - -const lastDropId = () => { - try { - return localStorage.getItem(LAST_DROP_KEY); - } catch { - return null; +const store = { + get(key) { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, + set(key, value) { + try { + value === null ? localStorage.removeItem(key) : localStorage.setItem(key, value); + } catch { + /* Storage may be blocked: only the shortcut to the last drop is lost. */ + } } }; @@ -45,46 +51,94 @@ const clock = (milliseconds) => { return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; }; -const slider = (name, label, min, max, value, unit = "") => html` - -`; +/* -------------------------------------------------------------------------- + Landing: a wordless story of what the engine does + -------------------------------------------------------------------------- */ +const COLUMNS = 6; +const ROWS = 4; +const CELL = 30; +const GAP = 8; +const GRID_X = 330; +const GRID_Y = 34; + +const story = () => { + const cells = []; + const dots = []; + const crowd = []; + + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLUMNS; col++) { + const index = row * COLUMNS + col; + const x = GRID_X + col * (CELL + GAP); + const y = GRID_Y + row * (CELL + GAP); + // Every third unit is held by someone who never confirms: it comes back, then sells. + const kind = index % 3 === 0 ? "back" : "sold"; + cells.push(``); + + // A buyer starts on the left of the scene and walks to the unit it will hold. A faint + // copy stays behind so the crowd is still there once its members have walked away. + const startX = 24 + ((index * 37) % 120); + const startY = 30 + ((index * 53) % 150); + crowd.push(``); + dots.push( + `` + ); + } + } + + const captions = ["arrive", "hold", "expire", "sold"] + .map((key, index) => html`${t(`story.${key}`)}`) + .join(""); -const launcher = (products, recent) => html` -
-
- - ${raw(slider("units", "Unidades", 1, 200, 24))} - ${raw(slider("perBuyer", "Máximo por persona", 1, 10, 2))} - ${raw(slider("hold", "Reserva", 10, 120, 30, " s"))} - +
+ ${raw( recent.length - ? html`
${raw( + ? html`
${raw( recent .map( (d) => html`` ) .join("") )}
` : "" )} -
+ `; -const stage = () => html` +/* -------------------------------------------------------------------------- + The open drop + -------------------------------------------------------------------------- */ +const stage = (role) => html`
@@ -95,34 +149,69 @@ const stage = () => html`
+
+ + +
-
- -
+
+
+`; -
- - - +const buyerLens = () => html` +
+
+ + ${t("drop.stat.available")} +
+ + +
+`; + +const organizerLens = () => html` +
+
+ +
+ +
+
+

${t("drop.org.pace")}

+ +
+
+

${t("drop.org.next")}

+

+
+
+ +
+ +
    -
  • disponible
  • -
  • reservada
  • -
  • vendida
  • +
  • ${t("drop.legend.available")}
  • +
  • ${t("drop.legend.held")}
  • +
  • ${t("drop.legend.sold")}
- +
`; const cellsFor = (drop, mine) => { @@ -140,9 +229,23 @@ const cellsFor = (drop, mine) => { .join(""); }; +/** Plots the sold count the server reported over the last minute. It applies no rule. */ +const sparkPoints = (samples, total, now) => { + const visible = samples.filter((s) => now - s.at <= WINDOW_MS); + if (visible.length < 2) return { line: "", area: "" }; + + const points = visible.map((s) => { + const x = 200 - ((now - s.at) / WINDOW_MS) * 200; + const y = 46 - (s.sold / Math.max(total, 1)) * 44; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + + return { line: points.join(" "), area: `${points[0].split(",")[0]},48 ${points.join(" ")} 200,48` }; +}; + export const dropView = { - title: "Drop", - subtitle: "Unidades limitadas, una multitud, cero sobreventa", + get title() { return t("drop.title"); }, + get subtitle() { return t("drop.subtitle"); }, destroy() { clearInterval(this._poll); @@ -153,18 +256,12 @@ export const dropView = { async render(outlet) { this.destroy(); + // The top bar already names the view; repeating it here would only add text. const header = html` -
-
-

Drop

-

Lanza una venta limitada y suelta una multitud sobre ella.

-
-
${raw(isDemoMode() ? html`
${raw(icon("info", { size: 15 }))} -

Aquí corre en el navegador. Las colisiones reales - entre compradores simultáneos se ven con la API .NET en marcha.

+

${t("drop.demo.title")} ${t("drop.demo.text")}

` : "")} `; @@ -172,99 +269,142 @@ export const dropView = { const root = render(outlet, html`${raw(header)}
`); const body = $("[data-body]", root); - const showLauncher = async () => { - remember(null); + const showLanding = async () => { + store.set(LAST_DROP_KEY, null); this.destroy(); - let products = []; let recent = []; try { - [products, recent] = await Promise.all([api.products.list(), api.drops.list()]); - } catch (error) { - toastError(error, "No se pudo cargar el catálogo"); - return; + recent = await api.drops.list(); + } catch { + /* The story and the two buttons work without the list of recent drops. */ } - body.innerHTML = launcher(products, recent); + body.innerHTML = landing(recent); }; - const openDrop = async (id) => { + const openDrop = async (id, preferredRole) => { let drop; try { drop = await api.drops.get(id); } catch { - await showLauncher(); + await showLanding(); return; } - remember(id); - body.innerHTML = stage(); + this.destroy(); + store.set(LAST_DROP_KEY, id); + + let role = preferredRole ?? store.get(ROLE_KEY) ?? "buyer"; + body.innerHTML = stage(role); const stageNode = $("[data-stage]", body); + const lens = $("[data-lens]", stageNode); + let current = drop; let skew = Date.parse(drop.serverTime) - Date.now(); let busy = false; + const samples = []; - const cell = drop.totalUnits <= 48 ? 44 : drop.totalUnits <= 200 ? 26 : 14; - $("[data-grid]", stageNode).style.setProperty("--cell", `${cell}px`); $("[data-name]", stageNode).textContent = drop.productName; $("[data-price]", stageNode).textContent = fmtMoney(drop.unitPrice); - const paint = () => { - const mine = current.myHolds.find((h) => h.state === "Active"); - const now = Date.now() + skew; - - $("[data-grid]", stageNode).innerHTML = cellsFor(current, mine?.quantity ?? 0); + const now = () => Date.now() + skew; + const myActiveHold = () => current.myHolds.find((h) => h.state === "Active"); + const paintHeader = () => { const badge = $("[data-badge]", stageNode); - badge.textContent = STATUS_LABEL[current.status] ?? current.status; + badge.textContent = t(`drop.status.${current.status}`); badge.dataset.status = { Scheduled: "PendingPayment", Live: "Paid", SoldOut: "Cancelled" }[current.status]; - $("[data-stats]", stageNode).innerHTML = html` -
${fmtNumber(current.available)}disponibles
-
${fmtNumber(current.held)}reservadas
-
${fmtNumber(current.sold)}vendidas
- `; + $("[data-clock]", stageNode).textContent = + current.status === "Scheduled" ? t("drop.opensIn", { time: clock(Date.parse(current.opensAt) - now()) }) : ""; + }; - $('[data-action="take"]', stageNode).disabled = busy || current.status !== "Live"; + const paintBuyer = () => { + $("[data-available]", lens).textContent = fmtNumber(current.available); + $("[data-scarcity]", lens).style.width = `${(current.available / Math.max(current.totalUnits, 1)) * 100}%`; + $('[data-action="take"]', lens).disabled = busy || current.status !== "Live"; - const holdNode = $("[data-hold]", stageNode); + const mine = myActiveHold(); + const holdNode = $("[data-hold]", lens); holdNode.hidden = !mine; if (mine) { - const left = Date.parse(mine.expiresAt) - now; - $("[data-hold-clock]", stageNode).textContent = clock(left); - $("[data-hold-bar]", stageNode).style.width = `${Math.max(0, Math.min(100, (left / (current.holdSeconds * 1000)) * 100))}%`; + const left = Date.parse(mine.expiresAt) - now(); + $("[data-hold-clock]", lens).textContent = clock(left); + $("[data-hold-bar]", lens).style.width = `${Math.max(0, Math.min(100, (left / (current.holdSeconds * 1000)) * 100))}%`; } + }; + + const paintOrganizer = () => { + const grid = $("[data-grid]", lens); + const cell = current.totalUnits <= 48 ? 36 : current.totalUnits <= 200 ? 24 : 14; + grid.style.setProperty("--cell", `${cell}px`); + grid.innerHTML = cellsFor(current, myActiveHold()?.quantity ?? 0); + + $("[data-stats]", lens).innerHTML = html` +
${fmtNumber(current.available)}${t("drop.stat.available")}
+
${fmtNumber(current.held)}${t("drop.stat.held")}
+
${fmtNumber(current.sold)}${t("drop.stat.sold")}
+ `; + + const { line, area } = sparkPoints(samples, current.totalUnits, Date.now()); + $("[data-spark-line]", lens).setAttribute("points", line); + $("[data-spark-area]", lens).setAttribute("points", area); + + $("[data-next]", lens).textContent = current.nextHoldExpiresAt + ? clock(Date.parse(current.nextHoldExpiresAt) - now()) + : t("drop.org.none"); + }; + + const paint = () => { + paintHeader(); + if (role === "buyer") paintBuyer(); + else paintOrganizer(); + }; - const clockNode = $("[data-clock]", stageNode); - clockNode.textContent = current.status === "Scheduled" ? `Abre en ${clock(Date.parse(current.opensAt) - now)}` : ""; + const showLens = () => { + lens.innerHTML = role === "buyer" ? buyerLens() : organizerLens(); + stageNode.querySelectorAll("[data-role]").forEach((button) => { + button.setAttribute("aria-pressed", String(button.dataset.role === role)); + }); + paint(); }; const refresh = async () => { try { current = await api.drops.get(id); skew = Date.parse(current.serverTime) - Date.now(); + samples.push({ at: Date.now(), sold: current.sold }); + if (samples.length > 240) samples.shift(); paint(); } catch { - /* Un fallo puntual de red se corrige en el siguiente ciclo. */ + /* A one-off network failure is corrected on the next cycle. */ } }; const say = (error, fallback) => { const code = error instanceof ApiError ? error.code : null; - if (MESSAGES[code]) toast({ title: MESSAGES[code], variant: "error" }); + if (MESSAGES[code]) toast({ title: t(MESSAGES[code]), variant: "error" }); else toastError(error, fallback); }; - paint(); + samples.push({ at: Date.now(), sold: current.sold }); + showLens(); this._poll = setInterval(refresh, POLL_MS); this._tick = setInterval(paint, 250); + delegate(stageNode, "click", "[data-role]", (event, button) => { + role = button.dataset.role; + store.set(ROLE_KEY, role); + showLens(); + }); + delegate(stageNode, "click", '[data-action="take"]', async (event, button) => { try { busy = true; await withBusy(button, () => api.drops.take(id, 1)); } catch (error) { - say(error, "No se pudo reservar"); + say(error, t("drop.takeFailed")); } finally { busy = false; await refresh(); @@ -272,21 +412,25 @@ export const dropView = { }); delegate(stageNode, "click", '[data-action="confirm"]', async (event, button) => { - const mine = current.myHolds.find((h) => h.state === "Active"); + const mine = myActiveHold(); if (!mine) return; try { const order = await withBusy(button, () => api.drops.confirm(id, mine.id)); - toast({ title: "Compra confirmada", text: `Pedido ${order.reference} · ${fmtMoney(order.totalAmount)}`, variant: "success" }); + toast({ + title: t("drop.bought"), + text: t("drop.boughtText", { reference: order.reference, total: fmtMoney(order.totalAmount) }), + variant: "success" + }); } catch (error) { - say(error, "No se pudo confirmar"); + say(error, t("drop.confirmFailed")); } finally { await refresh(); } }); delegate(stageNode, "click", '[data-action="crowd"]', async (event, button) => { - // Cada comprador es un visitante distinto; las colisiones de escritura se reintentan - // como haría cualquier cliente, y lo que sobra recibe "agotado". + // Every buyer is a different visitor; write collisions are retried as any client would, + // and whoever is left gets "sold out". const burst = setInterval(refresh, 150); try { await withBusy(button, () => @@ -310,44 +454,30 @@ export const dropView = { } }); - delegate(stageNode, "click", '[data-action="reset"]', showLauncher); + delegate(stageNode, "click", '[data-action="reset"]', showLanding); }; - delegate(root, "input", "[data-input]", (event, input) => { - const out = $(`[data-out="${input.dataset.input}"]`, root); - if (out) out.textContent = input.value; - if (input.name === "units") { - const per = $('[data-input="perBuyer"]', root); - per.max = Math.min(10, Number(input.value)); - if (Number(per.value) > Number(per.max)) { - per.value = per.max; - $('[data-out="perBuyer"]', root).textContent = per.max; - } - } - }); - - delegate(root, "click", '[data-action="launch"]', async (event, button) => { - const value = (name) => $(`[data-input="${name}"]`, root).value; + /** Starts a drop for the chosen role with sensible defaults: nothing to configure. */ + const startAs = async (role, button) => { try { - const drop = await withBusy(button, () => - api.drops.create({ - productId: value("product"), - totalUnits: Number(value("units")), - perBuyerLimit: Number(value("perBuyer")), - holdSeconds: Number(value("hold")), - opensInSeconds: 0 - }) - ); - await openDrop(drop.id); + const drop = await withBusy(button, async () => { + const products = await api.products.list(); + const product = products[Math.floor(Math.random() * products.length)]; + return api.drops.create({ productId: product.id, ...QUICK_DROP }); + }); + + store.set(ROLE_KEY, role); + await openDrop(drop.id, role); } catch (error) { - toastError(error, "No se pudo lanzar el drop"); + toastError(error, t("launch.failed")); } - }); + }; + delegate(root, "click", "[data-start]", (event, button) => startAs(button.dataset.start, button)); delegate(root, "click", "[data-open]", (event, node) => openDrop(node.dataset.open)); - const last = lastDropId(); + const last = store.get(LAST_DROP_KEY); if (last) await openDrop(last); - else await showLauncher(); + else await showLanding(); } }; diff --git a/NainOrder.Api/wwwroot/assets/js/views/orders.js b/NainOrder.Api/wwwroot/assets/js/views/orders.js index f7ab012..cf45c52 100644 --- a/NainOrder.Api/wwwroot/assets/js/views/orders.js +++ b/NainOrder.Api/wwwroot/assets/js/views/orders.js @@ -5,6 +5,7 @@ import { api } from "../api.js"; import { toastError, toastSuccess } from "../toast.js"; import { openOrderPanel } from "../order-panel.js"; import { getState, setActiveOrder } from "../state.js"; +import { t } from "../i18n.js"; import { fmtMoney, fmtNumber, fmtRelative, fmtDateTime, statusLabel, STATUS_LABELS } from "../format.js"; const query = { status: "", search: "", customerId: "", page: 1, pageSize: 12 }; @@ -12,30 +13,29 @@ const query = { status: "", search: "", customerId: "", page: 1, pageSize: 12 }; const markup = (result, customers) => html`
-

Pedidos

+

${t("orders.title")}

- 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. + ${t("orders.intro")}

- ${fmtNumber(result.totalCount)} pedidos + ${t("orders.count", { count: fmtNumber(result.totalCount) })}
@@ -71,9 +71,9 @@ const markup = (result, customers) => html`
ReferenciaClienteEstadoTotalCreado${t("dash.col.reference")}${t("dash.col.customer")}${t("dash.col.status")}${t("dash.col.total")}${t("dash.col.created")}
- - - + + + @@ -101,31 +101,31 @@ const markup = (result, customers) => html` ` : html`
${raw(icon("orders", { size: 22 }))} -

No hay pedidos que mostrar

-

Ajusta los filtros o abre un pedido nuevo para empezar.

- +

${t("orders.empty.title")}

+

${t("orders.empty.text")}

+
` )} `; export const ordersView = { - title: "Pedidos", - subtitle: "Ciclo de vida completo, del carrito al envío", + get title() { return t("orders.title"); }, + get subtitle() { return t("orders.subtitle"); }, async render(outlet) { if (!outlet.firstChild) { @@ -138,7 +138,7 @@ export const ordersView = { try { [result, customers] = await Promise.all([api.orders.list(query), api.customers.list()]); } catch (error) { - toastError(error, "No se pudieron cargar los pedidos"); + toastError(error, t("orders.loadFailed")); return; } @@ -175,18 +175,18 @@ export const ordersView = { delegate(root, "click", '[data-action="new-order"]', async () => { const customer = getState().customers[0] ?? customers[0]; if (!customer) { - toastError(new Error("sin clientes"), "No hay clientes disponibles"); + toastError(new Error("sin clientes"), t("orders.noCustomers")); return; } try { const order = await api.orders.create(customer.id); setActiveOrder(order); - toastSuccess("Pedido abierto", `${order.reference} · ${customer.name}`); + toastSuccess(t("orders.opened"), `${order.reference} · ${customer.name}`); await reload(); openOrderPanel(order.id, { onChange: reload }); } catch (error) { - toastError(error, "No se pudo abrir el pedido"); + toastError(error, t("orders.openFailed")); } }); diff --git a/NainOrder.Api/wwwroot/assets/js/views/race.js b/NainOrder.Api/wwwroot/assets/js/views/race.js index 74a49a8..7f02b9e 100644 --- a/NainOrder.Api/wwwroot/assets/js/views/race.js +++ b/NainOrder.Api/wwwroot/assets/js/views/race.js @@ -1,7 +1,7 @@ /** - * Carrera de stock: la garantía central del proyecto, ejecutada a la vista. - * N compradores compiten por M unidades a la vez; el panel pinta lo que la API devuelve - * y no calcula ninguna cifra: el veredicto "cuadra" lo decide el servidor sobre lo persistido. + * Stock race: the project's central guarantee, run in plain sight. + * N buyers compete for M units at once; the panel draws what the API returns and computes no + * figure itself: the "balanced" verdict is decided by the server over what was persisted. */ import { html, icon, raw, render, $, delegate } from "../dom.js"; @@ -9,6 +9,7 @@ import { api, isDemoMode } from "../api.js"; import { toastError } from "../toast.js"; import { withBusy } from "../overlay.js"; import { fmtNumber } from "../format.js"; +import { t } from "../i18n.js"; const DEFAULTS = { buyers: 24, units: 5 }; @@ -21,7 +22,7 @@ const slider = (name, label, min, max, value) => html` const tile = (buyer, index, outcome) => html` + title="${buyer ? `#${buyer.buyer}` : ""}"> ${buyer && buyer.attempts > 1 ? raw(`${buyer.attempts - 1}`) : ""} `; @@ -38,14 +39,12 @@ const verdict = (race) => {
${raw(icon(race.balanced ? "check" : "alert", { size: 20 }))}
-

- ${race.balanced ? "Cuadra al céntimo: ninguna unidad se vendió dos veces" : "El inventario no cuadra"} -

+

${t(race.balanced ? "race.verdict.ok" : "race.verdict.bad")}

- ${fmtNumber(race.sold)} vendidas + ${fmtNumber(race.remaining)} restantes = ${fmtNumber(race.units)} en stock. - ${retries > 0 - ? `${fmtNumber(retries)} reintentos tras perder una carrera de escritura.` - : "Sin colisiones en esta ejecución."} + ${t("race.verdict.text", { + sold: fmtNumber(race.sold), remaining: fmtNumber(race.remaining), units: fmtNumber(race.units) + })} + ${retries > 0 ? t("race.verdict.retries", { n: fmtNumber(retries) }) : t("race.verdict.noRetries")}

@@ -54,57 +53,52 @@ const verdict = (race) => { const stats = (race) => html`
-
${fmtNumber(race.buyers)}compradores
-
${fmtNumber(race.units)}unidades
-
${fmtNumber(race.sold)}consiguieron una
-
${fmtNumber(race.rejected)}rechazados · 409
+
${fmtNumber(race.buyers)}${t("race.stat.buyers")}
+
${fmtNumber(race.units)}${t("race.stat.units")}
+
${fmtNumber(race.sold)}${t("race.stat.won")}
+
${fmtNumber(race.rejected)}${t("race.stat.rejected")}
`; const markup = () => html`
-

Carrera de stock

-

- Muchos compradores, pocas unidades, el mismo instante. Pulsa y comprueba que nunca se vende de más. -

+

${t("race.title")}

+

${t("race.text")}

${raw(isDemoMode() ? html`
${raw(icon("info", { size: 15 }))} -

- Aquí corre en secuencia. 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. -

+

${t("race.demo.title")} ${t("race.demo.text")}

` : "")}
- ${raw(slider("buyers", "Compradores", 2, 64, DEFAULTS.buyers))} - ${raw(slider("units", "Unidades en stock", 1, 32, DEFAULTS.units))} + ${raw(slider("buyers", t("race.buyers"), 2, 64, DEFAULTS.buyers))} + ${raw(slider("units", t("race.units"), 1, 32, DEFAULTS.units))}
-
${raw(idleGrid(DEFAULTS.buyers))}
+
${raw(idleGrid(DEFAULTS.buyers))}
    -
  • consiguió una unidad
  • -
  • rechazado por falta de stock
  • -
  • n reintentos tras una colisión
  • +
  • ${t("race.legend.won")}
  • +
  • ${t("race.legend.rejected")}
  • +
  • n ${t("race.legend.retries")}
`; export const raceView = { - title: "Carrera de stock", - subtitle: "La garantía anti-sobreventa, a la vista", + get title() { return t("race.title"); }, + get subtitle() { return t("race.subtitle"); }, async render(outlet) { const root = render(outlet, markup()); @@ -124,7 +118,7 @@ export const raceView = { grid.innerHTML = resultGrid(race); result.innerHTML = stats(race) + verdict(race); } catch (error) { - toastError(error, "La carrera no se pudo ejecutar"); + toastError(error, t("race.failed")); } }); } diff --git a/NainOrder.Application/DTOs/DropDtos.cs b/NainOrder.Application/DTOs/DropDtos.cs index 97856fd..04c462c 100644 --- a/NainOrder.Application/DTOs/DropDtos.cs +++ b/NainOrder.Application/DTOs/DropDtos.cs @@ -36,4 +36,5 @@ public record DropDto( int PerBuyerLimit, int HoldSeconds, DateTime ServerTime, + DateTime? NextHoldExpiresAt, IReadOnlyList MyHolds); diff --git a/NainOrder.Application/Services/DropService.cs b/NainOrder.Application/Services/DropService.cs index f8e3f8f..78f0ead 100644 --- a/NainOrder.Application/Services/DropService.cs +++ b/NainOrder.Application/Services/DropService.cs @@ -112,7 +112,8 @@ private DropDto MapToDto(Drop drop, string? buyerToken) return new DropDto( drop.Id, drop.ProductId, drop.ProductName, drop.ProductSku, drop.UnitPrice, drop.TotalUnits, drop.AvailableAt(now), drop.HeldAt(now), drop.SoldUnits, - drop.StatusAt(now).ToString(), drop.OpensAt, drop.PerBuyerLimit, drop.HoldSeconds, now, mine); + drop.StatusAt(now).ToString(), drop.OpensAt, drop.PerBuyerLimit, drop.HoldSeconds, now, + drop.NextHoldExpiryAt(now), mine); } private static HoldDto MapHold(Hold hold) => diff --git a/NainOrder.Domain/Entities/Drop.cs b/NainOrder.Domain/Entities/Drop.cs index a23a79d..8e99ed7 100644 --- a/NainOrder.Domain/Entities/Drop.cs +++ b/NainOrder.Domain/Entities/Drop.cs @@ -71,6 +71,10 @@ public Drop(Guid productId, string productName, string productSku, decimal unitP /// public int AvailableAt(DateTime now) => TotalUnits - SoldUnits - HeldAt(now); + /// When the soonest live hold ends, or null if no hold is live. Lets an organiser see pressure ease. + public DateTime? NextHoldExpiryAt(DateTime now) => + _holds.Where(h => h.IsLiveAt(now)).Select(h => (DateTime?)h.ExpiresAt).Min(); + public DropStatus StatusAt(DateTime now) => now < OpensAt ? DropStatus.Scheduled : AvailableAt(now) == 0 ? DropStatus.SoldOut diff --git a/NainOrder.Tests/Api/DropFlowTests.cs b/NainOrder.Tests/Api/DropFlowTests.cs index e96e270..245353e 100644 --- a/NainOrder.Tests/Api/DropFlowTests.cs +++ b/NainOrder.Tests/Api/DropFlowTests.cs @@ -104,6 +104,22 @@ public async Task Timestamps_are_serialised_as_utc_so_browsers_do_not_read_them_ Assert.EndsWith("Z", doc.RootElement.GetProperty("myHolds")[0].GetProperty("expiresAt").GetString()); } + [Fact] + public async Task A_drop_reports_when_its_next_hold_expires() + { + var drop = await CreateDropAsync(units: 3, holdSeconds: 30); + Assert.Null(drop.NextHoldExpiresAt); + + await TakeAsync(drop.Id, "buyer-next"); + var after = await GetAsync(drop.Id); + + Assert.NotNull(after.NextHoldExpiresAt); + Assert.InRange((after.NextHoldExpiresAt!.Value - after.ServerTime).TotalSeconds, 25, 31); + + _clock.Advance(TimeSpan.FromSeconds(31)); + Assert.Null((await GetAsync(drop.Id)).NextHoldExpiresAt); + } + [Fact] public async Task A_scheduled_drop_rejects_takes_until_it_opens() { diff --git a/NainOrder.Tests/Api/I18nParityTests.cs b/NainOrder.Tests/Api/I18nParityTests.cs new file mode 100644 index 0000000..5f0a68e --- /dev/null +++ b/NainOrder.Tests/Api/I18nParityTests.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace NainOrder.Tests.Api; + +/// +/// REQ-094 and REQ-095: every user-visible string exists in both languages. The panel has no +/// build step, so this test is what turns a forgotten translation into a failing build. +/// +public class I18nParityTests +{ + private static readonly string Directory = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "../../../../NainOrder.Api/wwwroot/assets/i18n")); + + /// The parts of a language, merged the way the panel merges them. A key in two parts is a defect. + private static Dictionary Load(string locale) + { + var merged = new Dictionary(); + + foreach (var file in new[] { $"{locale}.json", $"{locale}.views.json" }) + { + var part = JsonSerializer.Deserialize>( + File.ReadAllText(Path.Combine(Directory, file)))!; + + foreach (var (key, value) in part) + Assert.True(merged.TryAdd(key, value), $"{file}: key '{key}' is already defined in another part."); + } + + return merged; + } + + [Fact] + public void Spanish_and_English_define_exactly_the_same_keys() + { + var es = Load("es").Keys.ToHashSet(); + var en = Load("en").Keys.ToHashSet(); + + Assert.True(es.SetEquals(en), + $"Only in es: [{string.Join(", ", es.Except(en))}]. Only in en: [{string.Join(", ", en.Except(es))}]."); + } + + [Theory] + [InlineData("es")] + [InlineData("en")] + public void No_string_is_empty(string locale) => + Assert.All(Load(locale), pair => Assert.False(string.IsNullOrWhiteSpace(pair.Value), $"{locale}:{pair.Key} is empty")); + + [Fact] + public void Placeholders_match_between_languages() + { + var es = Load("es"); + var en = Load("en"); + + foreach (var (key, spanish) in es) + { + var spanishTokens = Tokens(spanish); + var englishTokens = Tokens(en[key]); + Assert.True(spanishTokens.SetEquals(englishTokens), $"Placeholders differ in '{key}'."); + } + } + + private static HashSet Tokens(string text) => + System.Text.RegularExpressions.Regex.Matches(text, @"\{(\w+)\}", System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromSeconds(1)) + .Select(m => m.Groups[1].Value).ToHashSet(); +} diff --git a/NainOrder.Tests/Domain/DropTests.cs b/NainOrder.Tests/Domain/DropTests.cs index c1f2ca7..d5e04cd 100644 --- a/NainOrder.Tests/Domain/DropTests.cs +++ b/NainOrder.Tests/Domain/DropTests.cs @@ -87,6 +87,21 @@ public void An_unconfirmed_hold_returns_its_units_when_its_window_ends() Assert.Equal(DropStatus.Live, drop.StatusAt(At(61))); } + [Fact] + public void The_next_expiry_is_the_soonest_live_hold_and_null_when_none_is_live() + { + var drop = NewDrop(units: 5, perBuyer: 5, holdSeconds: 60); + Assert.Null(drop.NextHoldExpiryAt(At(0))); + + drop.Take("buyer-a", 1, At(10)); + drop.Take("buyer-b", 1, At(20)); + + Assert.Equal(At(70), drop.NextHoldExpiryAt(At(30))); + // Once the first window ends, the next live one takes over. + Assert.Equal(At(80), drop.NextHoldExpiryAt(At(75))); + Assert.Null(drop.NextHoldExpiryAt(At(81))); + } + [Fact] public void Sweeping_marks_expired_holds_and_reports_how_many() { diff --git a/README.md b/README.md index dac0c14..1f3cb80 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,21 @@ [![ci](https://github.com/Nain9Dev/NainOrder/actions/workflows/ci.yml/badge.svg)](https://github.com/Nain9Dev/NainOrder/actions/workflows/ci.yml) [![CodeQL](https://github.com/Nain9Dev/NainOrder/actions/workflows/codeql.yml/badge.svg)](https://github.com/Nain9Dev/NainOrder/actions/workflows/codeql.yml) ![.NET 10](https://img.shields.io/badge/.NET-10-512BD4) -![Tests](https://img.shields.io/badge/tests-110%20passing-2ea44f) +![Tests](https://img.shields.io/badge/tests-116%20passing-2ea44f) ![Client dependencies](https://img.shields.io/badge/client%20dependencies-0-22d3ee) [![Live demo](https://img.shields.io/badge/live%20demo-order.naindev.com-8b5cf6)](https://order.naindev.com/)

- A live drop: 56 units, 2 sold, 42 held by a crowd of 40 buyers, 12 still available + Animation: a crowd arrives, each unit is held, unconfirmed holds return, everything sells once

-> **56 units. 40 buyers released at once.** Each unit is *available*, *held* for 60 seconds, or *sold*. -> Hold it and don't confirm: it goes back to the crowd. Press **Soltar 40 compradores** and watch. +

+ The organizer view of a live drop: 48 units, 3 sold, 40 held by a crowd of 40 buyers, 5 still available +

+ +> **48 units. 40 buyers released at once.** Each unit is *available*, *held* for 30 seconds, or *sold*. +> Hold it and don't confirm: it goes back to the crowd. Try it as a **buyer** or an **organizer**, in +> Spanish or English: open the app and press **Release 40 buyers**. --- @@ -88,11 +93,11 @@ git clone https://github.com/Nain9Dev/NainOrder.git cd NainOrder/NainOrder.Api && dotnet run ``` -Open → **Drop** → *Lanzar drop*. Also there: [`/swagger`](http://localhost:8170/swagger) +Open → **Try as a buyer** or **Try as an organizer**. Also there: [`/swagger`](http://localhost:8170/swagger) and `/health`. No database to install: SQLite creates itself. ```bash -dotnet test NainOrder.slnx -c Release # 110 tests +dotnet test NainOrder.slnx -c Release # 116 tests docker build -t nainorder . && docker run -p 8080:8080 nainorder ``` diff --git a/docs/00-charter.md b/docs/00-charter.md index 51163fb..91077cf 100644 --- a/docs/00-charter.md +++ b/docs/00-charter.md @@ -33,7 +33,9 @@ These are deliberately out of scope. Their absence is a decision, not an oversig - **Multi-warehouse or partial fulfilment.** Stock is a single global counter per product. - **Order history / event sourcing.** Timestamps per transition are kept; the full audit trail is not modelled. -- **Internationalisation.** The panel's copy is Spanish; the API is locale-neutral. +- **Locales beyond Spanish and English.** The panel offers ES and EN, chosen by the owner on + 2026-10-20 ([specs/003-drop-experience](../specs/003-drop-experience/spec.md)); the API is + locale-neutral and its errors are machine-readable codes. ## Constraints diff --git a/docs/10-requirements.md b/docs/10-requirements.md index 455ca22..3a7b823 100644 --- a/docs/10-requirements.md +++ b/docs/10-requirements.md @@ -80,6 +80,17 @@ enters this table when it is implemented and tested. | REQ-086 | When a drop's units are all sold or held, the system shall report it `SoldOut`; when expired holds free units again, it shall report it `Live`. | | REQ-088 | If a buyer exceeds the per-buyer limit across holds and orders of one drop, then the system shall reject the request with `409 buyer_limit_reached`. | +## Drop experience + +From [specs/003-drop-experience](../specs/003-drop-experience/spec.md). The visual requirements +(REQ-090 to REQ-094, REQ-096) live in that spec until browser-level tests exist; only what is +verified by an automated test is listed here. + +| ID | Requirement | +| :--- | :--- | +| REQ-095 | Every user-visible string of the panel shall exist in both Spanish and English with the same placeholders, and a missing or empty one shall fail the build. | +| REQ-097 | The system shall report, with each drop, when its soonest live hold ends, or nothing when no hold is live. | + ## HTTP contract | ID | Requirement | diff --git a/docs/50-traceability.md b/docs/50-traceability.md index 0c059c4..3071232 100644 --- a/docs/50-traceability.md +++ b/docs/50-traceability.md @@ -1,6 +1,6 @@ # Traceability -**Status:** Active · verified against a green run of `dotnet test NainOrder.slnx -c Release` (110/110 tests) +**Status:** Active · verified against a green run of `dotnet test NainOrder.slnx -c Release` (116/116 tests) Every requirement in [10-requirements.md](10-requirements.md) and the test that demonstrates it. A requirement with no test is not done, whatever the code says. @@ -17,6 +17,7 @@ Test suites: | `Api.OrderAggregateConcurrencyProbe` | Concurrency, aggregate | Multi-context concurrent aggregate mutations and optimistic concurrency conflict enforcement | | `Domain.DropTests` | Example-based + property-based | The drop aggregate with an explicit clock: holds, expiry, limits, the held-plus-sold invariant | | `Api.DropFlowTests` | Integration, concurrent | Drops over HTTP with a hand-moved clock, a 40-buyer crowd and UTC serialisation | +| `Api.I18nParityTests` | Contract | Both panel languages define the same keys and placeholders | | `Api.StockRaceTests` | Integration, concurrent | The on-demand race: many buyers, few units, verdict from persisted stock | | `Api.GlobalExceptionHandlerTests` | Contract/Unit | RFC 7807 error translation and production 500 detail sanitization | @@ -91,6 +92,13 @@ Test suites: | REQ-086 | `DropTests.An_unconfirmed_hold_returns_its_units_when_its_window_ends`; `DropFlowTests.An_unconfirmed_hold_expires_and_returns_its_units` | | REQ-088 | `DropTests.A_buyer_cannot_exceed_the_per_buyer_limit_across_holds`, `Expired_units_do_not_count_against_the_buyer_limit`; `DropFlowTests.A_buyer_over_the_limit_is_rejected_with_409` | +## Drop experience + +| Requirement | Test | +| :--- | :--- | +| REQ-095 | `I18nParityTests.Spanish_and_English_define_exactly_the_same_keys`, `No_string_is_empty`, `Placeholders_match_between_languages` | +| REQ-097 | `DropTests.The_next_expiry_is_the_soonest_live_hold_and_null_when_none_is_live`; `DropFlowTests.A_drop_reports_when_its_next_hold_expires` | + ## HTTP contract | Requirement | Test | @@ -112,4 +120,10 @@ Test suites: ## Gaps -None. Every requirement declared in [10-requirements.md](10-requirements.md) is covered by an automated test passing in the Release suite. +Every requirement declared in [10-requirements.md](10-requirements.md) is covered by an automated test passing in the Release suite. + +One known gap, deliberately outside that file: the visual behaviour of the panel in +[specs/003-drop-experience](../specs/003-drop-experience/spec.md) (REQ-090 to REQ-094, REQ-096: +landing story, role lenses, language selector, reduced motion). It was verified by hand in a browser +against the real API and the in-browser engine, in both languages, with no console errors or missing +translation keys; there is no browser-level test yet (task T-056). diff --git a/docs/90-changelog.md b/docs/90-changelog.md index 615c6f8..13eb94a 100644 --- a/docs/90-changelog.md +++ b/docs/90-changelog.md @@ -8,6 +8,10 @@ Notable changes. Format loosely follows Keep a Changelog; dates come from git. ### Added +- Drop experience (`specs/003-drop-experience`): an animated, wordless landing for the drop, a + buyer view and an organizer view of the same drop, and a Spanish/English selector covering the + whole panel (dictionaries in `assets/i18n`, parity enforced by `I18nParityTests`). Drops report + `nextHoldExpiresAt`. - Drops (phase 1 of `specs/002-drops`, ADR-0008): a fixed number of units sold to a crowd through short expiring holds, with `/api/drops` endpoints, a `Drop` panel view with a crowd simulator, and an in-browser engine for the static demo. REQ-080 to REQ-086 and REQ-088. diff --git a/docs/assets/drop-live.png b/docs/assets/drop-live.png index 8da72c7..9c8bbb6 100644 Binary files a/docs/assets/drop-live.png and b/docs/assets/drop-live.png differ diff --git a/docs/assets/drop-story.gif b/docs/assets/drop-story.gif new file mode 100644 index 0000000..2843d5c Binary files /dev/null and b/docs/assets/drop-story.gif differ diff --git a/docs/assets/panel-dashboard.png b/docs/assets/panel-dashboard.png index f482455..afee54a 100644 Binary files a/docs/assets/panel-dashboard.png and b/docs/assets/panel-dashboard.png differ diff --git a/docs/assets/panel-race.png b/docs/assets/panel-race.png index 5a061d7..4941381 100644 Binary files a/docs/assets/panel-race.png and b/docs/assets/panel-race.png differ diff --git a/docs/client.ts b/docs/client.ts index c5b650f..244dbdd 100644 --- a/docs/client.ts +++ b/docs/client.ts @@ -84,6 +84,7 @@ export interface DropDto { perBuyerLimit?: number; holdSeconds?: number; serverTime?: string; + nextHoldExpiresAt?: string; myHolds?: HoldDto[]; } diff --git a/docs/openapi.json b/docs/openapi.json index c765405..5d889e5 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1480,6 +1480,11 @@ "type": "string", "format": "date-time" }, + "nextHoldExpiresAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, "myHolds": { "type": "array", "items": { diff --git a/specs/003-drop-experience/plan.md b/specs/003-drop-experience/plan.md new file mode 100644 index 0000000..9fb4af0 --- /dev/null +++ b/specs/003-drop-experience/plan.md @@ -0,0 +1,32 @@ +# 003 — Plan + +## i18n + +- Dictionaries as static JSON, `wwwroot/assets/i18n/es.json` and `en.json`, flat dotted keys. JSON + (not JS) so a plain .NET test can read both and assert identical key sets (REQ-095) without a + Node toolchain. +- `i18n.js`: `t(key, params)`, `getLocale()`, `setLocale()`; loads both files once before the first + render (works on the static host); persists in `localStorage`; default from `navigator.language`. +- Selector in the top bar. Changing it re-renders the current view. +- Missing key: `t` returns the key and logs once, so a gap is visible rather than silent. + +## Landing story + +A single SVG scene driven by CSS animation, no library: crowd dots stream in, a grid of units fills +with *held*, one hold drains and returns, others turn *sold*. Under 20 s, looped, no text beyond the +two role buttons. `prefers-reduced-motion` renders the final frame (REQ-096). + +## Roles + +One drop, two lenses over the same polled state: + +- **Buyer**: product, big countdown or "live", one action, own hold with its bar. +- **Organizer**: unit grid, sales-rate sparkline computed from polled `sold` deltas, list of holds + expiring soon, crowd button. The sparkline plots figures the API returned; it applies no rule. + +A tab control switches lens without reloading the drop (REQ-093). + +## Verification + +- `I18nParityTests` reads both JSON files and asserts equal key sets and non-empty values. +- Browser check of both locales and both roles against the real API and the in-browser engine. diff --git a/specs/003-drop-experience/spec.md b/specs/003-drop-experience/spec.md new file mode 100644 index 0000000..384b32f --- /dev/null +++ b/specs/003-drop-experience/spec.md @@ -0,0 +1,40 @@ +# 003 — Drop experience + +**Status:** Approved direction by the owner (interview of 2026-10-20). Requirements below are drafts until each has its test. + +## Goal + +A shop owner or a recruiter opens the app and understands within ten seconds what it does and why it +matters, without reading: the product explains itself through motion and the result. + +## Decisions taken in the interview + +| Question | Answer | +| :--- | :--- | +| Who must understand it fast | A shop owner or client (recruiters benefit too) | +| First piece to build | An animated landing for the drop | +| Roles a visitor can try | Both, one click apart: buyer and organizer | +| Language of the public panel | ES and EN with a selector | + +## Requirements (EARS) + +- **REQ-090** — When the panel opens on the Drop view with no drop selected, the system shall play a short wordless story — a crowd arrives, units are held, an unconfirmed hold expires, units are sold — and offer one action per role. +- **REQ-091** — Where the visitor chooses the buyer role, the panel shall show only what a buyer needs: the countdown, one action, and their own hold. +- **REQ-092** — Where the visitor chooses the organizer role, the panel shall show the drop's units, the sales rhythm and the holds about to expire, and shall let the organizer release a simulated crowd. +- **REQ-093** — The panel shall let the visitor switch between buyer and organizer on the same drop without losing it. +- **REQ-094** — The panel shall offer Spanish and English, remember the choice, and default to the browser's language. +- **REQ-095** — Every user-visible string shall exist in both languages: a string missing from one dictionary is a build failure. +- **REQ-096** — Where the visitor prefers reduced motion, the landing story shall show its end state without animation. +- **REQ-097** — The system shall report, with each drop, when its soonest live hold ends, or nothing when no hold is live. (Added during implementation: REQ-092's "holds about to expire" needs it from the server, since the panel must not compute it.) + +## Status of verification + +REQ-095 and REQ-097 have automated tests. REQ-090 to REQ-094 and REQ-096 are visual and were +verified by hand in a browser (real API and in-browser engine, both languages); a browser-level +test is task T-056. + +## Non-goals + +- Translating API error messages: they are machine-readable (`code`) and the panel maps the codes it needs. +- Right-to-left languages. +- A marketing site separate from the panel. diff --git a/specs/003-drop-experience/tasks.md b/specs/003-drop-experience/tasks.md new file mode 100644 index 0000000..801051c --- /dev/null +++ b/specs/003-drop-experience/tasks.md @@ -0,0 +1,18 @@ +# 003 — Tasks + +| ID | Task | Label | Done when | +| :--- | :--- | :--- | :--- | +| T-050 | i18n core, JSON dictionaries, selector; parity test first | `[A]` | ✅ `I18nParityTests` green (REQ-094, REQ-095) | +| T-051 | Translate the shell and the Drop, Race and Architecture views | `[A]` | ✅ No hardcoded user-visible string in those files | +| T-052 | Animated landing story | `[A]` | ✅ Plays in a browser; static end state under reduced motion (REQ-090, REQ-096) | +| T-053 | Buyer and organizer lenses | `[A]` | ✅ Verified in a browser on the real API (REQ-091 to REQ-093, REQ-097) | +| T-054 | Translate Dashboard, Catalogue, Orders and the order panel | `[A]` | ✅ Every route in both languages: no console errors, no missing keys | +| T-055 | README capture (animated) | `[A]` | ✅ `docs/assets/drop-story.gif` and a live-drop capture | +| T-056 | Browser-level tests for the visual requirements | `[M]` | Playwright decision (a dev dependency) approved by the owner; REQ-090 to REQ-094, REQ-096 move into `docs/10-requirements.md` | +| T-057 | Animated request map in the Architecture view (a purchase travelling through the layers) | `[A]` | The four layers light up in order for a real request | + +## Handoff + +Done: i18n (ES/EN, 352 keys per language, parity test), landing story, buyer and organizer lenses, `nextHoldExpiresAt`. +Not done: T-056 needs the owner's call on adding Playwright; T-057 is the next visual piece. Open drawers +and modals do not re-render on a language switch, only on the next open.
ReferenciaClienteEstadoLíneasUnidadesTotalCreado${t("orders.col.reference")}${t("orders.col.customer")}${t("orders.col.status")}${t("orders.col.lines")}${t("orders.col.units")}${t("orders.col.total")}${t("orders.col.created")}