38 Commits

Author SHA1 Message Date
sdarbinyan
07367d3183 Merge branch 'B2B'
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
2026-08-13 13:01:42 +04:00
sdarbinyan
357d346787 refactor: remove dead mock-mode branches in PRODUCT_DATA_PROVIDER/CATEGORY_REPOSITORY
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Both tokens switched on getProductProviderMode()/getCategoryProviderMode()
but every case (including 'mock') fell through to the same real API
provider - no mock implementation of either interface exists. Removed
the dead switch instead of leaving code that implies a mock mode which
was never built.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:18:14 +04:00
sdarbinyan
23d9f2f66f refactor: rename duplicate AdminRole interface, type sellerId as UUID
AdminRole was defined twice with unrelated shapes (core/auth's real
JWT role union vs. the Users admin page's display interface), flagged
in BACKEND-API-REFERENCE.md \u00a72b as needing a rename. Renamed the
Users-page one to AdminUserRoleRecord.

sellerId was bare string in admin-order/admin-product/item models
while core/sellers/models/seller-scope.model.ts already used the
shared UUID alias. Aligned all three to UUID for consistency (UUID is
currently just = string, so this is a documentation-level type change,
not a behavior change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:16:16 +04:00
sdarbinyan
00e5ce6b20 perf: AdminAnalyticsFacade.load() - forkJoin instead of 4 nested subscriptions
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
orders/products/categories/reviews don't depend on each other but were
fetched serially, 4 levels deep. forkJoin runs them in parallel.

Also fixes a real race: a rapid setDateRange() double-call previously
had no cancellation, so a stale in-flight chain could resolve after
and overwrite a newer one. Added a cancelPreviousLoad$ subject with
takeUntil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:08:47 +04:00
sdarbinyan
a339a1c64e perf: debounce price-range/slider filter inputs
updateRange()/updateSlider() emitted stateChange synchronously on
every keystroke/drag event, triggering a full catalog filter
recompute each time. Debounced both (350ms, per filterId+key timer,
cleared on destroy).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:07:03 +04:00
sdarbinyan
c9a80da7c3 perf: memoize TranslatePipe instead of re-walking translations every CD cycle
pure:false stays (needed so language switches propagate without
touching every | translate template call site), but repeat calls with
unchanged key/params/language now hit a Map lookup instead of
re-splitting the key and re-walking the translation object tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:03:30 +04:00
sdarbinyan
bad3002006 fix: WCAG 2.2.2 hero autoplay pause control, invisible keyboard-focusable cart button, literal hex token
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- Hero widget autoplay had no pause control and ignored
  prefers-reduced-motion (WCAG 2.2.2 requires a way to pause
  auto-updating content lasting >5s). Added a pause/resume toggle
  button and skip autoplay entirely when the OS prefers reduced motion.
- Cart's swipe-reveal delete-btn-mobile was reachable by Tab even
  while invisible (opacity: 0, only the touch-swipe gesture could
  reveal it) - a confusing, unusable focus stop for keyboard users.
  Now tabindex=-1 + aria-hidden until swiped. Keyboard users already
  had a full removal path via the always-visible header remove button;
  this just stops the redundant hidden button from being a dead tab
  stop.
- stars.component.scss hardcoded #cdd6d5 for the unfilled-star color
  instead of the --border-color design token.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:12:41 +04:00
sdarbinyan
178b5f0dc7 fix: i18n gaps in popular searches and compare table
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
- SearchFacade.popularSearches hardcoded English titles regardless of
  active locale. Converted to a getter using translate.t() for the
  displayed title/text; the underlying search query stays the stable
  English canonical term the backend index matches against.
- Compare table and compare page rendered product.name raw instead of
  through getTranslatedField(), same pattern used everywhere else
  product titles are shown (catalog, product detail).
- SearchTrendingService.loadTrending() is a genuine backend gap (no
  trending-search endpoint exists) - already degrades gracefully,
  documented as a gap in BACKEND-API-REFERENCE.md \u00a712.6 rather than
  faked client-side.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:04:29 +04:00
sdarbinyan
6cc5d43a10 feat: build and wire the Quick View modal
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
The Quick View button (search results page only) emitted
quickViewPlaceholder with zero listeners anywhere up the chain - the
button did nothing. Built a minimal QuickViewDialogComponent (image,
name, price incl. discount, short description, Add to Cart, link to
full product page) and wired the event through product-grid ->
search-results -> catalog-container, which fetches the product via
ProductFacade and opens the dialog.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:45:03 +04:00
sdarbinyan
b04e3a67f5 feat: wire up dark mode selector with a real dark palette
theme.mode wrote data-theme-mode to the DOM but no CSS ever reacted to
it, and mapThemeConfigToCssVariables() never looked at mode at all -
selecting Dark had zero visible effect.

Added dark-mode neutral overrides (background/text/border axis only -
brand colors stay as configured) using the palette provided by the
user (colorhunt.co/palette/091413285a48408a71b0e4cc):
  --bg-primary: #091413   --bg-secondary: #285a48
  --text-primary: #b0e4cc --text-secondary: #408a71
  --border-color: #285a48

ThemeEngineService now resolves 'system' mode via
prefers-color-scheme and re-renders live on OS theme changes, and
sets data-theme-mode to the *effective* resolved mode instead of the
raw setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:35:09 +04:00
sdarbinyan
0b08802996 feat: real back-in-stock subscription for Notify Me (API + localStorage fallback)
notifyMe() just called toggleWishlist() - no actual subscription
mechanism existed. Now calls a new subscribeToRestock() API method
(POST /items/{id}/notify-me, not yet built server-side - see
BACKEND-API-REFERENCE.md §12.5) and falls back to a local-only record
in localStorage on failure, so the request isn't silently dropped
while the backend catches up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:30:53 +04:00
sdarbinyan
1af337f005 fix: footer Contacts link resolved to nothing, add placeholder static page
The /contacts route had no bootstrap.staticPages entry at all (the old
hardcoded contacts page was intentionally removed per app.routes.ts's
comment, in favor of the generic staticPath -> CMS resolver). Added a
placeholder entry (route/title/html per locale) so the link resolves
instead of 404ing. Placeholder text explicitly says the real contact
details go through the admin panel - per user decision, real content
belongs to whoever runs a given marketplace, not something to fabricate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:26:32 +04:00
sdarbinyan
3feb806caa feat: add JSON-LD structured data for product and site pages
JSON-LD was absent entirely (sitemap generation is separate backend
work, out of scope here). Added Product schema (name/description/
image/offers with price+availability) on item pages via setItemMeta(),
and a site-wide Organization schema via resetToDefaults(), both
injected as a single #seo-json-ld <script type=application/ld+json>
tag that gets replaced on navigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:16:37 +04:00
sdarbinyan
ec8ed8f6a8 fix: Site Layout builder setting saved but never read by page rendering
bootstrap.layout.type (the 'Site Layout' selector in the theme editor)
was only ever consulted by a validator checking it against the known
list - nothing used it to actually pick a layout. SectionEngineService
already resolves a per-page layout.type with a hardcoded 'default'
fallback; that fallback now reads the site-wide setting first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:14:56 +04:00
sdarbinyan
8937aea57c fix: checkout payment-description fallback hardcoded Russian regardless of locale
getPaymentDescription()'s final fallback ('Покупка на Маркетплейсе')
ignored the active language. Moved to a translated key.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:09:12 +04:00
sdarbinyan
c0bce7feac fix: Buy Now raced ahead of addToCart, per-product SEO tags never applied
CartService.addItem() fired an async dynamic import + API call for
new items but returned void immediately - buyNow() navigated to /cart
before the item was actually added, landing the user on an empty or
stale cart. addItem() now returns a Promise that resolves once the
cart signal actually contains the item; buyNow() awaits it before
navigating.

Also wired SeoService.setItemMeta()/resetToDefaults() into the product
detail page - built and working, but never called anywhere, so every
product page rendered the site-wide default OG/Twitter tags instead of
per-product ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:06:46 +04:00
sdarbinyan
9f784406d7 fix: og:locale hardcoded ru_RU regardless of active locale
Both setItemMeta() and resetToDefaults() hardcoded 'ru_RU'. Added a
LanguageService-driven mapping (ru/en/hy -> ru_RU/en_US/hy_AM).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:02:18 +04:00
sdarbinyan
461cd8421c fix: toAuthErrorShape() ignored backend error.code, session-expired screen unreachable
Known bug per BACKEND-API-REFERENCE.md §5: the client has dedicated
session-expired/invalid-signature recovery screens fully built, but
the mapper only ever derived the error code from HTTP status, never
the response body - so a real 401 with error.code: 'TOKEN_EXPIRED'
rendered the generic 'Unauthorized' screen instead.

Frontend half of the fix: prefer error.code from the body when present
(mapped via authErrorCodeFromBackendCode), fall back to status-derived
code otherwise. Stays dormant until the backend actually sends the
code, per the doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 09:00:39 +04:00
sdarbinyan
6231128288 fix: static-page loadByKey/loadByPath had no error handler, infinite spinner on failure
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
resolveByKey/resolveByRoute subscribed with only a next callback -
a resolver failure left loading=true forever with no error branch to
recover from. Added an error signal, error subscribe handler, and a
distinct error state UI (separate from the existing 404 not-found
state) with a way back home.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:56:25 +04:00
sdarbinyan
fd8e7e1b28 fix: DataSourceResolverService.resolve() had no catchError
A category/product facade error propagated through switchMap
uncaught, erroring the shared widget stream (shareReplay) in
WidgetHostService with no fallback - the widget just silently failed
to render, and the error stayed cached for every later subscriber.

Added catchError falling back to an empty { section, settings } shape,
same pattern as the widget-manifest fetch (falls back to { widgets: [] }
on any error, never throws to the UI).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:54:39 +04:00
sdarbinyan
000bb78112 fix: no error feedback on failed save/delete/role-change, editors navigated away before save result was known
Products/Categories saveDraft() and deleteOne(), and Users
setRole()/setStatus()/invite(), had no error handler at all - a
failed mutation was completely silent.

Also found and fixed the same premature-navigation bug as the Phase 1
cart fix: both product and category editor pages called
router.navigate() immediately after facade.saveDraft(), before the
save had resolved - so even after adding error feedback, the user
would already be gone from the page before it could show. saveDraft()
now takes an onSuccess callback and only the page navigates on actual
success; on failure it stays put and shows a themed error dialog.

Added a mutationError signal to all three facades and a themed
app-dialog error alert on the products/categories editor + list pages
and the users page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:53:12 +04:00
sdarbinyan
d3d6632375 fix: reports page never read facade.error(), silently showed 0/0
AdminAnalyticsFacade.error() already existed but the reports page
template never checked it - a load failure just rendered the summary
cards with default/zero values, looking like a legitimate empty
report instead of a failure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:46:17 +04:00
sdarbinyan
1e84d67e24 fix: load errors swallowed to empty array across 5 admin facades
Orders/Products/Categories/Customers/Transactions loadList() caught
errors by silently clearing the list to [] with no error state - an
API failure looked identical to a genuine 'no results' empty state.

Added an error signal to each facade (set on failure, cleared on
retry) and an error branch in each list page/component, distinct from
both loading and the real empty state.

Order and Customer detail (loadDetail) had it worse: no error handler
at all, so a failure just left the page on 'Loading...' forever with
nothing to retry or navigate away with. Added selectedLoading/
selectedError to both facades and an error screen with a back button
to both detail pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:44:30 +04:00
sdarbinyan
c461d9bd5f fix: kill remaining native confirm() in admin, standardize on themed dialog
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Order cancel/refund and user suspend used window.confirm(). Migrated
all three to app-confirm-dialog, matching products/categories/orders
delete gates from earlier in this phase.

Remaining window.confirm() usages are the three canDeactivate dirty
guards (categories/products/project-editor) - left as-is, since
CanDeactivate needs a synchronous or Observable/Promise return and
browser navigation guards conventionally use the native dialog there;
converting those is a separate, larger refactor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:34:18 +04:00
sdarbinyan
63039b8707 fix: review 'Archive' button was mislabeled delete, zero confirmation
The bulk button read 'Archive' but called applyBulkDelete() ->
gateway.deleteReview() - a real hard delete, and reviews have no
archived status in the model at all, so 'archive' was never a real
concept here. Relabeled to 'Delete' and added an app-confirm-dialog
gate, same pattern as products/categories/orders.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:28:56 +04:00
sdarbinyan
908f10e022 fix: order bulk-delete had zero confirmation
Bulk-delete button called facade.applyBulkDelete() with no gate.
Added app-confirm-dialog, same pattern as products/categories.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:25:09 +04:00
sdarbinyan
49aab63124 fix: product delete (single+bulk) had zero confirmation
(delete) and (bulkDelete) called facade.deleteOne()/applyBulkDelete()
straight from the click event. Added app-confirm-dialog gates for
both, same pattern as categories.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:22:01 +04:00
sdarbinyan
320f1f44b7 fix: category bulk-delete had no confirmation, single-delete used native dialogs
Bulk-delete called facade.applyBulkDelete() straight from the button
with zero confirmation. Single-delete had a confirm gate but via raw
window.confirm/alert instead of the themed dialog used elsewhere.

Migrated both to app-confirm-dialog (single + new bulk), and the
delete-blocked message to a themed app-dialog instead of window.alert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 08:19:22 +04:00
sdarbinyan
8a91a862ca fix: order status dropdown could bypass confirm-gated cancel/refund
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
The status <select> on the order detail page let an admin jump
straight to 'cancelled'/'refunded' with no confirmation, bypassing the
dedicated cancel()/requestRefund() buttons that do confirm. It also
stayed editable after an order reached a terminal status, so it could
be moved backward out of cancelled/refunded.

- Dropdown options now exclude terminal statuses; reaching them
  requires the confirm-gated buttons.
- setStatus() guards against a terminal status slipping through
  regardless.
- Once an order is terminal (isTerminal(), already computed but
  unused), the dropdown and both action buttons are disabled.
- Same fix applied to the orders list page's bulk status dropdown,
  which had the identical gap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:42:55 +04:00
sdarbinyan
4ef5ea2f58 fix: cart payment/order currency ignored the selected currency
createPayment() and recordOrder() hardcoded currency: 'RUB' regardless
of LanguageService.currentCurrency() (app supports RUB/USD/EUR/AMD).
Widened CartPaymentRequest.currency from a 'RUB' literal to string and
use the actual selected currency in both calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:39:35 +04:00
sdarbinyan
4c4417dc1d fix: cart email/phone capture form was never rendered
recordOrder() and autoSubmitPurchase() read userEmail()/userPhone()
signals and submitEmail() was fully implemented (validation, error
handling), but the success screen's template never rendered the
inputs - so the form was unreachable and the fallback auto-submit
always sent blank email/phone.

- Added the email/phone form to the payment-success screen, wired to
  the existing signals/handlers.
- autoSubmitPurchase() (the 5s fallback if the user doesn't submit
  manually) no longer navigates home via an unconditional setTimeout(0)
  fired before the submission result is known - it now waits for
  submitPurchaseEmail() to settle, same as the manual path, and skips
  entirely if the user already submitted (new purchaseSubmitted flag).
- It also now sends whatever the user has typed instead of
  hardcoded-blank fields, and shows a toast instead of only logging to
  console when no Telegram user id is available.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:37:36 +04:00
sdarbinyan
ebca66dd4c docs: backend TODOs for the four Phase 0 items needing backend work
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Adds §12 to the living backend reference: admin role claim, HttpOnly
session cookie, server-side order pricing, and a real order audit
trail, each with the proposed API/JSON shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:28:47 +04:00
sdarbinyan
c7d8ef1295 fix: add CSP/Permissions-Policy to lovero.store and tenant template
Both server blocks were missing Content-Security-Policy and
Permissions-Policy entirely (dexarmarket.ru already had them). This is
defense-in-depth against XSS, not a fix for the underlying issue: the
customer session cookie is still non-HttpOnly and JS-readable, which
only a backend Set-Cookie change can close (BACKEND-API-REFERENCE.md
§12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:28:10 +04:00
sdarbinyan
f336420415 feat: add actor to order timeline audit trail
AdminOrderTimelineEntry had no actor field at all - order status
changes and refund requests were unattributed. Added actor: string,
populated from the signed-in admin's displayName (same pattern as
Users/Transactions), surfaced in the order detail timeline UI.

Real backend-issued orders still need a server-side audit trail;
this only covers the local mock gateway pending backend work
(BACKEND-API-REFERENCE.md §12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:27:10 +04:00
sdarbinyan
9fa3321322 fix: stop sending client-computed price on order creation
createOrder() sent a discount-applied price per line item that the
client computed itself, with no server revalidation. Items now only
carry productId/name/quantity - the backend must price from its own
catalog. createPayment()'s amount (required to actually charge the
payment gateway) is unchanged; backend must revalidate it instead,
tracked in BACKEND-API-REFERENCE.md §12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:24:08 +04:00
sdarbinyan
bac415d003 feat: UI-only permission gate for admin routes (cosmetic pending backend)
adminAuthGuard only checked isAuthenticated() - any signed-in admin
could reach any route. The live Telegram/QR auth (Mechanism A) carries
no role claim, so a real gate needs a backend change (tracked in
BACKEND-API-REFERENCE.md).

Added AdminPermissionsService + requireAdminPermission() guard factory
that derive a permission set locally by matching the Telegram username
against the mock Users domain's roleId - the same local-only stand-in
already used for the rest of that domain. Wired onto /backoffice/users
requiring 'users.manage'. Explicitly cosmetic: backend must
independently authorize every mutation regardless of what this guard
decides.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:22:01 +04:00
sdarbinyan
0646d587eb fix: record real admin identity in Users/Transactions audit trail
audit entries hardcoded actor: 'admin' regardless of who performed the
action. Both local gateways now pull the signed-in admin's displayName
from AdminAuthService, falling back to 'admin' only when unavailable.

Moderation's actor field is a role classifier ('admin' | 'customer'),
not an identity string, and is left unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:15:59 +04:00
sdarbinyan
c3b5820ac9 fix: category slug-uniqueness check fails closed on API error
isSlugTaken previously caught network/API errors and returned false,
letting the save proceed as if the slug were free. Now the error
propagates and blocks save via a distinct slugCheckError state,
surfaced in the category form with its own hint.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 07:15:50 +04:00
87 changed files with 1503 additions and 265 deletions

View File

@@ -454,3 +454,81 @@ Per-domain migration pattern for the six no-seam admin domains (Orders, Products
- **`ADMIN_DASHBOARD_METRICS_GATEWAY` and `USER_EXPERIENCE_REPOSITORY` token factories return the mock/local class in every mode** — a real implementation must be written *and* explicitly bound; the seam existing does not mean a real backend is one line away. - **`ADMIN_DASHBOARD_METRICS_GATEWAY` and `USER_EXPERIENCE_REPOSITORY` token factories return the mock/local class in every mode** — a real implementation must be written *and* explicitly bound; the seam existing does not mean a real backend is one line away.
For open product/business decisions this document deliberately does not resolve (rate limiting posture, refresh-token reuse detection, tenant-scoped auth, API versioning scheme, etc.), see [GAPS-AND-IMPROVEMENTS.md](GAPS-AND-IMPROVEMENTS.md). For open product/business decisions this document deliberately does not resolve (rate limiting posture, refresh-token reuse detection, tenant-scoped auth, API versioning scheme, etc.), see [GAPS-AND-IMPROVEMENTS.md](GAPS-AND-IMPROVEMENTS.md).
---
## 12. Frontend-blocked TODOs — needs backend
Raised during the Phase 0 security hardening pass (see the sprint plan). Each of these has a client-side mitigation already in place where one exists, but none of them close the actual gap without a backend change.
### 12.1 Admin role claim on the session
**Gap:** `adminAuthGuard` (Mechanism A, Telegram/QR) only checks "is there an active session" — the session API has no concept of admin role at all, so the frontend cannot enforce permissions server-authoritatively. Client mitigation: `AdminPermissionsService` derives a cosmetic permission set by matching the Telegram username against the mock Users domain locally — this is UI-only and trivially bypassed by calling the API directly.
**Ask:** either (a) add a `role` field to the existing `GET /users/sessions/{id}` response when the session belongs to a registered admin, or (b) finish Mechanism B (Ed25519 challenge/response, already wired client-side, `/challenge` and `/verify` currently 404) so the JWT `role` claim becomes real. Whichever is chosen, every admin-mutating endpoint must independently authorize the request — a role claim on the session is necessary but not sufficient.
Proposed minimal shape for option (a), added to the existing poll response (§2a):
```json
{
"webSessionID": "3f1c2a0e-4e21-4d3a-9e77-1e8f6a2d9c11",
"status": "active",
"user": { "id": 8823771, "username": "buyer_ivan", "firstName": "Ivan", "lastName": "P" },
"expiresAt": "2026-07-26T05:00:00Z",
"adminRole": "admin"
}
```
`adminRole` absent/null → treat as non-admin regardless of what `/backoffice/**` UI is reachable client-side.
### 12.2 HttpOnly session cookie
**Gap:** the customer session cookie (`webSessionID`, `services/auth.service.ts`) is set via `document.cookie` from the frontend, which means it cannot be `HttpOnly` — only a `Set-Cookie` response header from the backend can set that flag, and JS-set cookies are readable by any injected script. Client mitigation: CSP hardened on all three nginx tenant blocks (was missing entirely on two of three) as defense-in-depth, but this does not close the gap.
**Ask:** `POST /users/sessions` and `GET /users/sessions/{id}` issue the session id via `Set-Cookie: webSessionID=…; HttpOnly; Secure; SameSite=Lax; Max-Age=…` instead of (or in addition to, during migration) returning it in the JSON body. Once that ships, the frontend stops writing `document.cookie` itself and relies on the browser sending the cookie automatically; `credentials: 'include'` needs enabling on the relevant HTTP calls.
### 12.3 Server-side order pricing
**Gap:** `POST` order creation (§7) let the client send a computed, discount-applied `price` per line item with no server-side revalidation. Client fix already shipped: `CreateOrderRequest.items` no longer sends `price` — only `{ productId, name, quantity }`.
**Ask:** the order-creation endpoint must price every line item itself by looking up `productId` in its own catalog (applying whatever discount/promo logic is authoritative server-side), and reject/[400] if the resulting total doesn't reconcile with what the client displayed (or just recompute and use the server total as-of-record, ignoring any client total entirely). Example of the request shape now sent:
```json
{
"items": [{ "productId": "prod_1042", "name": "Sample Product", "quantity": 2 }],
"customer": { "name": "Ivan P", "email": "ivan@example.com", "phone": "79991234567" },
"payment": { "method": "card", "currency": "RUB" }
}
```
Separately, `createCartPayment()` (payment-gateway charge creation) still sends a client-computed `amount` — that field can't simply be dropped, since it's what tells the payment provider how much to charge. That endpoint must independently revalidate `amount` against its own pricing before creating the charge, and reject on mismatch.
### 12.4 Real order audit trail
**Gap:** `AdminOrder` had no actor/audit field at all. Client fix already shipped: `AdminOrderTimelineEntry.actor` now exists and is populated from the signed-in admin's display name in the local mock gateway — but that's client-only bookkeeping with no server-side record.
**Ask:** when admin Orders CRUD gets a real backend (§10, step 6), every mutating endpoint (`updateStatus`, `requestRefund`, `addNote`, etc.) should record who performed the action server-side (from the authenticated session/JWT, not a client-supplied field) and return it in the order/timeline response:
```json
{
"timeline": [
{ "status": "processing", "timestamp": "2026-08-13T10:15:00Z", "eventKey": "statusChanged", "actor": "anna@dexar.market" }
]
}
```
`actor` must be derived server-side from the authenticated caller, never trusted from the request body.
### 12.5 Back-in-stock ("Notify Me") subscription
**Gap:** the "Notify Me" button on out-of-stock products had no real subscription mechanism at all - it just toggled wishlist. Client fix already shipped: `notifyMe()` now calls `POST /items/{id}/notify-me` and, if that fails (today it always will - the endpoint doesn't exist), falls back to a local-only record in `localStorage['restockSubscriptions']` so the request isn't silently dropped while waiting on the backend. The shopper sees the same confirmation either way.
**Ask:** implement `POST /items/{id}/notify-me`, plus whatever mechanism actually sends the notification once the item restocks (Telegram message, most likely, given the rest of the auth stack). Request body sent today:
```json
{ "telegramUserId": "8823771" }
```
`telegramUserId` may be `null` for a non-Telegram web session - decide whether to also accept an email address as an alternative identifier (the frontend has no email capture on this flow today, so that would need a small frontend addition too). Once this ships, the frontend's localStorage fallback becomes purely a resilience path rather than the common case, and could optionally sync any locally-queued subscriptions on next successful call.
### 12.6 Trending search terms
**Gap:** `SearchTrendingService.loadTrending()` is a stub returning `of(null)` - no trending-searches endpoint exists. It already degrades gracefully (UI hides the trending section rather than showing an error), so this is purely a missing-feature gap, not a bug.
**Ask:** an endpoint returning the top N search queries over some recent window, e.g.:
```json
{ "trending": [{ "query": "wireless earbuds", "count": 214 }, { "query": "winter jacket", "count": 187 }] }
```
Once it exists, wire `loadTrending()` to it and map `query` -> `SearchSuggestion.title/text`.

View File

@@ -93,6 +93,8 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
} }
# Template for onboarding a new marketplace tenant. # Template for onboarding a new marketplace tenant.
@@ -178,4 +180,6 @@ server {
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
} }

View File

@@ -1,7 +1,7 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { languageGuard } from './guards/language.guard'; import { languageGuard } from './guards/language.guard';
import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard'; import { projectEditorDirtyGuard } from './features/project-editor/guards/project-editor-dirty.guard';
import { adminAuthGuard } from './core/admin-auth/admin-auth.guard'; import { adminAuthGuard, requireAdminPermission } from './core/admin-auth/admin-auth.guard';
import { authRoutes } from './core/auth/auth.routes'; import { authRoutes } from './core/auth/auth.routes';
import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard'; import { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard'; import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
@@ -229,6 +229,7 @@ const coreRoutes: Routes = [
}, },
{ {
path: 'users', path: 'users',
canActivate: [requireAdminPermission('users.manage')],
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent), loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
data: { data: {
titleKey: 'adminShell.pages.users.title', titleKey: 'adminShell.pages.users.title',

View File

@@ -1,6 +1,7 @@
import { inject } from '@angular/core'; import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router'; import { CanActivateFn } from '@angular/router';
import { AdminAuthService } from './admin-auth.service'; import { AdminAuthService } from './admin-auth.service';
import { AdminPermissionsService } from './admin-permissions.service';
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */ /** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
export const adminAuthGuard: CanActivateFn = () => { export const adminAuthGuard: CanActivateFn = () => {
@@ -13,3 +14,22 @@ export const adminAuthGuard: CanActivateFn = () => {
adminAuth.requestLogin(); adminAuth.requestLogin();
return false; return false;
}; };
/**
* UI-only gate for a specific permission, on top of adminAuthGuard's
* authentication check. See AdminPermissionsService for why this is
* cosmetic until the backend ships real admin-role enforcement.
*/
export function requireAdminPermission(permission: string): CanActivateFn {
return () => {
const adminAuth = inject(AdminAuthService);
const permissions = inject(AdminPermissionsService);
if (!adminAuth.isAuthenticated()) {
adminAuth.requestLogin();
return false;
}
return permissions.has(permission);
};
}

View File

@@ -0,0 +1,41 @@
import { Injectable, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { AdminAuthService } from './admin-auth.service';
import { AdminUsersLocalGateway } from '../../features/admin/users/services/admin-users-local.gateway';
/**
* UI-only permission gate for the live Telegram/QR admin auth (Mechanism A),
* which carries no role claim of its own (see admin-auth.service.ts). This
* derives a permission set by matching the signed-in Telegram username
* against the mock Users domain's roleId - the same local-only stand-in the
* rest of the Users admin domain already uses (see BACKEND-API-REFERENCE.md
* §8 "Users - MOCK-ONLY, no seam"). It is cosmetic until a real backend
* ships either an admin-role claim on the session, or Mechanism B
* (Ed25519 JWT + PermissionService) goes live.
*/
@Injectable({ providedIn: 'root' })
export class AdminPermissionsService {
private readonly adminAuth = inject(AdminAuthService);
private readonly usersGateway = inject(AdminUsersLocalGateway);
private readonly users = toSignal(this.usersGateway.loadUsers(), { initialValue: [] });
private readonly roles = toSignal(this.usersGateway.loadRoles(), { initialValue: [] });
readonly permissions = computed<readonly string[]>(() => {
const session = this.adminAuth.session();
if (!session) {
return [];
}
const username = session.username?.replace(/^@/, '');
const matchedUser = this.users().find(user => user.telegramUsername.replace(/^@/, '') === username);
if (!matchedUser) {
return [];
}
return this.roles().find(role => role.id === matchedUser.roleId)?.permissions ?? [];
});
has(permission: string): boolean {
const permissions = this.permissions();
return permissions.includes('*') || permissions.includes(permission);
}
}

View File

@@ -17,6 +17,24 @@ export interface AuthError {
status?: number; status?: number;
} }
/**
* Maps the backend error envelope's `error.code` (see
* BACKEND-API-REFERENCE.md §5) to the client's AuthErrorCode screens.
* Only codes with a dedicated screen are mapped; anything else falls back
* to the HTTP-status-derived code via authErrorCodeFromStatus.
*/
const BACKEND_ERROR_CODE_MAP: Record<string, AuthErrorCode> = {
TOKEN_EXPIRED: 'session-expired',
INVALID_SIGNATURE: 'invalid-signature',
UNAUTHENTICATED: 'unauthorized',
FORBIDDEN: 'forbidden',
SERVICE_UNAVAILABLE: 'backend-unavailable',
};
export function authErrorCodeFromBackendCode(code: unknown): AuthErrorCode | undefined {
return typeof code === 'string' ? BACKEND_ERROR_CODE_MAP[code] : undefined;
}
/** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */ /** Maps a backend HTTP status to the AuthErrorCode screen it should route to. */
export function authErrorCodeFromStatus(status: number): AuthErrorCode { export function authErrorCodeFromStatus(status: number): AuthErrorCode {
switch (status) { switch (status) {

View File

@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { catchError, switchMap, tap, throwError } from 'rxjs'; import { catchError, switchMap, tap, throwError } from 'rxjs';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AuthTokenPair } from '../models/auth-api.model'; import { AuthTokenPair } from '../models/auth-api.model';
import { AuthError, authErrorCodeFromStatus } from '../models/auth-error.model'; import { AuthError, authErrorCodeFromBackendCode, authErrorCodeFromStatus } from '../models/auth-error.model';
import { AuthApiService } from './auth-api.service'; import { AuthApiService } from './auth-api.service';
import { Ed25519KeypairService } from './ed25519-keypair.service'; import { Ed25519KeypairService } from './ed25519-keypair.service';
import { SessionService } from './session.service'; import { SessionService } from './session.service';
@@ -109,7 +109,9 @@ export class AuthService {
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError { private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
if (error instanceof HttpErrorResponse) { if (error instanceof HttpErrorResponse) {
return { code: authErrorCodeFromStatus(error.status), message: error.message, status: error.status }; const bodyCode = (error.error as { error?: { code?: unknown } } | null)?.error?.code;
const code = authErrorCodeFromBackendCode(bodyCode) ?? authErrorCodeFromStatus(error.status);
return { code, message: error.message, status: error.status };
} }
if (error instanceof Error) { if (error instanceof Error) {
return { code: fallbackCode, message: error.message }; return { code: fallbackCode, message: error.message };

View File

@@ -1,20 +1,13 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
import { ApiCategoryRepository } from './repositories/api-category.repository'; import { ApiCategoryRepository } from './repositories/api-category.repository';
import { CategoryRepository } from './repositories/category.repository'; import { CategoryRepository } from './repositories/category.repository';
/**
* No mock CategoryRepository implementation exists - same dead branch as
* PRODUCT_DATA_PROVIDER. Always resolved to the real API repository
* regardless of getCategoryProviderMode(); removed the dead switch.
*/
export const CATEGORY_REPOSITORY = new InjectionToken<CategoryRepository>('CATEGORY_REPOSITORY', { export const CATEGORY_REPOSITORY = new InjectionToken<CategoryRepository>('CATEGORY_REPOSITORY', {
providedIn: 'root', providedIn: 'root',
factory: () => { factory: () => inject(ApiCategoryRepository)
const strategy = inject(RuntimeProviderStrategyService);
const apiRepository = inject(ApiCategoryRepository);
switch (strategy.getCategoryProviderMode()) {
case 'mock':
case 'remote-config':
case 'api':
default:
return apiRepository;
}
}
}); });

View File

@@ -1,20 +1,14 @@
import { InjectionToken, inject } from '@angular/core'; import { InjectionToken, inject } from '@angular/core';
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
import { ApiProductDataProvider } from './providers/api-product-data.provider'; import { ApiProductDataProvider } from './providers/api-product-data.provider';
import { ProductDataProvider } from './providers/product-data-provider.interface'; import { ProductDataProvider } from './providers/product-data-provider.interface';
/**
* No mock ProductDataProvider implementation exists - RuntimeProviderStrategyService.
* getProductProviderMode() can report 'mock', but there was never a branch that acted
* on it, so this always resolved to the real API provider regardless. Removed the dead
* switch instead of leaving code that implies a mock mode which doesn't exist.
*/
export const PRODUCT_DATA_PROVIDER = new InjectionToken<ProductDataProvider>('PRODUCT_DATA_PROVIDER', { export const PRODUCT_DATA_PROVIDER = new InjectionToken<ProductDataProvider>('PRODUCT_DATA_PROVIDER', {
providedIn: 'root', providedIn: 'root',
factory: () => { factory: () => inject(ApiProductDataProvider)
const strategy = inject(RuntimeProviderStrategyService);
const apiProvider = inject(ApiProductDataProvider);
switch (strategy.getProductProviderMode()) {
case 'mock':
case 'remote-config':
case 'api':
default:
return apiProvider;
}
}
}); });

View File

@@ -1,12 +1,15 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { PageConfig } from '../../shared/models/config'; import { PageConfig } from '../../shared/models/config';
import { PageRenderModel } from '../page-renderer/page-renderer.model'; import { PageRenderModel } from '../page-renderer/page-renderer.model';
import { SectionRendererService } from '../section-renderer/section-renderer.service'; import { SectionRendererService } from '../section-renderer/section-renderer.service';
import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config'; import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config';
import { SectionConfig } from '../../shared/models/config'; import { SectionConfig } from '../../shared/models/config';
import { ConfigService } from '../../core/config/config.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SectionEngineService { export class SectionEngineService {
private readonly configService = inject(ConfigService);
constructor(private readonly sectionRenderer: SectionRendererService) {} constructor(private readonly sectionRenderer: SectionRendererService) {}
toPageRenderModel(page: PageConfig): PageRenderModel { toPageRenderModel(page: PageConfig): PageRenderModel {
@@ -27,12 +30,23 @@ export class SectionEngineService {
}; };
} }
/**
* Falls back to the site-wide builder setting (bootstrap.layout.type,
* "Site Layout" in the theme editor) when a page has no layout of its
* own - previously that global setting was saved but never read by
* rendering at all, so it had no visible effect.
*/
private resolveLayoutType(layout: PageConfig['layout']): string { private resolveLayoutType(layout: PageConfig['layout']): string {
if (typeof layout === 'string') { if (typeof layout === 'string') {
return layout; return layout;
} }
return (layout as PlatformLayoutConfig)?.type ?? 'default'; const pageLayoutType = (layout as PlatformLayoutConfig)?.type;
if (pageLayoutType) {
return pageLayoutType;
}
return this.configService.getBootstrapSnapshot()?.layout?.type ?? 'default';
} }
private normalizeSectionsByLayout(sections: PageConfig['sections'], layoutType: string): PageConfig['sections'] { private normalizeSectionsByLayout(sections: PageConfig['sections'], layoutType: string): PageConfig['sections'] {

View File

@@ -1,5 +1,6 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators'; import { forkJoin, Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { import {
AdminAnalyticsDateRange, AdminAnalyticsDateRange,
AdminAnalyticsSeriesPoint, AdminAnalyticsSeriesPoint,
@@ -62,7 +63,13 @@ export class AdminAnalyticsFacade {
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info')); readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
private readonly cancelPreviousLoad$ = new Subject<void>();
load(): void { load(): void {
// Cancel any still-in-flight previous load so a rapid setDateRange() double-call
// can't have a stale response overwrite a newer one.
this.cancelPreviousLoad$.next();
this.loading.set(true); this.loading.set(true);
this.error.set(false); this.error.set(false);
this.dashboardFacade.ensureLoaded(); this.dashboardFacade.ensureLoaded();
@@ -74,10 +81,13 @@ export class AdminAnalyticsFacade {
})), })),
); );
const fail = (): void => { this.loading.set(false); this.error.set(true); }; forkJoin({
orderResult: this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }),
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ productResult: this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }),
next: orderResult => { categories: this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }),
reviewResult: this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }),
}).pipe(take(1), takeUntil(this.cancelPreviousLoad$)).subscribe({
next: ({ orderResult, productResult, categories, reviewResult }) => {
const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000; const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000;
const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff); const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
@@ -89,43 +99,28 @@ export class AdminAnalyticsFacade {
const ordersCount = inRange.length; const ordersCount = inRange.length;
const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size; const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size;
this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ const products = productResult.items;
next: productResult => { const reviews = reviewResult.items;
this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
next: categories => {
this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: reviewResult => {
const products = productResult.items;
const reviews = reviewResult.items;
this.summary.set({ this.summary.set({
revenueTotal, revenueTotal,
currency: inRange[0]?.currency ?? 'RUB', currency: inRange[0]?.currency ?? 'RUB',
ordersCount, ordersCount,
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0, avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
productsCount: products.length, productsCount: products.length,
categoriesCount: categories.length, categoriesCount: categories.length,
customersCount: uniqueCustomers, customersCount: uniqueCustomers,
conversionRate: null, conversionRate: null,
});
this.lowStockProducts.set(this.buildLowStock(products));
this.productAnalytics.set(this.buildProductAnalytics(products));
this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items));
this.recommendations.set(this.buildRecommendations(products, categories));
this.loading.set(false);
},
error: fail
});
},
error: fail
});
},
error: fail
}); });
this.lowStockProducts.set(this.buildLowStock(products));
this.productAnalytics.set(this.buildProductAnalytics(products));
this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items));
this.recommendations.set(this.buildRecommendations(products, categories));
this.loading.set(false);
}, },
error: fail error: () => { this.loading.set(false); this.error.set(true); }
}); });
} }

View File

@@ -64,6 +64,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (error) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (treeRows.length === 0) { } @else if (treeRows.length === 0) {
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate"> <app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
<span slot="actions"> <span slot="actions">

View File

@@ -51,6 +51,7 @@ export class AdminCategoriesListComponent {
@Input() flatCategories: AdminCategory[] = []; @Input() flatCategories: AdminCategory[] = [];
@Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean }; @Input() filters!: { search: string; visibility: 'all' | 'visible' | 'hidden'; includeDeleted: boolean };
@Input() loading = false; @Input() loading = false;
@Input() error: string | null = null;
@Input() viewMode: AdminCategoriesViewMode = 'tree'; @Input() viewMode: AdminCategoriesViewMode = 'tree';
@Input() density: AdminCategoriesDensity = 'comfortable'; @Input() density: AdminCategoriesDensity = 'comfortable';
@Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS]; @Input() visibleColumns: AdminCategoryColumn[] = [...ALL_CATEGORY_COLUMNS];

View File

@@ -29,7 +29,7 @@
<app-form-field [label]="'adminCategories.title' | translate" [required]="true"> <app-form-field [label]="'adminCategories.title' | translate" [required]="true">
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" /> <app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
</app-form-field> </app-form-field>
<app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugTaken ? ('adminCategories.slugTaken' | translate) : null"> <app-form-field [label]="'adminCategories.slug' | translate" [required]="true" [hint]="'adminProducts.slugHint' | translate" [error]="slugCheckError ? ('adminCategories.slugCheckError' | translate) : (slugTaken ? ('adminCategories.slugTaken' | translate) : null)">
<app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" /> <app-input [ngModel]="category.slug" (ngModelChange)="updateField('slug', $event)" />
</app-form-field> </app-form-field>
<label><span>{{ 'adminCategories.parent' | translate }}</span> <label><span>{{ 'adminCategories.parent' | translate }}</span>

View File

@@ -41,6 +41,7 @@ export class AdminCategoryFormComponent {
@Input() breadcrumb: string[] = []; @Input() breadcrumb: string[] = [];
@Input() children: AdminCategory[] = []; @Input() children: AdminCategory[] = [];
@Input() slugTaken = false; @Input() slugTaken = false;
@Input() slugCheckError = false;
@Input() locales: string[] = ['en']; @Input() locales: string[] = ['en'];
@Input() mode: 'create' | 'edit' = 'create'; @Input() mode: 'create' | 'edit' = 'create';
@Input() health!: AdminCategoryHealth; @Input() health!: AdminCategoryHealth;

View File

@@ -1,5 +1,5 @@
import { Injectable, computed, inject, signal } from '@angular/core'; import { Injectable, computed, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators'; import { catchError, of, take } from 'rxjs';
import { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model'; import { AdminCategory, AdminCategoryEditorMode, AdminCategoryListFilters } from '../models/admin-category.model';
import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory'; import { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token'; import { ADMIN_CATEGORIES_GATEWAY } from '../services/admin-categories-gateway.token';
@@ -131,10 +131,13 @@ export class AdminCategoriesFacade {
readonly filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false }); readonly filters = signal<AdminCategoryListFilters>({ search: '', visibility: 'all', includeDeleted: false });
readonly categories = signal<AdminCategory[]>([]); readonly categories = signal<AdminCategory[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly draft = signal<AdminCategory | null>(null); readonly draft = signal<AdminCategory | null>(null);
readonly editorMode = signal<AdminCategoryEditorMode>('create'); readonly editorMode = signal<AdminCategoryEditorMode>('create');
readonly dirty = signal(false); readonly dirty = signal(false);
readonly slugTaken = signal(false); readonly slugTaken = signal(false);
readonly slugCheckError = signal(false);
readonly mutationError = signal<string | null>(null);
private savedSnapshot: string | null = null; private savedSnapshot: string | null = null;
private draftStorageKey: string | null = null; private draftStorageKey: string | null = null;
@@ -173,6 +176,7 @@ export class AdminCategoriesFacade {
*/ */
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({ this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
next: categories => { next: categories => {
this.categories.set(categories); this.categories.set(categories);
@@ -181,6 +185,7 @@ export class AdminCategoriesFacade {
error: () => { error: () => {
this.categories.set([]); this.categories.set([]);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }
@@ -305,16 +310,28 @@ export class AdminCategoriesFacade {
const draft = this.draft(); const draft = this.draft();
if (!draft || !draft.slug) { if (!draft || !draft.slug) {
this.slugTaken.set(false); this.slugTaken.set(false);
this.slugCheckError.set(false);
return; return;
} }
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(take(1)) this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(
.subscribe(taken => this.slugTaken.set(taken)); take(1),
catchError(() => {
this.slugCheckError.set(true);
return of(true);
})
).subscribe(taken => {
this.slugTaken.set(taken);
if (!taken) {
this.slugCheckError.set(false);
}
});
} }
saveDraft(publish: boolean): void { saveDraft(publish: boolean, onSuccess?: () => void): void {
const draft = this.draft(); const draft = this.draft();
if (!draft || this.slugTaken()) return; if (!draft || this.slugTaken() || this.slugCheckError()) return;
this.mutationError.set(null);
const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() }; const toSave: AdminCategory = { ...draft, status: publish ? 'published' : 'draft', updatedAt: new Date().toISOString() };
const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave); const request = this.editorMode() === 'create' ? this.gateway.createCategory(toSave) : this.gateway.updateCategory(toSave);
@@ -327,7 +344,9 @@ export class AdminCategoriesFacade {
this.savedSnapshot = JSON.stringify(saved); this.savedSnapshot = JSON.stringify(saved);
this.dirty.set(false); this.dirty.set(false);
this.loadList(); this.loadList();
} onSuccess?.();
},
error: () => this.mutationError.set('common.errorDescription')
}); });
} }
@@ -342,7 +361,11 @@ export class AdminCategoriesFacade {
} }
deleteOne(id: string): void { deleteOne(id: string): void {
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({ next: () => { this.loadList(); this.loadDashboardStats(); } }); this.mutationError.set(null);
this.gateway.deleteCategory(id).pipe(take(1)).subscribe({
next: () => { this.loadList(); this.loadDashboardStats(); },
error: () => this.mutationError.set('common.errorDescription')
});
} }
restoreOne(id: string): void { restoreOne(id: string): void {

View File

@@ -1,20 +1,25 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AdminCategoriesFacade } from '../facade/admin-categories.facade'; import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
import { AdminCategoriesListComponent } from '../components/admin-categories-list.component'; import { AdminCategoriesListComponent } from '../components/admin-categories-list.component';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { TranslateService } from '../../../../i18n/translate.service'; import { TranslateService } from '../../../../i18n/translate.service';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-admin-categories-list-page', selector: 'app-admin-categories-list-page',
standalone: true, standalone: true,
imports: [AdminCategoriesListComponent], imports: [AdminCategoriesListComponent, ConfirmDialogComponent, DialogComponent, ButtonComponent, TranslatePipe],
template: `<app-admin-categories-list template: `<app-admin-categories-list
[treeRows]="facade.visibleTreeRows()" [treeRows]="facade.visibleTreeRows()"
[flatCategories]="facade.filteredCategories()" [flatCategories]="facade.filteredCategories()"
[allCategories]="facade.categories()" [allCategories]="facade.categories()"
[filters]="facade.filters()" [filters]="facade.filters()"
[loading]="facade.loading()" [loading]="facade.loading()"
[error]="facade.error()"
[viewMode]="facade.viewMode()" [viewMode]="facade.viewMode()"
[density]="facade.density()" [density]="facade.density()"
[visibleColumns]="facade.visibleColumns()" [visibleColumns]="facade.visibleColumns()"
@@ -37,14 +42,44 @@ import { TranslateService } from '../../../../i18n/translate.service';
(selectionChange)="facade.toggleSelection($event.id, $event.checked)" (selectionChange)="facade.toggleSelection($event.id, $event.checked)"
(selectAll)="facade.toggleAll($event)" (selectAll)="facade.toggleAll($event)"
(bulkVisibility)="facade.applyBulkVisibility($event)" (bulkVisibility)="facade.applyBulkVisibility($event)"
(bulkDelete)="facade.applyBulkDelete()" (bulkDelete)="bulkDelete()"
(bulkDuplicate)="facade.applyBulkDuplicate()" (bulkDuplicate)="facade.applyBulkDuplicate()"
(bulkAssignParent)="facade.applyBulkAssignParent($event)" (bulkAssignParent)="facade.applyBulkAssignParent($event)"
(bulkAssignImage)="facade.applyBulkAssignImage($event)" (bulkAssignImage)="facade.applyBulkAssignImage($event)"
(bulkExport)="facade.exportSelectedAsCsv()" (bulkExport)="facade.exportSelectedAsCsv()"
(viewModeChange)="facade.setViewMode($event)" (viewModeChange)="facade.setViewMode($event)"
(densityChange)="facade.setDensity($event)" (densityChange)="facade.setDensity($event)"
(columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`, (columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />
<app-confirm-dialog
[open]="!!pendingDeleteId()"
[titleText]="'adminCategories.deleteTitle' | translate"
[message]="'adminCategories.confirmDelete' | translate"
[destructive]="true"
(confirmed)="confirmDelete()"
(cancelled)="pendingDeleteId.set(null)" />
<app-confirm-dialog
[open]="bulkDeleteConfirmOpen()"
[titleText]="'adminCategories.deleteTitle' | translate"
[message]="'adminCategories.confirmBulkDelete' | translate"
[destructive]="true"
(confirmed)="confirmBulkDelete()"
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
@if (deleteBlockedMessage()) {
<app-dialog [open]="true" [titleText]="'adminCategories.deleteBlocked' | translate" size="sm" (closed)="deleteBlockedMessage.set(null)">
<p>{{ deleteBlockedMessage() }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="deleteBlockedMessage.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}`,
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AdminCategoriesListPageComponent { export class AdminCategoriesListPageComponent {
@@ -57,6 +92,10 @@ export class AdminCategoriesListPageComponent {
readonly isExpandedFn = (id: string) => this.facade.isExpanded(id); readonly isExpandedFn = (id: string) => this.facade.isExpanded(id);
readonly canDeleteFn = (id: string) => this.facade.canDelete(id); readonly canDeleteFn = (id: string) => this.facade.canDelete(id);
readonly pendingDeleteId = signal<string | null>(null);
readonly bulkDeleteConfirmOpen = signal(false);
readonly deleteBlockedMessage = signal<string | null>(null);
constructor() { constructor() {
this.facade.loadList(); this.facade.loadList();
this.facade.loadDashboardStats(); this.facade.loadDashboardStats();
@@ -75,12 +114,27 @@ export class AdminCategoriesListPageComponent {
deleteOne(id: string): void { deleteOne(id: string): void {
if (!this.facade.canDelete(id)) { if (!this.facade.canDelete(id)) {
window.alert(this.translate.t('adminCategories.deleteBlocked')); this.deleteBlockedMessage.set(this.translate.t('adminCategories.deleteBlocked'));
return; return;
} }
if (window.confirm(this.translate.t('adminCategories.confirmDelete'))) { this.pendingDeleteId.set(id);
}
confirmDelete(): void {
const id = this.pendingDeleteId();
if (id) {
this.facade.deleteOne(id); this.facade.deleteOne(id);
} }
this.pendingDeleteId.set(null);
}
bulkDelete(): void {
this.bulkDeleteConfirmOpen.set(true);
}
confirmBulkDelete(): void {
this.facade.applyBulkDelete();
this.bulkDeleteConfirmOpen.set(false);
} }
private lang(): string { private lang(): string {

View File

@@ -4,12 +4,22 @@ import { AdminCategoriesFacade } from '../facade/admin-categories.facade';
import { AdminCategoryFormComponent } from '../components/admin-category-form.component'; import { AdminCategoryFormComponent } from '../components/admin-category-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-category-editor-page', selector: 'app-admin-category-editor-page',
standalone: true, standalone: true,
imports: [AdminCategoryFormComponent, TranslatePipe], imports: [AdminCategoryFormComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`, template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-category-form [category]="draft" [parentOptions]="parentOptions()" [breadcrumb]="facade.breadcrumbFor(draft.parentId)" [children]="facade.childrenOf(draft.id)" [slugTaken]="facade.slugTaken()" [slugCheckError]="facade.slugCheckError()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (categoryChange)="facade.updateDraft($event)" (saveDraft)="save(false)" (publish)="save(true)" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}`,
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`], styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
@@ -41,8 +51,9 @@ export class AdminCategoryEditorPageComponent {
} }
save(publish: boolean): void { save(publish: boolean): void {
this.facade.saveDraft(publish); this.facade.saveDraft(publish, () => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']); void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
});
} }
private descendantIds(id: string): string[] { private descendantIds(id: string): string[] {

View File

@@ -59,8 +59,7 @@ export class AdminCategoriesApiGateway implements AdminCategoriesGateway {
params = params.set('excludingId', excludingId); params = params.set('excludingId', excludingId);
} }
return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe( return this.http.get<{ taken: boolean }>(`${this.baseUrl}/slug-taken`, { params }).pipe(
map(response => response.taken), map(response => response.taken)
catchError(() => of(false))
); );
} }
} }

View File

@@ -10,8 +10,11 @@ export class AdminCustomersFacade {
readonly customers = signal<AdminCustomer[]>([]); readonly customers = signal<AdminCustomer[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly search = signal(''); readonly search = signal('');
readonly selected = signal<AdminCustomer | null>(null); readonly selected = signal<AdminCustomer | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] { private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
const byEmail = new Map<string, AdminOrder[]>(); const byEmail = new Map<string, AdminOrder[]>();
@@ -41,6 +44,7 @@ export class AdminCustomersFacade {
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => { next: result => {
this.customers.set(this.buildCustomers(result.items)); this.customers.set(this.buildCustomers(result.items));
@@ -49,16 +53,24 @@ export class AdminCustomersFacade {
error: () => { error: () => {
this.customers.set([]); this.customers.set([]);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }
loadDetail(email: string): void { loadDetail(email: string): void {
this.selectedLoading.set(true);
this.selectedError.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => { next: result => {
const decoded = decodeURIComponent(email); const decoded = decodeURIComponent(email);
const customers = this.buildCustomers(result.items); const customers = this.buildCustomers(result.items);
this.selected.set(customers.find(customer => customer.email === decoded) ?? null); this.selected.set(customers.find(customer => customer.email === decoded) ?? null);
this.selectedLoading.set(false);
},
error: () => {
this.selectedLoading.set(false);
this.selectedError.set('common.errorDescription');
} }
}); });
} }

View File

@@ -48,6 +48,12 @@
<app-order-timeline [entries]="activity()" [showOrderNumber]="true" /> <app-order-timeline [entries]="activity()" [showOrderNumber]="true" />
</section> </section>
</main> </main>
} @else if (facade.selectedError()) {
<div class="customer-detail-error" role="alert">
<p>{{ 'common.errorTitle' | translate }}</p>
<p>{{ 'common.errorDescription' | translate }}</p>
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminCustomers.back' | translate }}</app-button>
</div>
} @else { } @else {
<p>{{ 'common.loading' | translate }}</p> <p>{{ 'common.loading' | translate }}</p>
} }

View File

@@ -8,6 +8,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.filteredCustomers().length === 0) { } @else if (facade.filteredCustomers().length === 0) {
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" /> <app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
} @else { } @else {

View File

@@ -53,9 +53,16 @@
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="facade.applyBulkVisible(false)">{{ 'adminModeration.hideSelected' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="facade.applyBulkVisible(false)">{{ 'adminModeration.hideSelected' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="facade.applyBulkDelete()">{{ 'adminModeration.archiveSelected' | translate }}</app-button> <app-button variant="danger" size="sm" (click)="bulkDelete()">{{ 'adminModeration.deleteSelected' | translate }}</app-button>
</div> </div>
} }
<app-confirm-dialog
[open]="bulkDeleteConfirmOpen()"
[titleText]="'adminModeration.deleteSelected' | translate"
[message]="'adminModeration.confirmBulkDelete' | translate"
[destructive]="true"
(confirmed)="confirmBulkDelete()"
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
@if (facade.loading()) { @if (facade.loading()) {
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true"> <div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">

View File

@@ -16,11 +16,12 @@ import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.compo
import { CardComponent } from '../../../../shared/ui/card/card.component'; import { CardComponent } from '../../../../shared/ui/card/card.component';
import { ModerationDashboardComponent } from '../components/moderation-dashboard/moderation-dashboard.component'; import { ModerationDashboardComponent } from '../components/moderation-dashboard/moderation-dashboard.component';
import { ReviewHealthWidgetComponent, ReviewHealthItem } from '../components/review-health-widget/review-health-widget.component'; import { ReviewHealthWidgetComponent, ReviewHealthItem } from '../components/review-health-widget/review-health-widget.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
@Component({ @Component({
selector: 'app-admin-reviews-list-page', selector: 'app-admin-reviews-list-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, ModerationDashboardComponent, ReviewHealthWidgetComponent], imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, ModerationDashboardComponent, ReviewHealthWidgetComponent, ConfirmDialogComponent],
templateUrl: './admin-reviews-list-page.component.html', templateUrl: './admin-reviews-list-page.component.html',
styleUrls: ['./admin-reviews-list-page.component.scss'], styleUrls: ['./admin-reviews-list-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -35,6 +36,7 @@ export class AdminReviewsListPageComponent {
readonly allColumns = ALL_REVIEW_COLUMNS; readonly allColumns = ALL_REVIEW_COLUMNS;
protected readonly columnsPanelOpen = signal(false); protected readonly columnsPanelOpen = signal(false);
protected readonly bulkStatusValue = signal<AdminReviewStatus>('approved'); protected readonly bulkStatusValue = signal<AdminReviewStatus>('approved');
protected readonly bulkDeleteConfirmOpen = signal(false);
constructor() { constructor() {
this.facade.loadList(); this.facade.loadList();
@@ -45,6 +47,15 @@ export class AdminReviewsListPageComponent {
return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize)); return Math.max(1, Math.ceil(this.facade.total() / this.facade.filters().pageSize));
} }
bulkDelete(): void {
this.bulkDeleteConfirmOpen.set(true);
}
confirmBulkDelete(): void {
this.facade.applyBulkDelete();
this.bulkDeleteConfirmOpen.set(false);
}
view(id: string): void { view(id: string): void {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', id]); void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', id]);
} }

View File

@@ -13,6 +13,9 @@
} }
</div> </div>
<p class="order-timeline__note">{{ entry.note }}</p> <p class="order-timeline__note">{{ entry.note }}</p>
@if (entry.actor) {
<span class="order-timeline__actor">{{ entry.actor }}</span>
}
</div> </div>
</li> </li>
} }

View File

@@ -55,3 +55,10 @@
font-size: var(--font-size-sm, 0.8125rem); font-size: var(--font-size-sm, 0.8125rem);
color: var(--text-secondary); color: var(--text-secondary);
} }
.order-timeline__actor {
display: block;
margin-top: 2px;
font-size: var(--font-size-xs, 0.75rem);
color: var(--text-light);
}

View File

@@ -8,6 +8,7 @@ export interface OrderTimelineEntry {
/** Pre-resolved, already-translated display text for this event. */ /** Pre-resolved, already-translated display text for this event. */
note: string; note: string;
orderNumber?: string; orderNumber?: string;
actor?: string;
} }
/** Reusable vertical event timeline - used on both the order detail page and the customer activity tab. */ /** Reusable vertical event timeline - used on both the order detail page and the customer activity tab. */

View File

@@ -37,7 +37,10 @@ export class AdminOrdersFacade {
readonly orders = signal<AdminOrder[]>([]); readonly orders = signal<AdminOrder[]>([]);
readonly total = signal(0); readonly total = signal(0);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly selected = signal<AdminOrder | null>(null); readonly selected = signal<AdminOrder | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
readonly viewMode = signal<AdminOrdersViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminOrdersViewMode) || 'table'); readonly viewMode = signal<AdminOrdersViewMode>((this.localStorage.getItem(VIEW_MODE_KEY) as AdminOrdersViewMode) || 'table');
readonly density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable'); readonly density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable');
@@ -76,6 +79,7 @@ export class AdminOrdersFacade {
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({ this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({
next: result => { next: result => {
this.orders.set(result.items); this.orders.set(result.items);
@@ -86,6 +90,7 @@ export class AdminOrdersFacade {
this.orders.set([]); this.orders.set([]);
this.total.set(0); this.total.set(0);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }
@@ -96,7 +101,18 @@ export class AdminOrdersFacade {
} }
loadDetail(id: string): void { loadDetail(id: string): void {
this.gateway.loadOrder(id).pipe(take(1)).subscribe({ next: order => this.selected.set(order) }); this.selectedLoading.set(true);
this.selectedError.set(null);
this.gateway.loadOrder(id).pipe(take(1)).subscribe({
next: order => {
this.selected.set(order);
this.selectedLoading.set(false);
},
error: () => {
this.selectedLoading.set(false);
this.selectedError.set('common.errorDescription');
}
});
} }
setStatus(id: string, status: AdminOrderStatus): void { setStatus(id: string, status: AdminOrderStatus): void {

View File

@@ -1,3 +1,5 @@
import { UUID } from '../../../../shared/types/primitive.types';
export type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded'; export type AdminOrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
export type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded'; export type AdminOrderPaymentStatus = 'unpaid' | 'paid' | 'refund_requested' | 'refunded';
@@ -33,6 +35,7 @@ export interface AdminOrderTimelineEntry {
status: AdminOrderStatus; status: AdminOrderStatus;
timestamp: string; timestamp: string;
eventKey: AdminOrderTimelineEventKey; eventKey: AdminOrderTimelineEventKey;
actor: string;
} }
export interface AdminOrder { export interface AdminOrder {
@@ -56,7 +59,7 @@ export interface AdminOrder {
* Absent means marketplace-owned, exactly like every order today - * Absent means marketplace-owned, exactly like every order today -
* nothing reads this field yet, no behavior change. * nothing reads this field yet, no behavior change.
*/ */
sellerId?: string; sellerId?: UUID;
} }
export interface AdminOrderListFilters { export interface AdminOrderListFilters {

View File

@@ -46,14 +46,17 @@
</div> </div>
<div class="card no-print"> <div class="card no-print">
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3> <h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)"> <select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)" [disabled]="isTerminal()">
@for (status of statuses; track status) { @for (status of selectableStatuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
} }
@if (isTerminal()) {
<option [value]="order.status">{{ ('adminOrders.status.' + order.status) | translate }}</option>
}
</select> </select>
<div class="actions"> <div class="actions">
<app-button variant="secondary" size="sm" (click)="requestRefund(order.id)">{{ 'adminOrders.requestRefund' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="requestRefund(order.id)" [disabled]="isTerminal()">{{ 'adminOrders.requestRefund' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="cancel(order.id)">{{ 'adminOrders.cancelOrder' | translate }}</app-button> <app-button variant="danger" size="sm" (click)="cancel(order.id)" [disabled]="isTerminal()">{{ 'adminOrders.cancelOrder' | translate }}</app-button>
</div> </div>
</div> </div>
</section> </section>
@@ -85,7 +88,27 @@
<app-button variant="secondary" size="sm" (click)="submitInternalNote(order.id)">{{ 'adminOrders.addNote' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="submitInternalNote(order.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
</div> </div>
</section> </section>
<app-confirm-dialog
[open]="!!pendingCancelId()"
[titleText]="'adminOrders.cancelOrder' | translate"
[message]="'adminOrders.confirmCancel' | translate"
[destructive]="true"
(confirmed)="confirmCancel()"
(cancelled)="pendingCancelId.set(null)" />
<app-confirm-dialog
[open]="!!pendingRefundId()"
[titleText]="'adminOrders.requestRefund' | translate"
[message]="'adminOrders.confirmRefund' | translate"
[destructive]="true"
(confirmed)="confirmRefund()"
(cancelled)="pendingRefundId.set(null)" />
</main> </main>
} @else if (facade.selectedError()) {
<div class="order-detail-error" role="alert">
<p>{{ 'common.errorTitle' | translate }}</p>
<p>{{ 'common.errorDescription' | translate }}</p>
<app-button variant="secondary" size="sm" (click)="back()">{{ 'adminOrders.back' | translate }}</app-button>
</div>
} @else { } @else {
<p>{{ 'common.loading' | translate }}</p> <p>{{ 'common.loading' | translate }}</p>
} }

View File

@@ -10,6 +10,7 @@ import { LanguageService } from '../../../../services/language.service';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { BadgeComponent } from '../../../../shared/ui/badge/badge.component'; import { BadgeComponent } from '../../../../shared/ui/badge/badge.component';
import { OrderTimelineComponent, OrderTimelineEntry } from '../components/order-timeline/order-timeline.component'; import { OrderTimelineComponent, OrderTimelineEntry } from '../components/order-timeline/order-timeline.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
const WORKFLOW_STEPS: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered']; const WORKFLOW_STEPS: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded']; const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
@@ -17,7 +18,7 @@ const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
@Component({ @Component({
selector: 'app-admin-order-detail-page', selector: 'app-admin-order-detail-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent], imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent, ConfirmDialogComponent],
templateUrl: './admin-order-detail-page.component.html', templateUrl: './admin-order-detail-page.component.html',
styleUrls: ['./admin-order-detail-page.component.scss'], styleUrls: ['./admin-order-detail-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -30,8 +31,12 @@ export class AdminOrderDetailPageComponent {
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
readonly statuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; readonly statuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
/** Terminal statuses are only reachable via the confirm-gated cancel()/requestRefund(), never the raw dropdown. */
readonly selectableStatuses: AdminOrderStatus[] = this.statuses.filter(status => !TERMINAL_STATUSES.includes(status));
readonly workflowSteps = WORKFLOW_STEPS; readonly workflowSteps = WORKFLOW_STEPS;
readonly noteDraft = signal(''); readonly noteDraft = signal('');
readonly pendingCancelId = signal<string | null>(null);
readonly pendingRefundId = signal<string | null>(null);
readonly internalNoteDraft = signal(''); readonly internalNoteDraft = signal('');
readonly isTerminal = computed(() => { readonly isTerminal = computed(() => {
@@ -54,6 +59,7 @@ export class AdminOrderDetailPageComponent {
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, { note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
status: this.translate.t('adminOrders.status.' + entry.status), status: this.translate.t('adminOrders.status.' + entry.status),
}), }),
actor: entry.actor,
})); }));
}); });
@@ -73,19 +79,36 @@ export class AdminOrderDetailPageComponent {
} }
setStatus(id: string, status: AdminOrderStatus): void { setStatus(id: string, status: AdminOrderStatus): void {
if (TERMINAL_STATUSES.includes(status)) {
// Unreachable from the dropdown (options are filtered), but guard anyway
// since terminal transitions must always go through the confirm dialog.
return;
}
this.facade.setStatus(id, status); this.facade.setStatus(id, status);
} }
cancel(id: string): void { cancel(id: string): void {
if (window.confirm(this.translate.t('adminOrders.confirmCancel'))) { this.pendingCancelId.set(id);
}
confirmCancel(): void {
const id = this.pendingCancelId();
if (id) {
this.facade.cancelOrder(id); this.facade.cancelOrder(id);
} }
this.pendingCancelId.set(null);
} }
requestRefund(id: string): void { requestRefund(id: string): void {
if (window.confirm(this.translate.t('adminOrders.confirmRefund'))) { this.pendingRefundId.set(id);
}
confirmRefund(): void {
const id = this.pendingRefundId();
if (id) {
this.facade.requestRefund(id); this.facade.requestRefund(id);
} }
this.pendingRefundId.set(null);
} }
submitNote(id: string): void { submitNote(id: string): void {

View File

@@ -37,23 +37,32 @@
<div class="bulk-actions"> <div class="bulk-actions">
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span> <span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)"> <select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
@for (status of statuses; track status) { @for (status of bulkSelectableStatuses; track status) {
@if (status !== 'all') { <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> } <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
} }
</select> </select>
<app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="applyBulkStatus()">{{ 'adminOrders.applyStatus' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="facade.applyBulkArchive(true)">{{ 'adminOrders.archiveSelected' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="facade.applyBulkArchive(true)">{{ 'adminOrders.archiveSelected' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="facade.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | translate }}</app-button>
<app-button variant="secondary" size="sm" (click)="printSelection()">{{ 'adminOrders.printSelected' | translate }}</app-button> <app-button variant="secondary" size="sm" (click)="printSelection()">{{ 'adminOrders.printSelected' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="facade.applyBulkDelete()">{{ 'adminProducts.bulkDelete' | translate }}</app-button> <app-button variant="danger" size="sm" (click)="bulkDelete()">{{ 'adminProducts.bulkDelete' | translate }}</app-button>
</div> </div>
} }
<app-confirm-dialog
[open]="bulkDeleteConfirmOpen()"
[titleText]="'adminOrders.bulkDeleteTitle' | translate"
[message]="'adminOrders.confirmBulkDelete' | translate"
[destructive]="true"
(confirmed)="confirmBulkDelete()"
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
@if (facade.loading()) { @if (facade.loading()) {
<div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true"> <div class="skeleton-rows" role="status" aria-live="polite" aria-busy="true">
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.orders().length === 0) { } @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" /> <app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
} @else { } @else {

View File

@@ -15,11 +15,12 @@ import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-sta
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { CardComponent } from '../../../../shared/ui/card/card.component'; import { CardComponent } from '../../../../shared/ui/card/card.component';
import { OrdersDashboardComponent } from '../components/orders-dashboard/orders-dashboard.component'; import { OrdersDashboardComponent } from '../components/orders-dashboard/orders-dashboard.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
@Component({ @Component({
selector: 'app-admin-orders-list-page', selector: 'app-admin-orders-list-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, OrdersDashboardComponent], imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, PaginationComponent, EmptyStateComponent, SkeletonComponent, CardComponent, OrdersDashboardComponent, ConfirmDialogComponent],
templateUrl: './admin-orders-list-page.component.html', templateUrl: './admin-orders-list-page.component.html',
styleUrls: ['./admin-orders-list-page.component.scss'], styleUrls: ['./admin-orders-list-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -30,9 +31,12 @@ export class AdminOrdersListPageComponent {
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
readonly statuses = ['all', 'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'] as const; readonly statuses = ['all', 'pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'] as const;
/** Bulk status change excludes terminal statuses - cancel/refund must go through the confirm-gated single-order flow. */
readonly bulkSelectableStatuses: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered'];
readonly allColumns = ALL_ORDER_COLUMNS; readonly allColumns = ALL_ORDER_COLUMNS;
protected readonly columnsPanelOpen = signal(false); protected readonly columnsPanelOpen = signal(false);
protected readonly bulkStatusValue = signal<AdminOrderStatus>('pending'); protected readonly bulkStatusValue = signal<AdminOrderStatus>('pending');
protected readonly bulkDeleteConfirmOpen = signal(false);
constructor() { constructor() {
this.facade.loadList(); this.facade.loadList();
@@ -59,6 +63,15 @@ export class AdminOrdersListPageComponent {
this.facade.applyBulkStatus(this.bulkStatusValue()); this.facade.applyBulkStatus(this.bulkStatusValue());
} }
bulkDelete(): void {
this.bulkDeleteConfirmOpen.set(true);
}
confirmBulkDelete(): void {
this.facade.applyBulkDelete();
this.bulkDeleteConfirmOpen.set(false);
}
printSelection(): void { printSelection(): void {
window.print(); window.print();
} }

View File

@@ -1,8 +1,9 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators'; import { delay } from 'rxjs/operators';
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model'; import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
import { AdminOrdersGateway } from './admin-orders-gateway.interface'; import { AdminOrdersGateway } from './admin-orders-gateway.interface';
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
const STATUSES: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded']; const STATUSES: AdminOrderStatus[] = ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'];
const CUSTOMER_NAMES = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan']; const CUSTOMER_NAMES = ['Anna Petrova', 'Karen Sargsyan', 'Ivan Ivanov', 'Mariam Grigoryan', 'Sergey Volkov', 'Lilit Hakobyan'];
@@ -10,8 +11,13 @@ const SEED_COUNT = 24;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AdminOrdersLocalGateway implements AdminOrdersGateway { export class AdminOrdersLocalGateway implements AdminOrdersGateway {
private readonly adminAuth = inject(AdminAuthService);
private cache: AdminOrder[] | null = null; private cache: AdminOrder[] | null = null;
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> { loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
const all = this.ensureData(); const all = this.ensureData();
const filtered = all const filtered = all
@@ -36,7 +42,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
...order, ...order,
status, status,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), eventKey: 'statusChanged' as const }], timeline: [...order.timeline, { status, timestamp: new Date().toISOString(), eventKey: 'statusChanged' as const, actor: this.currentActor }],
})); }));
} }
@@ -45,7 +51,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
...order, ...order,
payment: { ...order.payment, status: 'refund_requested' }, payment: { ...order.payment, status: 'refund_requested' },
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), eventKey: 'refundRequested' as const }], timeline: [...order.timeline, { status: order.status, timestamp: new Date().toISOString(), eventKey: 'refundRequested' as const, actor: this.currentActor }],
})); }));
} }
@@ -122,8 +128,8 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
notes: '', notes: '',
internalNotes: '', internalNotes: '',
timeline: [ timeline: [
{ status: 'pending', timestamp: createdAt, eventKey: 'created' }, { status: 'pending', timestamp: createdAt, eventKey: 'created', actor: 'system' },
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const }] : []), ...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const, actor: 'system' }] : []),
], ],
archived: false, archived: false,
createdAt, createdAt,

View File

@@ -78,6 +78,8 @@
} }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (error) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (products.length === 0) { } @else if (products.length === 0) {
<app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate"> <app-empty-state [title]="'adminProducts.emptyTitle' | translate" [description]="'adminProducts.emptyDescription' | translate">
<span slot="actions"> <span slot="actions">

View File

@@ -44,6 +44,7 @@ export class AdminProductsListComponent {
@Input() total = 0; @Input() total = 0;
@Input() selectedIds: string[] = []; @Input() selectedIds: string[] = [];
@Input() loading = false; @Input() loading = false;
@Input() error: string | null = null;
@Input() infiniteScroll = false; @Input() infiniteScroll = false;
@Input() viewMode: AdminProductsViewMode = 'table'; @Input() viewMode: AdminProductsViewMode = 'table';
@Input() density: AdminProductsDensity = 'comfortable'; @Input() density: AdminProductsDensity = 'comfortable';

View File

@@ -91,6 +91,8 @@ export class AdminProductsFacade {
readonly infiniteScroll = signal(false); readonly infiniteScroll = signal(false);
readonly categories = signal<AdminProductCategoryOption[]>([]); readonly categories = signal<AdminProductCategoryOption[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly mutationError = signal<string | null>(null);
readonly selectedIds = signal<string[]>([]); readonly selectedIds = signal<string[]>([]);
readonly draft = signal<AdminProduct | null>(null); readonly draft = signal<AdminProduct | null>(null);
readonly dirty = signal(false); readonly dirty = signal(false);
@@ -100,6 +102,7 @@ export class AdminProductsFacade {
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({ this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({
next: result => { next: result => {
this.products.set(result.items); this.products.set(result.items);
@@ -110,6 +113,7 @@ export class AdminProductsFacade {
this.products.set([]); this.products.set([]);
this.total.set(0); this.total.set(0);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }
@@ -302,18 +306,26 @@ export class AdminProductsFacade {
this.dirty.set(true); this.dirty.set(true);
} }
saveDraft(): void { saveDraft(onSuccess?: () => void): void {
const draft = this.draft(); const draft = this.draft();
if (!draft) return; if (!draft) return;
this.mutationError.set(null);
const request = this.editorMode() === 'create' const request = this.editorMode() === 'create'
? this.gateway.createProduct(draft) ? this.gateway.createProduct(draft)
: this.gateway.updateProduct(draft); : this.gateway.updateProduct(draft);
request.pipe(take(1)).subscribe({ next: () => { this.dirty.set(false); this.loadList(); } }); request.pipe(take(1)).subscribe({
next: () => { this.dirty.set(false); this.loadList(); onSuccess?.(); },
error: () => this.mutationError.set('common.errorDescription')
});
} }
deleteOne(id: string): void { deleteOne(id: string): void {
this.gateway.deleteProduct(id).pipe(take(1)).subscribe({ next: () => this.loadList() }); this.mutationError.set(null);
this.gateway.deleteProduct(id).pipe(take(1)).subscribe({
next: () => this.loadList(),
error: () => this.mutationError.set('common.errorDescription')
});
} }
} }

View File

@@ -1,3 +1,5 @@
import { UUID } from '../../../../shared/types/primitive.types';
export type AdminProductStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock'; export type AdminProductStockStatus = 'in_stock' | 'low_stock' | 'out_of_stock';
export type AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated'; export type AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated';
export type AdminProductEditorMode = 'create' | 'edit' | 'duplicate'; export type AdminProductEditorMode = 'create' | 'edit' | 'duplicate';
@@ -117,7 +119,7 @@ export interface AdminProduct {
* Absent means marketplace-owned, exactly like every product today - * Absent means marketplace-owned, exactly like every product today -
* nothing reads this field yet, nothing breaks by it being undefined. * nothing reads this field yet, nothing breaks by it being undefined.
*/ */
sellerId?: string; sellerId?: UUID;
} }
export interface AdminProductListFilters { export interface AdminProductListFilters {

View File

@@ -4,12 +4,22 @@ import { AdminProductsFacade } from '../facade/admin-products.facade';
import { AdminProductFormComponent } from '../components/admin-product-form.component'; import { AdminProductFormComponent } from '../components/admin-product-form.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-product-editor-page', selector: 'app-admin-product-editor-page',
standalone: true, standalone: true,
imports: [AdminProductFormComponent, TranslatePipe], imports: [AdminProductFormComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}`, template: `@if (facade.draft(); as draft) {<main class="editor-page"><header><h1>{{ title() | translate }}</h1></header><app-admin-product-form [product]="draft" [categories]="facade.categories()" [allProducts]="facade.products()" [locales]="facade.supportedLocales()" [mode]="facade.editorMode()" [health]="facade.health(draft)" (productChange)="facade.updateDraft($event)" (save)="save()" /></main>} @else {<main class="editor-page"><p>{{ 'common.loading' | translate }}</p></main>}
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}`,
styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`], styles: [`.editor-page { max-width: 1120px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; } .editor-page h1, .editor-page p { margin: 0; }`],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
@@ -33,7 +43,8 @@ export class AdminProductEditorPageComponent {
} }
save(): void { save(): void {
this.facade.saveDraft(); this.facade.saveDraft(() => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']); void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
});
} }
} }

View File

@@ -1,13 +1,17 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AdminProductsFacade } from '../facade/admin-products.facade'; import { AdminProductsFacade } from '../facade/admin-products.facade';
import { AdminProductsListComponent } from '../components/admin-products-list.component'; import { AdminProductsListComponent } from '../components/admin-products-list.component';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({ @Component({
selector: 'app-admin-products-list-page', selector: 'app-admin-products-list-page',
standalone: true, standalone: true,
imports: [AdminProductsListComponent], imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `<app-admin-products-list template: `<app-admin-products-list
[products]="facade.products()" [products]="facade.products()"
[categories]="facade.categories()" [categories]="facade.categories()"
@@ -15,6 +19,7 @@ import { LanguageService } from '../../../../services/language.service';
[total]="facade.total()" [total]="facade.total()"
[selectedIds]="facade.selectedIds()" [selectedIds]="facade.selectedIds()"
[loading]="facade.loading()" [loading]="facade.loading()"
[error]="facade.error()"
[infiniteScroll]="facade.infiniteScroll()" [infiniteScroll]="facade.infiniteScroll()"
[viewMode]="facade.viewMode()" [viewMode]="facade.viewMode()"
[density]="facade.density()" [density]="facade.density()"
@@ -25,7 +30,7 @@ import { LanguageService } from '../../../../services/language.service';
(create)="create()" (create)="create()"
(edit)="edit($event)" (edit)="edit($event)"
(duplicate)="duplicate($event)" (duplicate)="duplicate($event)"
(delete)="facade.deleteOne($event)" (delete)="deleteOne($event)"
(archive)="facade.archiveOne($event)" (archive)="facade.archiveOne($event)"
(restore)="facade.restoreOne($event)" (restore)="facade.restoreOne($event)"
(loadMore)="facade.loadMore()" (loadMore)="facade.loadMore()"
@@ -33,14 +38,36 @@ import { LanguageService } from '../../../../services/language.service';
(selectionChange)="facade.toggleSelection($event.id, $event.checked)" (selectionChange)="facade.toggleSelection($event.id, $event.checked)"
(selectAll)="facade.toggleAll($event)" (selectAll)="facade.toggleAll($event)"
(bulkVisibility)="facade.applyBulkVisibility($event)" (bulkVisibility)="facade.applyBulkVisibility($event)"
(bulkDelete)="facade.applyBulkDelete()" (bulkDelete)="bulkDelete()"
(bulkDuplicate)="facade.applyBulkDuplicate()" (bulkDuplicate)="facade.applyBulkDuplicate()"
(bulkAssignCategory)="facade.applyBulkAssignCategory($event)" (bulkAssignCategory)="facade.applyBulkAssignCategory($event)"
(bulkAssignTags)="facade.applyBulkAssignTags($event)" (bulkAssignTags)="facade.applyBulkAssignTags($event)"
(bulkExport)="facade.exportSelectedAsCsv()" (bulkExport)="facade.exportSelectedAsCsv()"
(viewModeChange)="facade.setViewMode($event)" (viewModeChange)="facade.setViewMode($event)"
(densityChange)="facade.setDensity($event)" (densityChange)="facade.setDensity($event)"
(columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />`, (columnToggle)="facade.setColumnVisible($event.column, $event.visible)" />
<app-confirm-dialog
[open]="!!pendingDeleteId()"
[titleText]="'adminProducts.deleteTitle' | translate"
[message]="'adminProducts.confirmDelete' | translate"
[destructive]="true"
(confirmed)="confirmDelete()"
(cancelled)="pendingDeleteId.set(null)" />
<app-confirm-dialog
[open]="bulkDeleteConfirmOpen()"
[titleText]="'adminProducts.deleteTitle' | translate"
[message]="'adminProducts.confirmBulkDelete' | translate"
[destructive]="true"
(confirmed)="confirmBulkDelete()"
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}`,
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class AdminProductsListPageComponent { export class AdminProductsListPageComponent {
@@ -50,6 +77,9 @@ export class AdminProductsListPageComponent {
readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product); readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product);
readonly pendingDeleteId = signal<string | null>(null);
readonly bulkDeleteConfirmOpen = signal(false);
constructor() { constructor() {
this.facade.loadCategories(); this.facade.loadCategories();
this.facade.loadList(); this.facade.loadList();
@@ -60,6 +90,27 @@ export class AdminProductsListPageComponent {
edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'edit']); } edit(id: string): void { this.facade.loadForEdit(id, 'edit'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'edit']); }
duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'duplicate']); } duplicate(id: string): void { this.facade.loadForEdit(id, 'duplicate'); void this.router.navigate([this.lang(), 'backoffice', 'products', id, 'duplicate']); }
deleteOne(id: string): void {
this.pendingDeleteId.set(id);
}
confirmDelete(): void {
const id = this.pendingDeleteId();
if (id) {
this.facade.deleteOne(id);
}
this.pendingDeleteId.set(null);
}
bulkDelete(): void {
this.bulkDeleteConfirmOpen.set(true);
}
confirmBulkDelete(): void {
this.facade.applyBulkDelete();
this.bulkDeleteConfirmOpen.set(false);
}
private lang(): string { private lang(): string {
return this.languageService.currentLanguage(); return this.languageService.currentLanguage();
} }

View File

@@ -6,6 +6,8 @@
} }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else { } @else {
<div class="report-grid"> <div class="report-grid">
<div class="report-card"> <div class="report-card">

View File

@@ -3,11 +3,12 @@ import { AdminAnalyticsFacade } from '../../analytics/facade/admin-analytics.fac
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({ @Component({
selector: 'app-admin-reports-page', selector: 'app-admin-reports-page',
standalone: true, standalone: true,
imports: [TranslatePipe, ButtonComponent, SkeletonComponent], imports: [TranslatePipe, ButtonComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-reports-page.component.html', templateUrl: './admin-reports-page.component.html',
styleUrls: ['./admin-reports-page.component.scss'], styleUrls: ['./admin-reports-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -11,9 +11,11 @@ export class AdminTransactionsFacade {
readonly transactions = signal<AdminTransaction[]>([]); readonly transactions = signal<AdminTransaction[]>([]);
readonly total = signal(0); readonly total = signal(0);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal<string | null>(null);
loadList(): void { loadList(): void {
this.loading.set(true); this.loading.set(true);
this.error.set(null);
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({ this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
next: result => { next: result => {
this.transactions.set(result.items); this.transactions.set(result.items);
@@ -24,6 +26,7 @@ export class AdminTransactionsFacade {
this.transactions.set([]); this.transactions.set([]);
this.total.set(0); this.total.set(0);
this.loading.set(false); this.loading.set(false);
this.error.set('common.errorDescription');
} }
}); });
} }

View File

@@ -22,6 +22,8 @@
@for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> } @for (i of [1,2,3,4]; track i) { <app-skeleton shape="rect" height="40px" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span> <span class="sr-only">{{ 'common.loading' | translate }}</span>
</div> </div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.transactions().length === 0) { } @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" /> <app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
} @else { } @else {

View File

@@ -1,18 +1,24 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators'; import { delay } from 'rxjs/operators';
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model'; import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface'; import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway'; import { AdminOrdersLocalGateway } from '../../orders/services/admin-orders-local.gateway';
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
const METHODS = ['card', 'qr', 'cash_on_delivery']; const METHODS = ['card', 'qr', 'cash_on_delivery'];
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway { export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
private readonly adminAuth = inject(AdminAuthService);
private cache: AdminTransaction[] | null = null; private cache: AdminTransaction[] | null = null;
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {} constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> { loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
return new Observable<AdminTransactionsListResult>(subscriber => { return new Observable<AdminTransactionsListResult>(subscriber => {
this.ensureData().then(() => { this.ensureData().then(() => {
@@ -38,7 +44,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
...tx, ...tx,
status: 'retried', status: 'retried',
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
audit: [...tx.audit, { action: 'Retried failed transaction', actor: 'admin', timestamp: new Date().toISOString() }], audit: [...tx.audit, { action: 'Retried failed transaction', actor: this.currentActor, timestamp: new Date().toISOString() }],
})); }));
} }
@@ -47,7 +53,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
...tx, ...tx,
fraudFlag: flagged, fraudFlag: flagged,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
audit: [...tx.audit, { action: flagged ? 'Flagged as fraud' : 'Fraud flag cleared', actor: 'admin', timestamp: new Date().toISOString() }], audit: [...tx.audit, { action: flagged ? 'Flagged as fraud' : 'Fraud flag cleared', actor: this.currentActor, timestamp: new Date().toISOString() }],
})); }));
} }

View File

@@ -1,6 +1,6 @@
import { Injectable, inject, signal } from '@angular/core'; import { Injectable, inject, signal } from '@angular/core';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model'; import { AdminInvitation, AdminUserRoleRecord, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
import { AdminUsersLocalGateway } from '../services/admin-users-local.gateway'; import { AdminUsersLocalGateway } from '../services/admin-users-local.gateway';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -8,10 +8,11 @@ export class AdminUsersFacade {
private readonly gateway = inject(AdminUsersLocalGateway); private readonly gateway = inject(AdminUsersLocalGateway);
readonly users = signal<AdminUser[]>([]); readonly users = signal<AdminUser[]>([]);
readonly roles = signal<AdminRole[]>([]); readonly roles = signal<AdminUserRoleRecord[]>([]);
readonly invitations = signal<AdminInvitation[]>([]); readonly invitations = signal<AdminInvitation[]>([]);
readonly loading = signal(false); readonly loading = signal(false);
readonly error = signal(false); readonly error = signal(false);
readonly mutationError = signal<string | null>(null);
readonly sessionsTarget = signal<AdminUser | null>(null); readonly sessionsTarget = signal<AdminUser | null>(null);
readonly sessions = signal<AdminSession[]>([]); readonly sessions = signal<AdminSession[]>([]);
readonly auditTarget = signal<AdminUser | null>(null); readonly auditTarget = signal<AdminUser | null>(null);
@@ -33,16 +34,28 @@ export class AdminUsersFacade {
} }
setRole(userId: string, roleId: string): void { setRole(userId: string, roleId: string): void {
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({ next: () => this.loadAll() }); this.mutationError.set(null);
this.gateway.setUserRole(userId, roleId).pipe(take(1)).subscribe({
next: () => this.loadAll(),
error: () => this.mutationError.set('common.errorDescription')
});
} }
setStatus(userId: string, status: AdminUserStatus): void { setStatus(userId: string, status: AdminUserStatus): void {
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({ next: () => this.loadAll() }); this.mutationError.set(null);
this.gateway.setUserStatus(userId, status).pipe(take(1)).subscribe({
next: () => this.loadAll(),
error: () => this.mutationError.set('common.errorDescription')
});
} }
invite(email: string, roleId: string, scope: AdminUserScope): void { invite(email: string, roleId: string, scope: AdminUserScope): void {
if (!email.trim()) return; if (!email.trim()) return;
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({ next: () => this.loadAll() }); this.mutationError.set(null);
this.gateway.inviteUser(email.trim(), roleId, scope).pipe(take(1)).subscribe({
next: () => this.loadAll(),
error: () => this.mutationError.set('common.errorDescription')
});
} }
revokeInvitation(id: string): void { revokeInvitation(id: string): void {

View File

@@ -2,7 +2,8 @@ export type AdminUserScope = 'marketplace' | 'office';
export type AdminUserStatus = 'active' | 'invited' | 'suspended'; export type AdminUserStatus = 'active' | 'invited' | 'suspended';
export type AdminInvitationStatus = 'pending' | 'accepted' | 'expired' | 'revoked'; export type AdminInvitationStatus = 'pending' | 'accepted' | 'expired' | 'revoked';
export interface AdminRole { /** Users-admin display/permissions shape - unrelated to core/auth/models/permission.model.ts's AdminRole (the real JWT/auth role union). */
export interface AdminUserRoleRecord {
id: string; id: string;
name: string; name: string;
permissions: string[]; permissions: string[];

View File

@@ -120,4 +120,21 @@
<p>{{ entry.timestamp | date:'short' }} — {{ ('adminUsers.actor.' + entry.actor) | translate }} — {{ auditText(entry) }}</p> <p>{{ entry.timestamp | date:'short' }} — {{ ('adminUsers.actor.' + entry.actor) | translate }} — {{ auditText(entry) }}</p>
} }
</app-dialog> </app-dialog>
<app-confirm-dialog
[open]="!!pendingSuspendUserId()"
[titleText]="'adminUsers.suspend' | translate"
[message]="'adminUsers.confirmSuspend' | translate"
[destructive]="true"
(confirmed)="confirmSuspend()"
(cancelled)="pendingSuspendUserId.set(null)" />
@if (facade.mutationError()) {
<app-dialog [open]="true" [titleText]="'common.errorTitle' | translate" size="sm" (closed)="facade.mutationError.set(null)">
<p>{{ 'common.errorDescription' | translate }}</p>
<div class="app-confirm-dialog__actions">
<app-button variant="primary" (click)="facade.mutationError.set(null)">{{ 'common.confirm' | translate }}</app-button>
</div>
</app-dialog>
}
</section> </section>

View File

@@ -12,11 +12,12 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component'; import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
@Component({ @Component({
selector: 'app-admin-users-page', selector: 'app-admin-users-page',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent, SkeletonComponent, EmptyStateComponent], imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, InputComponent, BadgeComponent, TableComponent, DialogComponent, SkeletonComponent, EmptyStateComponent, ConfirmDialogComponent],
templateUrl: './admin-users-page.component.html', templateUrl: './admin-users-page.component.html',
styleUrls: ['./admin-users-page.component.scss'], styleUrls: ['./admin-users-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -28,6 +29,7 @@ export class AdminUsersPageComponent {
readonly inviteEmail = signal(''); readonly inviteEmail = signal('');
readonly inviteRoleId = signal('viewer'); readonly inviteRoleId = signal('viewer');
readonly inviteScope = signal<AdminUserScope>('office'); readonly inviteScope = signal<AdminUserScope>('office');
readonly pendingSuspendUserId = signal<string | null>(null);
constructor() { constructor() {
this.facade.loadAll(); this.facade.loadAll();
@@ -40,12 +42,21 @@ export class AdminUsersPageComponent {
toggleStatus(userId: string, current: AdminUserStatus): void { toggleStatus(userId: string, current: AdminUserStatus): void {
const next: AdminUserStatus = current === 'suspended' ? 'active' : 'suspended'; const next: AdminUserStatus = current === 'suspended' ? 'active' : 'suspended';
if (next === 'suspended' && !window.confirm(this.translate.t('adminUsers.confirmSuspend'))) { if (next === 'suspended') {
this.pendingSuspendUserId.set(userId);
return; return;
} }
this.facade.setStatus(userId, next); this.facade.setStatus(userId, next);
} }
confirmSuspend(): void {
const userId = this.pendingSuspendUserId();
if (userId) {
this.facade.setStatus(userId, 'suspended');
}
this.pendingSuspendUserId.set(null);
}
private static readonly PERMISSION_KEYS: Record<string, string> = { private static readonly PERMISSION_KEYS: Record<string, string> = {
'*': 'all', '*': 'all',
'products.manage': 'productsManage', 'products.manage': 'productsManage',

View File

@@ -1,9 +1,9 @@
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model'; import { AdminInvitation, AdminUserRoleRecord, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
export interface AdminUsersGateway { export interface AdminUsersGateway {
loadUsers(): Observable<AdminUser[]>; loadUsers(): Observable<AdminUser[]>;
loadRoles(): Observable<AdminRole[]>; loadRoles(): Observable<AdminUserRoleRecord[]>;
loadInvitations(): Observable<AdminInvitation[]>; loadInvitations(): Observable<AdminInvitation[]>;
loadSessions(userId: string): Observable<AdminSession[]>; loadSessions(userId: string): Observable<AdminSession[]>;
loadAudit(userId: string): Observable<AdminUserAuditEntry[]>; loadAudit(userId: string): Observable<AdminUserAuditEntry[]>;

View File

@@ -1,20 +1,22 @@
import { Injectable } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators'; import { delay } from 'rxjs/operators';
import { AdminInvitation, AdminRole, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model'; import { AdminInvitation, AdminUserRoleRecord, AdminSession, AdminUser, AdminUserAuditEntry, AdminUserScope, AdminUserStatus } from '../models/admin-user.model';
import { AdminUsersGateway } from './admin-users-gateway.interface'; import { AdminUsersGateway } from './admin-users-gateway.interface';
import { AdminAuthService } from '../../../../core/admin-auth/admin-auth.service';
const BUILT_IN_ROLES: AdminRole[] = [ const BUILT_IN_ROLES: AdminUserRoleRecord[] = [
{ id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true }, { id: 'owner', name: 'Owner', permissions: ['*'], builtIn: true },
{ id: 'admin', name: 'Admin', permissions: ['products.manage', 'categories.manage', 'orders.manage', 'media.manage'], builtIn: true }, { id: 'admin', name: 'Admin', permissions: ['products.manage', 'categories.manage', 'orders.manage', 'media.manage', 'users.manage'], builtIn: true },
{ id: 'editor', name: 'Editor', permissions: ['products.manage', 'categories.manage', 'media.manage'], builtIn: true }, { id: 'editor', name: 'Editor', permissions: ['products.manage', 'categories.manage', 'media.manage'], builtIn: true },
{ id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true }, { id: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
]; ];
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AdminUsersLocalGateway implements AdminUsersGateway { export class AdminUsersLocalGateway implements AdminUsersGateway {
private readonly adminAuth = inject(AdminAuthService);
private users: AdminUser[] | null = null; private users: AdminUser[] | null = null;
private roles: AdminRole[] = [...BUILT_IN_ROLES]; private roles: AdminUserRoleRecord[] = [...BUILT_IN_ROLES];
private invitations: AdminInvitation[] = []; private invitations: AdminInvitation[] = [];
private sessions: Record<string, AdminSession[]> = {}; private sessions: Record<string, AdminSession[]> = {};
private audit: Record<string, AdminUserAuditEntry[]> = {}; private audit: Record<string, AdminUserAuditEntry[]> = {};
@@ -23,7 +25,7 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
return of(this.ensureUsers()).pipe(delay(50)); return of(this.ensureUsers()).pipe(delay(50));
} }
loadRoles(): Observable<AdminRole[]> { loadRoles(): Observable<AdminUserRoleRecord[]> {
return of(this.roles).pipe(delay(50)); return of(this.roles).pipe(delay(50));
} }
@@ -90,12 +92,16 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
this.audit[userId] = [ this.audit[userId] = [
...(this.audit[userId] ?? []), ...(this.audit[userId] ?? []),
roleId roleId
? { eventKey: 'roleChanged', roleId, actor: 'admin', timestamp: new Date().toISOString() } ? { eventKey: 'roleChanged', roleId, actor: this.currentActor, timestamp: new Date().toISOString() }
: { eventKey: 'statusChanged', status, actor: 'admin', timestamp: new Date().toISOString() }, : { eventKey: 'statusChanged', status, actor: this.currentActor, timestamp: new Date().toISOString() },
]; ];
return of(updated).pipe(delay(50)); return of(updated).pipe(delay(50));
} }
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
private ensureUsers(): AdminUser[] { private ensureUsers(): AdminUser[] {
if (!this.users) { if (!this.users) {
this.users = [ this.users = [

View File

@@ -55,40 +55,47 @@ export class SearchFacade {
readonly state = this.store.state; readonly state = this.store.state;
readonly popularSearches: SearchSuggestion[] = [ /**
{ * Search query text stays the stable English canonical term (what the
id: 'popular-smartphones', * backend search index matches against); only the displayed title/text
type: 'collection', * are translated.
title: 'Smartphones', */
text: 'Smartphones', get popularSearches(): SearchSuggestion[] {
icon: 'trendingUp', return [
target: { route: '/search', query: { q: 'Smartphones' } } {
}, id: 'popular-smartphones',
{ type: 'collection',
id: 'popular-sneakers', title: this.translate.t('search.popularSmartphones'),
type: 'collection', text: this.translate.t('search.popularSmartphones'),
title: 'Sneakers', icon: 'trendingUp',
text: 'Sneakers', target: { route: '/search', query: { q: 'Smartphones' } }
icon: 'trendingUp', },
target: { route: '/search', query: { q: 'Sneakers' } } {
}, id: 'popular-sneakers',
{ type: 'collection',
id: 'popular-headphones', title: this.translate.t('search.popularSneakers'),
type: 'collection', text: this.translate.t('search.popularSneakers'),
title: 'Headphones', icon: 'trendingUp',
text: 'Headphones', target: { route: '/search', query: { q: 'Sneakers' } }
icon: 'trendingUp', },
target: { route: '/search', query: { q: 'Headphones' } } {
}, id: 'popular-headphones',
{ type: 'collection',
id: 'popular-laptops', title: this.translate.t('search.popularHeadphones'),
type: 'collection', text: this.translate.t('search.popularHeadphones'),
title: 'Laptops', icon: 'trendingUp',
text: 'Laptops', target: { route: '/search', query: { q: 'Headphones' } }
icon: 'trendingUp', },
target: { route: '/search', query: { q: 'Laptops' } } {
}, id: 'popular-laptops',
]; type: 'collection',
title: this.translate.t('search.popularLaptops'),
text: this.translate.t('search.popularLaptops'),
icon: 'trendingUp',
target: { route: '/search', query: { q: 'Laptops' } }
},
];
}
constructor() { constructor() {
const history = this.historyService.getSnapshot(); const history = this.historyService.getSnapshot();

View File

@@ -1,8 +1,10 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model'; import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
const RANGE_DEBOUNCE_MS = 350;
@Component({ @Component({
selector: 'app-catalog-filters-panel', selector: 'app-catalog-filters-panel',
standalone: true, standalone: true,
@@ -11,12 +13,20 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
styleUrls: ['./filters-panel.component.scss'], styleUrls: ['./filters-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CatalogFiltersPanelComponent { export class CatalogFiltersPanelComponent implements OnDestroy {
@Input() definitions: FilterGroup[] = []; @Input() definitions: FilterGroup[] = [];
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} }; @Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
@Output() stateChange = new EventEmitter<SearchFilterState>(); @Output() stateChange = new EventEmitter<SearchFilterState>();
private readonly debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
ngOnDestroy(): void {
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
}
readonly collapsed = signal<Record<string, boolean>>({}); readonly collapsed = signal<Record<string, boolean>>({});
toggleGroup(filterId: string): void { toggleGroup(filterId: string): void {
@@ -79,34 +89,51 @@ export class CatalogFiltersPanelComponent {
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void { updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
const value = rawValue.trim().length ? Number(rawValue) : undefined; const value = rawValue.trim().length ? Number(rawValue) : undefined;
const current = this.state.ranges[filterId] ?? {};
this.stateChange.emit({ this.debounce(`range:${filterId}:${key}`, () => {
...this.state, const current = this.state.ranges[filterId] ?? {};
ranges: { this.stateChange.emit({
...this.state.ranges, ...this.state,
[filterId]: { ranges: {
...current, ...this.state.ranges,
[key]: Number.isFinite(value as number) ? value : undefined [filterId]: {
...current,
[key]: Number.isFinite(value as number) ? value : undefined
}
} }
} });
}); });
} }
updateSlider(filterId: string, value: string): void { updateSlider(filterId: string, value: string): void {
const numeric = Number(value); const numeric = Number(value);
this.stateChange.emit({
...this.state, this.debounce(`slider:${filterId}`, () => {
ranges: { this.stateChange.emit({
...this.state.ranges, ...this.state,
[filterId]: { ranges: {
...this.state.ranges[filterId], ...this.state.ranges,
max: Number.isFinite(numeric) ? numeric : undefined, [filterId]: {
...this.state.ranges[filterId],
max: Number.isFinite(numeric) ? numeric : undefined,
},
}, },
}, });
}); });
} }
/** Debounces range/slider input so full catalog filter recompute doesn't run on every keystroke/drag event. */
private debounce(key: string, action: () => void): void {
const existing = this.debounceTimers.get(key);
if (existing) {
clearTimeout(existing);
}
this.debounceTimers.set(key, setTimeout(() => {
this.debounceTimers.delete(key);
action();
}, RANGE_DEBOUNCE_MS));
}
updateToggle(filterId: string, checked: boolean): void { updateToggle(filterId: string, checked: boolean): void {
this.stateChange.emit({ this.stateChange.emit({
...this.state, ...this.state,

View File

@@ -20,6 +20,7 @@
(selected)="productSelected.emit(product)" (selected)="productSelected.emit(product)"
(addToCart)="onAddToCart(product, $event.event)" (addToCart)="onAddToCart(product, $event.event)"
(preview)="productPreview.emit($event)" (preview)="productPreview.emit($event)"
(quickViewPlaceholder)="quickView.emit($event)"
(favoriteToggled)="favoriteToggled.emit(product)" (favoriteToggled)="favoriteToggled.emit(product)"
(compareToggled)="compareToggled.emit(product)" (compareToggled)="compareToggled.emit(product)"
(shareRequested)="shareRequested.emit(product)" (shareRequested)="shareRequested.emit(product)"

View File

@@ -28,6 +28,7 @@ export class CatalogProductGridComponent {
@Output() productSelected = new EventEmitter<Product>(); @Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter<number>(); @Output() productPreview = new EventEmitter<number>();
@Output() quickView = new EventEmitter<number>();
@Output() favoriteToggled = new EventEmitter<Product>(); @Output() favoriteToggled = new EventEmitter<Product>();
@Output() compareToggled = new EventEmitter<Product>(); @Output() compareToggled = new EventEmitter<Product>();
@Output() shareRequested = new EventEmitter<Product>(); @Output() shareRequested = new EventEmitter<Product>();

View File

@@ -28,6 +28,7 @@
(productSelected)="productSelected.emit($event)" (productSelected)="productSelected.emit($event)"
(addToCart)="addToCart.emit($event)" (addToCart)="addToCart.emit($event)"
(productPreview)="productPreview.emit($event)" (productPreview)="productPreview.emit($event)"
(quickView)="quickView.emit($event)"
(favoriteToggled)="favoriteToggled.emit($event)" (favoriteToggled)="favoriteToggled.emit($event)"
(compareToggled)="compareToggled.emit($event)" (compareToggled)="compareToggled.emit($event)"
(shareRequested)="shareRequested.emit($event)" /> (shareRequested)="shareRequested.emit($event)" />

View File

@@ -40,6 +40,7 @@ export class CatalogSearchResultsComponent {
@Output() productSelected = new EventEmitter<Product>(); @Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter<number>(); @Output() productPreview = new EventEmitter<number>();
@Output() quickView = new EventEmitter<number>();
@Output() favoriteToggled = new EventEmitter<Product>(); @Output() favoriteToggled = new EventEmitter<Product>();
@Output() compareToggled = new EventEmitter<Product>(); @Output() compareToggled = new EventEmitter<Product>();
@Output() shareRequested = new EventEmitter<Product>(); @Output() shareRequested = new EventEmitter<Product>();

View File

@@ -229,6 +229,7 @@
(productSelected)="selectProduct($event)" (productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)" (addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)" (productPreview)="previewProduct($event)"
(quickView)="openQuickView($event)"
(favoriteToggled)="onFavoriteToggled($event)" (favoriteToggled)="onFavoriteToggled($event)"
(compareToggled)="onCompareToggled($event)" (compareToggled)="onCompareToggled($event)"
(shareRequested)="onShareRequested($event)" /> (shareRequested)="onShareRequested($event)" />
@@ -295,3 +296,9 @@
} }
} }
</main> </main>
<app-quick-view-dialog
[product]="quickViewProduct()"
[loading]="quickViewLoading()"
(closed)="closeQuickView()"
(addToCart)="addQuickViewToCart($event)" />

View File

@@ -10,6 +10,7 @@ import { Product } from '../../../../core/products/models/product-domain.model';
import { ConfigService } from '../../../../core/config/config.service'; import { ConfigService } from '../../../../core/config/config.service';
import { FeatureConfigService } from '../../../../core/config/feature-config.service'; import { FeatureConfigService } from '../../../../core/config/feature-config.service';
import { CategoryFacade } from '../../../../facades/platform/category.facade'; import { CategoryFacade } from '../../../../facades/platform/category.facade';
import { ProductFacade } from '../../../../facades/platform/product.facade';
import { SearchFacade } from '../../../../facades/platform/search.facade'; import { SearchFacade } from '../../../../facades/platform/search.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade'; import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { CartService } from '../../../../services'; import { CartService } from '../../../../services';
@@ -32,6 +33,7 @@ import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-sta
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component'; import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component'; import { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { IconComponent } from '../../../../shared/ui/icon/icon.component'; import { IconComponent } from '../../../../shared/ui/icon/icon.component';
import { QuickViewDialogComponent } from '../../product/components/quick-view-dialog/quick-view-dialog.component';
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model'; import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
import { ProductShareService } from '../../user-experience/services/product-share.service'; import { ProductShareService } from '../../user-experience/services/product-share.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service'; import { UserNotificationService } from '../../user-experience/services/user-notification.service';
@@ -56,7 +58,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
EmptyStateComponent, EmptyStateComponent,
SkeletonComponent, SkeletonComponent,
ButtonComponent, ButtonComponent,
IconComponent IconComponent,
QuickViewDialogComponent
], ],
templateUrl: './catalog-container.component.html', templateUrl: './catalog-container.component.html',
styleUrls: ['./catalog-container.component.scss'], styleUrls: ['./catalog-container.component.scss'],
@@ -68,6 +71,7 @@ export class CatalogContainerComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private readonly categoryFacade = inject(CategoryFacade); private readonly categoryFacade = inject(CategoryFacade);
private readonly productFacade = inject(ProductFacade);
private readonly searchFacade = inject(SearchFacade); private readonly searchFacade = inject(SearchFacade);
private readonly featureConfig = inject(FeatureConfigService); private readonly featureConfig = inject(FeatureConfigService);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
@@ -287,6 +291,31 @@ export class CatalogContainerComponent {
this.prefetchService.prefetchItem(productId); this.prefetchService.prefetchItem(productId);
} }
readonly quickViewProduct = signal<Product | null>(null);
readonly quickViewLoading = signal(false);
openQuickView(productId: number): void {
this.quickViewProduct.set(null);
this.quickViewLoading.set(true);
this.productFacade.getProduct(productId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
next: product => {
this.quickViewProduct.set(product);
this.quickViewLoading.set(false);
},
error: () => this.quickViewLoading.set(false)
});
}
closeQuickView(): void {
this.quickViewProduct.set(null);
this.quickViewLoading.set(false);
}
addQuickViewToCart(product: Product): void {
this.cartService.addItem(product.itemID);
this.closeQuickView();
}
retry(): void { retry(): void {
this.enterCategory(this.state().category?.id ?? null); this.enterCategory(this.state().category?.id ?? null);
} }

View File

@@ -0,0 +1,34 @@
<app-dialog
[open]="!!product || loading"
[titleText]="'catalog.quickView' | translate"
size="md"
(closed)="closed.emit()"
>
@if (loading) {
<div class="quick-view__loading" role="status" aria-live="polite">{{ 'common.loading' | translate }}</div>
} @else if (product) {
<div class="quick-view">
<img class="quick-view__image" [src]="mainImage" [alt]="product.name" />
<div class="quick-view__body">
<h3 class="quick-view__title">{{ product.name }}</h3>
<p class="quick-view__price">
@if (hasDiscount) {
<span class="quick-view__price-original">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
<span class="quick-view__price-final">{{ discountedPrice | number:'1.2-2' }} {{ product.currency }}</span>
} @else {
<span class="quick-view__price-final">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
}
</p>
@if (product.simpleDescription) {
<p class="quick-view__description">{{ product.simpleDescription }}</p>
}
<div class="quick-view__actions">
<app-button variant="primary" (click)="addToCart.emit(product)">{{ 'carousel.addToCart' | translate }}</app-button>
<a class="quick-view__link" [routerLink]="('/product/' + product.itemID) | langRoute" (click)="closed.emit()">
{{ 'catalog.quickViewDetails' | translate }}
</a>
</div>
</div>
</div>
}
</app-dialog>

View File

@@ -0,0 +1,73 @@
.quick-view {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
@media (min-width: 560px) {
grid-template-columns: 200px 1fr;
}
}
.quick-view__image {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: var(--radius-md, 8px);
background: var(--bg-secondary, #f3f3f3);
}
.quick-view__body {
display: flex;
flex-direction: column;
gap: 8px;
}
.quick-view__title {
margin: 0;
font-size: var(--font-size-lg, 1.125rem);
color: var(--text-primary);
}
.quick-view__price {
margin: 0;
display: flex;
align-items: baseline;
gap: 8px;
}
.quick-view__price-original {
text-decoration: line-through;
color: var(--text-secondary);
font-size: var(--font-size-sm, 0.875rem);
}
.quick-view__price-final {
font-weight: var(--font-weight-bold, 700);
font-size: var(--font-size-lg, 1.125rem);
color: var(--text-primary);
}
.quick-view__description {
margin: 0;
color: var(--text-secondary);
font-size: var(--font-size-sm, 0.875rem);
}
.quick-view__actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.quick-view__link {
text-align: center;
color: var(--primary-color);
font-size: var(--font-size-sm, 0.875rem);
}
.quick-view__loading {
padding: 40px 0;
text-align: center;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { DecimalPipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Product } from '../../../../../core/products/models/product-domain.model';
import { DialogComponent } from '../../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
import { getDiscountedPrice, getMainImage } from '../../../../../utils/item.utils';
@Component({
selector: 'app-quick-view-dialog',
standalone: true,
imports: [DialogComponent, ButtonComponent, TranslatePipe, LangRoutePipe, DecimalPipe, RouterLink],
templateUrl: './quick-view-dialog.component.html',
styleUrl: './quick-view-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class QuickViewDialogComponent {
@Input() product: Product | null = null;
@Input() loading = false;
@Output() closed = new EventEmitter<void>();
@Output() addToCart = new EventEmitter<Product>();
get mainImage(): string {
return this.product ? getMainImage(this.product) : '';
}
get discountedPrice(): number {
return this.product ? getDiscountedPrice(this.product) : 0;
}
get hasDiscount(): boolean {
return !!this.product?.discount && this.product.discount > 0;
}
}

View File

@@ -16,6 +16,13 @@ import { LanguageService } from '../../../../services/language.service';
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config'; import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils'; import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductShareService } from '../../user-experience/services/product-share.service'; import { ProductShareService } from '../../user-experience/services/product-share.service';
import { SeoService } from '../../../../services/seo.service';
import { ApiService } from '../../../../services/api.service';
import { LocalStorageService } from '../../../../core/storage/local-storage.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
import { AuthService } from '../../../../services/auth.service';
const RESTOCK_SUBSCRIPTIONS_KEY = 'restockSubscriptions';
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component'; import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
import { ProductActionsComponent } from '../components/product-actions/product-actions.component'; import { ProductActionsComponent } from '../components/product-actions/product-actions.component';
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component'; import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
@@ -70,6 +77,11 @@ export class ProductDetailsContainerComponent {
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly shareService = inject(ProductShareService); private readonly shareService = inject(ProductShareService);
private readonly seoService = inject(SeoService);
private readonly apiService = inject(ApiService);
private readonly storage = inject(LocalStorageService);
private readonly notifications = inject(UserNotificationService);
private readonly authService = inject(AuthService);
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig()); readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig()); readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
@@ -229,6 +241,8 @@ export class ProductDetailsContainerComponent {
this.route.paramMap this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(params => this.loadProduct(Number(params.get('id')))); .subscribe(params => this.loadProduct(Number(params.get('id'))));
this.destroyRef.onDestroy(() => this.seoService.resetToDefaults());
} }
loadProduct(productId: number): void { loadProduct(productId: number): void {
@@ -261,6 +275,7 @@ export class ProductDetailsContainerComponent {
} }
this.product.set(product); this.product.set(product);
this.seoService.setItemMeta(product);
if (this.userExperienceConfig().recentlyViewed.enabled) { if (this.userExperienceConfig().recentlyViewed.enabled) {
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems); this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
} }
@@ -303,10 +318,10 @@ export class ProductDetailsContainerComponent {
} }
} }
addToCart(): void { addToCart(): Promise<void> {
const current = this.product(); const current = this.product();
if (!current) return; if (!current) return Promise.resolve();
this.cartService.addItem(current.itemID, 1, { return this.cartService.addItem(current.itemID, 1, {
colour: this.selectedColour() ?? undefined, colour: this.selectedColour() ?? undefined,
size: this.selectedSize() ?? undefined, size: this.selectedSize() ?? undefined,
price: this.effectivePrice(), price: this.effectivePrice(),
@@ -314,9 +329,9 @@ export class ProductDetailsContainerComponent {
}); });
} }
buyNow(): void { async buyNow(): Promise<void> {
this.addToCart(); await this.addToCart();
this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]); void this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
} }
toggleWishlist(): void { toggleWishlist(): void {
@@ -353,8 +368,36 @@ export class ProductDetailsContainerComponent {
await this.shareService.shareProduct(current, url); await this.shareService.shareProduct(current, url);
} }
/**
* Back-in-stock subscription. Tries the backend endpoint first (not built
* yet - see BACKEND-API-REFERENCE.md §12); falls back to a local-only
* record on any failure so the request isn't silently lost while the
* backend catches up. Either way the shopper sees the same confirmation.
*/
notifyMe(): void { notifyMe(): void {
this.toggleWishlist(); const current = this.product();
if (!current) {
return;
}
const telegramUserId = this.authService.session()?.userId != null
? String(this.authService.session()!.userId)
: null;
this.apiService.subscribeToRestock(current.itemID, { telegramUserId }).subscribe({
next: () => this.notifications.show(this.translate.t('productDetails.notifyMeConfirmed'), 'success'),
error: () => {
this.saveRestockSubscriptionLocally(current.itemID);
this.notifications.show(this.translate.t('productDetails.notifyMeConfirmed'), 'success');
}
});
}
private saveRestockSubscriptionLocally(itemID: number): void {
const existing = this.storage.getJSON<number[]>(RESTOCK_SUBSCRIPTIONS_KEY) ?? [];
if (!existing.includes(itemID)) {
this.storage.setJSON(RESTOCK_SUBSCRIPTIONS_KEY, [...existing, itemID]);
}
} }
addRelatedToCart(payload: { product: Product; event: Event }): void { addRelatedToCart(payload: { product: Product; event: Event }): void {

View File

@@ -7,7 +7,7 @@
} }
.star { .star {
color: #cdd6d5; color: var(--border-color);
} }
.star.filled { .star.filled {

View File

@@ -6,7 +6,7 @@
<th scope="col">{{ 'ux.compareAttribute' | translate }}</th> <th scope="col">{{ 'ux.compareAttribute' | translate }}</th>
@for (product of products; track product.itemID) { @for (product of products; track product.itemID) {
<th scope="col"> <th scope="col">
<div class="compare-product-title">{{ product.name }}</div> <div class="compare-product-title">{{ productTitle(product) }}</div>
</th> </th>
} }
</tr> </tr>

View File

@@ -2,6 +2,8 @@ import { ChangeDetectionStrategy, Component, Input, computed, inject } from '@an
import { Product } from '../../../../../core/products/models/product-domain.model'; import { Product } from '../../../../../core/products/models/product-domain.model';
import { TranslateService } from '../../../../../i18n/translate.service'; import { TranslateService } from '../../../../../i18n/translate.service';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../../services/language.service';
import { getTranslatedField } from '../../../../../utils/item.utils';
interface CompareRow { interface CompareRow {
key: string; key: string;
@@ -27,6 +29,7 @@ const STOCK_LABEL_KEYS: Record<string, string> = {
}) })
export class CompareTableComponent { export class CompareTableComponent {
private readonly i18n = inject(TranslateService); private readonly i18n = inject(TranslateService);
private readonly languageService = inject(LanguageService);
@Input() products: Product[] = []; @Input() products: Product[] = [];
@Input() hideIdentical = false; @Input() hideIdentical = false;
@@ -61,6 +64,10 @@ export class CompareTableComponent {
return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows; return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows;
}); });
productTitle(product: Product): string {
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
}
isDifferentRow(row: CompareRow): boolean { isDifferentRow(row: CompareRow): boolean {
return this.highlightDifferences && !row.identical; return this.highlightDifferences && !row.identical;
} }

View File

@@ -26,7 +26,7 @@
<section class="compare-products-list"> <section class="compare-products-list">
@for (product of products(); track product.itemID) { @for (product of products(); track product.itemID) {
<article class="compare-product-chip"> <article class="compare-product-chip">
<a [routerLink]="['/product', product.itemID] | langRoute">{{ product.name }}</a> <a [routerLink]="['/product', product.itemID] | langRoute">{{ productTitle(product) }}</a>
<button type="button" [attr.aria-label]="'ux.removeFromCompare' | translate" (click)="remove(product.itemID)">×</button> <button type="button" [attr.aria-label]="'ux.removeFromCompare' | translate" (click)="remove(product.itemID)">×</button>
</article> </article>
} }

View File

@@ -5,6 +5,8 @@ import { Product } from '../../../../../core/products/models/product-domain.mode
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade'; import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
import { LanguageService } from '../../../../../services/language.service';
import { getTranslatedField } from '../../../../../utils/item.utils';
import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../../shared/models/config'; import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../../shared/models/config';
import { CompareTableComponent } from '../components/compare-table.component'; import { CompareTableComponent } from '../components/compare-table.component';
import { EmptyStateComponent } from '../../../../../shared/ui/empty-state/empty-state.component'; import { EmptyStateComponent } from '../../../../../shared/ui/empty-state/empty-state.component';
@@ -21,6 +23,7 @@ import { ButtonComponent } from '../../../../../shared/ui/button/button.componen
export class ComparePageComponent { export class ComparePageComponent {
private readonly uxFacade = inject(UserExperienceFacade); private readonly uxFacade = inject(UserExperienceFacade);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private readonly languageService = inject(LanguageService);
private readonly compareConfig = this.resolveCompareConfig(); private readonly compareConfig = this.resolveCompareConfig();
@@ -39,6 +42,10 @@ export class ComparePageComponent {
this.uxFacade.clearCompare(); this.uxFacade.clearCompare();
} }
productTitle(product: Product): string {
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
}
private resolveCompareConfig() { private resolveCompareConfig() {
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {}; const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {};
return { return {

View File

@@ -123,6 +123,10 @@ export const en: Translations = {
emailNeedsAt: 'Email must contain @', emailNeedsAt: 'Email must contain @',
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)', emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
emailInvalid: 'Invalid email format', emailInvalid: 'Invalid email format',
telegramIdMissing: 'We could not identify your Telegram account, so we could not save your contact details. Your payment was still successful.',
paymentDescriptionFallback: 'Purchase on Marketplace',
emailPlaceholder: 'you@example.com',
phonePlaceholder: '+7 (___) ___-__-__',
loginRequired: 'Log in to checkout', loginRequired: 'Log in to checkout',
loginRequiredDesc: 'Please log in via Telegram to place your order', loginRequiredDesc: 'Please log in via Telegram to place your order',
loginWithTelegram: 'Log in with Telegram', loginWithTelegram: 'Log in with Telegram',
@@ -140,6 +144,10 @@ export const en: Translations = {
noResultsHint: 'Try changing your query or using different keywords', noResultsHint: 'Try changing your query or using different keywords',
emptyResultsAria: 'Empty search results', emptyResultsAria: 'Empty search results',
popularCategories: 'Popular categories', popularCategories: 'Popular categories',
popularSmartphones: 'Smartphones',
popularSneakers: 'Sneakers',
popularHeadphones: 'Headphones',
popularLaptops: 'Laptops',
recommendedProducts: 'Recommended products', recommendedProducts: 'Recommended products',
aiSuggestionHint: 'AI suggestion (future)', aiSuggestionHint: 'AI suggestion (future)',
suggestionType: { suggestionType: {
@@ -269,6 +277,7 @@ export const en: Translations = {
compare: 'Compare', compare: 'Compare',
share: 'Share', share: 'Share',
quickView: 'Quick view', quickView: 'Quick view',
quickViewDetails: 'View full details',
stockHigh: 'In stock', stockHigh: 'In stock',
stockMedium: 'Limited stock', stockMedium: 'Limited stock',
stockLow: 'Almost gone', stockLow: 'Almost gone',
@@ -317,6 +326,7 @@ export const en: Translations = {
compare: 'Compare', compare: 'Compare',
share: 'Share', share: 'Share',
notifyMe: 'Notify me', notifyMe: 'Notify me',
notifyMeConfirmed: 'We\'ll let you know when this is back in stock.',
zoom: 'Zoom', zoom: 'Zoom',
fullscreen: 'Fullscreen', fullscreen: 'Fullscreen',
pdfDocument: 'Product PDF document', pdfDocument: 'Product PDF document',
@@ -1119,6 +1129,8 @@ export const en: Translations = {
nextProducts: 'Next products', nextProducts: 'Next products',
previousSlide: 'Previous slide', previousSlide: 'Previous slide',
nextSlide: 'Next slide', nextSlide: 'Next slide',
pauseAutoplay: 'Pause slideshow',
resumeAutoplay: 'Resume slideshow',
closeDialog: 'Close dialog', closeDialog: 'Close dialog',
dismiss: 'Dismiss', dismiss: 'Dismiss',
qrCode: 'QR Code', qrCode: 'QR Code',
@@ -1299,7 +1311,8 @@ export const en: Translations = {
notAvailable: 'Not available yet', notAvailable: 'Not available yet',
changeStatus: 'Change status', changeStatus: 'Change status',
hideSelected: 'Hide', hideSelected: 'Hide',
archiveSelected: 'Archive', deleteSelected: 'Delete',
confirmBulkDelete: 'Delete the selected reviews? This cannot be undone.',
reportsQueue: 'Reports queue', reportsQueue: 'Reports queue',
reportsColumn: 'Reports', reportsColumn: 'Reports',
reportTarget: 'Reported item', reportTarget: 'Reported item',
@@ -1357,6 +1370,8 @@ export const en: Translations = {
}, },
}, },
adminOrders: { adminOrders: {
bulkDeleteTitle: 'Delete orders',
confirmBulkDelete: 'Delete the selected orders? This cannot be undone.',
back: 'Back to orders', back: 'Back to orders',
search: 'Search by order number, name, email, or phone…', search: 'Search by order number, name, email, or phone…',
export: 'Export', export: 'Export',
@@ -1533,6 +1548,7 @@ export const en: Translations = {
title: 'Title', title: 'Title',
slug: 'URL slug', slug: 'URL slug',
slugTaken: 'This slug is already used by another category.', slugTaken: 'This slug is already used by another category.',
slugCheckError: 'Could not verify this slug is unique. Try again before saving.',
parent: 'Parent category', parent: 'Parent category',
noParent: 'No parent (top level)', noParent: 'No parent (top level)',
icon: 'Icon', icon: 'Icon',
@@ -1545,6 +1561,8 @@ export const en: Translations = {
saveDraft: 'Save draft', saveDraft: 'Save draft',
publish: 'Publish', publish: 'Publish',
confirmDelete: 'Delete this category? This cannot be undone.', confirmDelete: 'Delete this category? This cannot be undone.',
confirmBulkDelete: 'Delete the selected categories? This cannot be undone.',
deleteTitle: 'Delete category',
confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?', confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?',
deleteBlocked: 'This category can\'t be deleted while it has subcategories or assigned products.', deleteBlocked: 'This category can\'t be deleted while it has subcategories or assigned products.',
emptyTitle: 'No categories yet', emptyTitle: 'No categories yet',
@@ -1611,6 +1629,9 @@ export const en: Translations = {
}, },
adminProducts: { adminProducts: {
confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?', confirmLeaveUnsaved: 'You have unsaved changes. Leave without saving?',
deleteTitle: 'Delete product',
confirmDelete: 'Delete this product? This cannot be undone.',
confirmBulkDelete: 'Delete the selected products? This cannot be undone.',
emptyTitle: 'No products found', emptyTitle: 'No products found',
emptyDescription: 'Try adjusting your filters, or create a new product.', emptyDescription: 'Try adjusting your filters, or create a new product.',
emptyGuide: 'Good products have a clear title, at least one photo, a price, and a short description — that\'s enough to publish. You can always add more detail later.', emptyGuide: 'Good products have a clear title, at least one photo, a price, and a short description — that\'s enough to publish. You can always add more detail later.',

View File

@@ -123,6 +123,10 @@ export const hy: Translations = {
emailNeedsAt: 'Email-ը պետք է պարունակի @', emailNeedsAt: 'Email-ը պետք է պարունակի @',
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)', emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
emailInvalid: 'Սխալ email ձևաչափ', emailInvalid: 'Սխալ email ձևաչափ',
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
paymentDescriptionFallback: 'Գնում Մարկետփլեյսում',
emailPlaceholder: 'you@example.com',
phonePlaceholder: '+7 (___) ___-__-__',
loginRequired: 'Մուտք գործեք ձևակերպելու համար', loginRequired: 'Մուտք գործեք ձևակերպելու համար',
loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով', loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով',
loginWithTelegram: 'Մուտք Telegram-ով', loginWithTelegram: 'Մուտք Telegram-ով',
@@ -140,6 +144,10 @@ export const hy: Translations = {
noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր', noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր',
emptyResultsAria: 'Դատարկ որոնման արդյունքներ', emptyResultsAria: 'Դատարկ որոնման արդյունքներ',
popularCategories: 'Հանրաճանաչ կատեգորիաներ', popularCategories: 'Հանրաճանաչ կատեգորիաներ',
popularSmartphones: 'Սմարթֆոններ',
popularSneakers: 'Կեդեր',
popularHeadphones: 'Ականջակալներ',
popularLaptops: 'Նոութբուքեր',
recommendedProducts: 'Առաջարկվող ապրանքներ', recommendedProducts: 'Առաջարկվող ապրանքներ',
aiSuggestionHint: 'AI առաջարկ (ապագայում)', aiSuggestionHint: 'AI առաջարկ (ապագայում)',
suggestionType: { suggestionType: {
@@ -269,6 +277,7 @@ export const hy: Translations = {
compare: 'Համեմատել', compare: 'Համեմատել',
share: 'Կիսվել', share: 'Կիսվել',
quickView: 'Արագ դիտում', quickView: 'Արագ դիտում',
quickViewDetails: 'Տեսնել ամբողջությամբ',
stockHigh: 'Առկա է', stockHigh: 'Առկա է',
stockMedium: 'Սահմանափակ քանակ', stockMedium: 'Սահմանափակ քանակ',
stockLow: 'Գրեթե սպառված է', stockLow: 'Գրեթե սպառված է',
@@ -317,6 +326,7 @@ export const hy: Translations = {
compare: 'Համեմատել', compare: 'Համեմատել',
share: 'Կիսվել', share: 'Կիսվել',
notifyMe: 'Ծանուցել ինձ', notifyMe: 'Ծանուցել ինձ',
notifyMeConfirmed: 'Մենք կտեղեկացնենք ձեզ, երբ ապրանքը կրկին հասանելի լինի։',
zoom: 'Մեծացնել', zoom: 'Մեծացնել',
fullscreen: 'Ամբողջ էկրան', fullscreen: 'Ամբողջ էկրան',
pdfDocument: 'Ապրանքի PDF փաստաթուղթ', pdfDocument: 'Ապրանքի PDF փաստաթուղթ',
@@ -1119,6 +1129,8 @@ export const hy: Translations = {
nextProducts: 'Հաջորդ ապրանքները', nextProducts: 'Հաջորդ ապրանքները',
previousSlide: 'Նախորդ սլայդը', previousSlide: 'Նախորդ սլայդը',
nextSlide: 'Հաջորդ սլայդը', nextSlide: 'Հաջորդ սլայդը',
pauseAutoplay: 'Դադարեցնել սլայդշոուն',
resumeAutoplay: 'Վերսկսել սլայդշոուն',
closeDialog: 'Փակել պատուհանը', closeDialog: 'Փակել պատուհանը',
dismiss: 'Փակել', dismiss: 'Փակել',
qrCode: 'QR կոդ', qrCode: 'QR կոդ',
@@ -1294,7 +1306,8 @@ export const hy: Translations = {
notAvailable: 'Դեռ հասանելի չէ', notAvailable: 'Դեռ հասանելի չէ',
changeStatus: 'Փոխել կարգավիճակը', changeStatus: 'Փոխել կարգավիճակը',
hideSelected: 'Թաքցնել', hideSelected: 'Թաքցնել',
archiveSelected: 'Արխիվացնել', deleteSelected: 'Ջնջել',
confirmBulkDelete: 'Ջնջե՞լ ընտրված կարծիքները։ Հնարավոր չէ հետարկել։',
reportsQueue: 'Բողոքների հերթ', reportsQueue: 'Բողոքների հերթ',
reportsColumn: 'Բողոքներ', reportsColumn: 'Բողոքներ',
reportTarget: 'Բողոքարկված օբյեկտ', reportTarget: 'Բողոքարկված օբյեկտ',
@@ -1352,6 +1365,8 @@ export const hy: Translations = {
}, },
}, },
adminOrders: { adminOrders: {
bulkDeleteTitle: 'Ջնջել պատվերները',
confirmBulkDelete: 'Ջնջե՞լ ընտրված պատվերները։ Հնարավոր չէ հետարկել։',
back: 'Վերադառնալ պատվերներին', back: 'Վերադառնալ պատվերներին',
search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…', search: 'Փնտրել ըստ պատվերի համարի, անվան, էլ. փոստի կամ հեռախոսի…',
export: 'Արտահանել', export: 'Արտահանել',
@@ -1528,6 +1543,7 @@ export const hy: Translations = {
title: 'Անուն', title: 'Անուն',
slug: 'URL հասցե', slug: 'URL հասցե',
slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։', slugTaken: 'Այս հասցեն արդեն օգտագործվում է այլ կատեգորիայի կողմից։',
slugCheckError: 'Չհաջողվեց ստուգել հասցեի եզակիությունը։ Փորձեք կրկին՝ նախքան պահպանելը։',
parent: 'Ծնող կատեգորիա', parent: 'Ծնող կատեգորիա',
noParent: 'Առանց ծնողի (վերին մակարդակ)', noParent: 'Առանց ծնողի (վերին մակարդակ)',
icon: 'Պատկերակ', icon: 'Պատկերակ',
@@ -1540,6 +1556,8 @@ export const hy: Translations = {
saveDraft: 'Պահպանել սևագիրը', saveDraft: 'Պահպանել սևագիրը',
publish: 'Հրապարակել', publish: 'Հրապարակել',
confirmDelete: 'Ջնջե՞լ այս կատեգորիան։ Հնարավոր չէ հետարկել։', confirmDelete: 'Ջնջե՞լ այս կատեգորիան։ Հնարավոր չէ հետարկել։',
confirmBulkDelete: 'Ջնջե՞լ ընտրված կատեգորիաները։ Հնարավոր չէ հետարկել։',
deleteTitle: 'Ջնջել կատեգորիան',
confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։', confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
deleteBlocked: 'Այս կատեգորիան հնարավոր չէ ջնջել, քանի դեռ ունի ենթակատեգորիաներ կամ նշանակված ապրանքներ։', deleteBlocked: 'Այս կատեգորիան հնարավոր չէ ջնջել, քանի դեռ ունի ենթակատեգորիաներ կամ նշանակված ապրանքներ։',
emptyTitle: 'Կատեգորիաներ դեռ չկան', emptyTitle: 'Կատեգորիաներ դեռ չկան',
@@ -1606,6 +1624,9 @@ export const hy: Translations = {
}, },
adminProducts: { adminProducts: {
confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։', confirmLeaveUnsaved: 'Դուք ունեք չպահպանված փոփոխություններ։ Դո՞ւրս գալ առանց պահպանելու։',
deleteTitle: 'Ջնջել ապրանքը',
confirmDelete: 'Ջնջե՞լ այս ապրանքը։ Հնարավոր չէ հետարկել։',
confirmBulkDelete: 'Ջնջե՞լ ընտրված ապրանքները։ Հնարավոր չէ հետարկել։',
emptyTitle: 'Ապրանքներ չեն գտնվել', emptyTitle: 'Ապրանքներ չեն գտնվել',
emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։', emptyDescription: 'Փոխեք ֆիլտրերը կամ ստեղծեք նոր ապրանք։',
emptyGuide: 'Լավ ապրանքին բավական է հստակ վերնագիր, առնվազն մեկ լուսանկար, գին և կարճ նկարագրություն՝ հրապարակելու համար։ Մնացածը կարող եք ավելացնել հետո։', emptyGuide: 'Լավ ապրանքին բավական է հստակ վերնագիր, առնվազն մեկ լուսանկար, գին և կարճ նկարագրություն՝ հրապարակելու համար։ Մնացածը կարող եք ավելացնել հետո։',

View File

@@ -123,6 +123,10 @@ export const ru: Translations = {
emailNeedsAt: 'Email должен содержать @', emailNeedsAt: 'Email должен содержать @',
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)', emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
emailInvalid: 'Некорректный формат email', emailInvalid: 'Некорректный формат email',
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
paymentDescriptionFallback: 'Покупка на Маркетплейсе',
emailPlaceholder: 'you@example.com',
phonePlaceholder: '+7 (___) ___-__-__',
loginRequired: 'Войдите для оформления', loginRequired: 'Войдите для оформления',
loginRequiredDesc: 'Для оформления заказа войдите через Telegram', loginRequiredDesc: 'Для оформления заказа войдите через Telegram',
loginWithTelegram: 'Войти через Telegram', loginWithTelegram: 'Войти через Telegram',
@@ -140,6 +144,10 @@ export const ru: Translations = {
noResultsHint: 'Попробуйте изменить запрос или используйте другие ключевые слова', noResultsHint: 'Попробуйте изменить запрос или используйте другие ключевые слова',
emptyResultsAria: 'Пустые результаты поиска', emptyResultsAria: 'Пустые результаты поиска',
popularCategories: 'Популярные категории', popularCategories: 'Популярные категории',
popularSmartphones: 'Смартфоны',
popularSneakers: 'Кроссовки',
popularHeadphones: 'Наушники',
popularLaptops: 'Ноутбуки',
recommendedProducts: 'Рекомендуемые товары', recommendedProducts: 'Рекомендуемые товары',
aiSuggestionHint: 'AI-подсказка (в будущем)', aiSuggestionHint: 'AI-подсказка (в будущем)',
suggestionType: { suggestionType: {
@@ -269,6 +277,7 @@ export const ru: Translations = {
compare: 'Сравнить', compare: 'Сравнить',
share: 'Поделиться', share: 'Поделиться',
quickView: 'Быстрый просмотр', quickView: 'Быстрый просмотр',
quickViewDetails: 'Смотреть полностью',
stockHigh: 'В наличии', stockHigh: 'В наличии',
stockMedium: 'Ограниченно', stockMedium: 'Ограниченно',
stockLow: 'Почти распродано', stockLow: 'Почти распродано',
@@ -317,6 +326,7 @@ export const ru: Translations = {
compare: 'Сравнить', compare: 'Сравнить',
share: 'Поделиться', share: 'Поделиться',
notifyMe: 'Сообщить о наличии', notifyMe: 'Сообщить о наличии',
notifyMeConfirmed: 'Мы сообщим вам, когда товар снова появится в наличии.',
zoom: 'Увеличить', zoom: 'Увеличить',
fullscreen: 'Полный экран', fullscreen: 'Полный экран',
pdfDocument: 'PDF документ товара', pdfDocument: 'PDF документ товара',
@@ -1119,6 +1129,8 @@ export const ru: Translations = {
nextProducts: 'Следующие товары', nextProducts: 'Следующие товары',
previousSlide: 'Предыдущий слайд', previousSlide: 'Предыдущий слайд',
nextSlide: 'Следующий слайд', nextSlide: 'Следующий слайд',
pauseAutoplay: 'Приостановить слайд-шоу',
resumeAutoplay: 'Возобновить слайд-шоу',
closeDialog: 'Закрыть диалог', closeDialog: 'Закрыть диалог',
dismiss: 'Скрыть', dismiss: 'Скрыть',
qrCode: 'QR-код', qrCode: 'QR-код',
@@ -1294,7 +1306,8 @@ export const ru: Translations = {
notAvailable: 'Пока недоступно', notAvailable: 'Пока недоступно',
changeStatus: 'Изменить статус', changeStatus: 'Изменить статус',
hideSelected: 'Скрыть', hideSelected: 'Скрыть',
archiveSelected: 'В архив', deleteSelected: 'Удалить',
confirmBulkDelete: 'Удалить выбранные отзывы? Это действие нельзя отменить.',
reportsQueue: 'Очередь жалоб', reportsQueue: 'Очередь жалоб',
reportsColumn: 'Жалобы', reportsColumn: 'Жалобы',
reportTarget: 'Объект жалобы', reportTarget: 'Объект жалобы',
@@ -1352,6 +1365,8 @@ export const ru: Translations = {
}, },
}, },
adminOrders: { adminOrders: {
bulkDeleteTitle: 'Удалить заказы',
confirmBulkDelete: 'Удалить выбранные заказы? Это действие нельзя отменить.',
back: 'Назад к заказам', back: 'Назад к заказам',
search: 'Поиск по номеру заказа, имени, email или телефону…', search: 'Поиск по номеру заказа, имени, email или телефону…',
export: 'Экспорт', export: 'Экспорт',
@@ -1528,6 +1543,7 @@ export const ru: Translations = {
title: 'Название', title: 'Название',
slug: 'URL-адрес', slug: 'URL-адрес',
slugTaken: 'Этот адрес уже используется другой категорией.', slugTaken: 'Этот адрес уже используется другой категорией.',
slugCheckError: 'Не удалось проверить уникальность адреса. Повторите попытку перед сохранением.',
parent: 'Родительская категория', parent: 'Родительская категория',
noParent: 'Без родителя (верхний уровень)', noParent: 'Без родителя (верхний уровень)',
icon: 'Иконка', icon: 'Иконка',
@@ -1540,6 +1556,8 @@ export const ru: Translations = {
saveDraft: 'Сохранить черновик', saveDraft: 'Сохранить черновик',
publish: 'Опубликовать', publish: 'Опубликовать',
confirmDelete: 'Удалить эту категорию? Это действие нельзя отменить.', confirmDelete: 'Удалить эту категорию? Это действие нельзя отменить.',
confirmBulkDelete: 'Удалить выбранные категории? Это действие нельзя отменить.',
deleteTitle: 'Удалить категорию',
confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?', confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
deleteBlocked: 'Эту категорию нельзя удалить, пока у неё есть подкатегории или назначенные товары.', deleteBlocked: 'Эту категорию нельзя удалить, пока у неё есть подкатегории или назначенные товары.',
emptyTitle: 'Категорий пока нет', emptyTitle: 'Категорий пока нет',
@@ -1606,6 +1624,9 @@ export const ru: Translations = {
}, },
adminProducts: { adminProducts: {
confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?', confirmLeaveUnsaved: 'У вас есть несохранённые изменения. Выйти без сохранения?',
deleteTitle: 'Удалить товар',
confirmDelete: 'Удалить этот товар? Это действие нельзя отменить.',
confirmBulkDelete: 'Удалить выбранные товары? Это действие нельзя отменить.',
emptyTitle: 'Товары не найдены', emptyTitle: 'Товары не найдены',
emptyDescription: 'Измените фильтры или создайте новый товар.', emptyDescription: 'Измените фильтры или создайте новый товар.',
emptyGuide: 'Хорошему товару нужны понятное название, минимум одно фото, цена и короткое описание — этого достаточно для публикации. Остальное можно добавить позже.', emptyGuide: 'Хорошему товару нужны понятное название, минимум одно фото, цена и короткое описание — этого достаточно для публикации. Остальное можно добавить позже.',

View File

@@ -1,14 +1,42 @@
import { Pipe, PipeTransform, inject } from '@angular/core'; import { Pipe, PipeTransform, inject } from '@angular/core';
import { TranslateService } from './translate.service'; import { TranslateService } from './translate.service';
import { LanguageService } from '../services/language.service';
/**
* Stays impure (must re-run every CD cycle so a live language switch
* updates every binding without touching hundreds of `| translate`
* template call sites to pass the language explicitly as a pure-pipe
* argument). Memoized per-instance instead: repeat calls with the same
* key/params/language - the overwhelming majority of CD cycles, since
* nothing actually changed - hit a Map lookup instead of re-walking the
* translation tree and re-running the interpolation regex.
*/
@Pipe({ @Pipe({
name: 'translate', name: 'translate',
pure: false, pure: false,
}) })
export class TranslatePipe implements PipeTransform { export class TranslatePipe implements PipeTransform {
private translateService = inject(TranslateService); private translateService = inject(TranslateService);
private langService = inject(LanguageService);
private lastLang = '';
private readonly cache = new Map<string, string>();
transform(key: string, params?: Record<string, string | number>): string { transform(key: string, params?: Record<string, string | number>): string {
return this.translateService.t(key, params); const lang = this.langService.currentLanguage();
if (lang !== this.lastLang) {
this.lastLang = lang;
this.cache.clear();
}
const cacheKey = params ? `${key}::${JSON.stringify(params)}` : key;
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const result = this.translateService.t(key, params);
this.cache.set(cacheKey, result);
return result;
} }
} }

View File

@@ -121,6 +121,10 @@ export interface Translations {
emailNeedsAt: string; emailNeedsAt: string;
emailNeedsDomain: string; emailNeedsDomain: string;
emailInvalid: string; emailInvalid: string;
telegramIdMissing: string;
paymentDescriptionFallback: string;
emailPlaceholder: string;
phonePlaceholder: string;
loginRequired: string; loginRequired: string;
loginRequiredDesc: string; loginRequiredDesc: string;
loginWithTelegram: string; loginWithTelegram: string;
@@ -138,6 +142,10 @@ export interface Translations {
noResultsHint: string; noResultsHint: string;
emptyResultsAria: string; emptyResultsAria: string;
popularCategories: string; popularCategories: string;
popularSmartphones: string;
popularSneakers: string;
popularHeadphones: string;
popularLaptops: string;
recommendedProducts: string; recommendedProducts: string;
aiSuggestionHint: string; aiSuggestionHint: string;
suggestionType: { suggestionType: {
@@ -267,6 +275,7 @@ export interface Translations {
compare: string; compare: string;
share: string; share: string;
quickView: string; quickView: string;
quickViewDetails: string;
stockHigh: string; stockHigh: string;
stockMedium: string; stockMedium: string;
stockLow: string; stockLow: string;
@@ -315,6 +324,7 @@ export interface Translations {
compare: string; compare: string;
share: string; share: string;
notifyMe: string; notifyMe: string;
notifyMeConfirmed: string;
zoom: string; zoom: string;
fullscreen: string; fullscreen: string;
pdfDocument: string; pdfDocument: string;
@@ -1118,6 +1128,8 @@ export interface Translations {
nextProducts: string; nextProducts: string;
previousSlide: string; previousSlide: string;
nextSlide: string; nextSlide: string;
pauseAutoplay: string;
resumeAutoplay: string;
closeDialog: string; closeDialog: string;
dismiss: string; dismiss: string;
qrCode: string; qrCode: string;
@@ -1298,7 +1310,8 @@ export interface Translations {
notAvailable: string; notAvailable: string;
changeStatus: string; changeStatus: string;
hideSelected: string; hideSelected: string;
archiveSelected: string; deleteSelected: string;
confirmBulkDelete: string;
reportsQueue: string; reportsQueue: string;
reportsColumn: string; reportsColumn: string;
reportTarget: string; reportTarget: string;
@@ -1356,6 +1369,8 @@ export interface Translations {
}; };
}; };
adminOrders: { adminOrders: {
bulkDeleteTitle: string;
confirmBulkDelete: string;
back: string; back: string;
search: string; search: string;
export: string; export: string;
@@ -1541,6 +1556,7 @@ export interface Translations {
title: string; title: string;
slug: string; slug: string;
slugTaken: string; slugTaken: string;
slugCheckError: string;
parent: string; parent: string;
noParent: string; noParent: string;
icon: string; icon: string;
@@ -1553,6 +1569,8 @@ export interface Translations {
saveDraft: string; saveDraft: string;
publish: string; publish: string;
confirmDelete: string; confirmDelete: string;
confirmBulkDelete: string;
deleteTitle: string;
confirmLeaveUnsaved: string; confirmLeaveUnsaved: string;
deleteBlocked: string; deleteBlocked: string;
emptyTitle: string; emptyTitle: string;
@@ -1619,6 +1637,9 @@ export interface Translations {
}; };
adminProducts: { adminProducts: {
confirmLeaveUnsaved: string; confirmLeaveUnsaved: string;
deleteTitle: string;
confirmDelete: string;
confirmBulkDelete: string;
emptyTitle: string; emptyTitle: string;
emptyDescription: string; emptyDescription: string;
emptyGuide: string; emptyGuide: string;

View File

@@ -1,3 +1,5 @@
import { UUID } from '../shared/types/primitive.types';
interface Photo { interface Photo {
photo?: string; photo?: string;
video?: string; video?: string;
@@ -182,7 +184,7 @@ export interface Item {
* Absent means marketplace-owned, exactly like every product today - * Absent means marketplace-owned, exactly like every product today -
* nothing reads this field yet, nothing breaks by it being undefined. * nothing reads this field yet, nothing breaks by it being undefined.
*/ */
sellerId?: string; sellerId?: UUID;
} }
export interface CartItem extends Item { export interface CartItem extends Item {

View File

@@ -98,7 +98,13 @@
</div> </div>
</div> </div>
<button class="delete-btn-mobile" (click)="removeItem(item)" [attr.aria-label]="'cart.removeItem' | translate"> <button
class="delete-btn-mobile"
(click)="removeItem(item)"
[attr.aria-label]="'cart.removeItem' | translate"
[attr.tabindex]="swipedItemId() === item.itemID ? null : -1"
[attr.aria-hidden]="swipedItemId() === item.itemID ? null : 'true'"
>
<app-icon name="trash" [size]="20" /> <app-icon name="trash" [size]="20" />
</button> </button>
</div> </div>
@@ -271,6 +277,44 @@
<div class="payment-status-screen success" role="status" aria-live="polite"> <div class="payment-status-screen success" role="status" aria-live="polite">
<div class="success-icon" aria-hidden="true"></div> <div class="success-icon" aria-hidden="true"></div>
<h2>{{ 'cart.paymentSuccess' | translate }}</h2> <h2>{{ 'cart.paymentSuccess' | translate }}</h2>
@if (!purchaseSubmitted()) {
<p>{{ 'cart.paymentSuccessDesc' | translate }}</p>
<form class="contact-capture-form" (ngSubmit)="submitEmail()">
<label>
<input
type="email"
name="email"
[value]="userEmail()"
[placeholder]="'cart.emailPlaceholder' | translate"
[attr.aria-invalid]="!!emailError()"
(input)="onEmailInput($event)"
(blur)="onEmailBlur()"
[disabled]="emailSubmitting()"
/>
@if (emailError()) {
<span class="field-error">{{ emailError() }}</span>
}
</label>
<label>
<input
type="tel"
name="phone"
[value]="userPhone()"
[placeholder]="'cart.phonePlaceholder' | translate"
[attr.aria-invalid]="!!phoneError()"
(input)="onPhoneInput($event)"
(blur)="onPhoneBlur()"
[disabled]="emailSubmitting()"
/>
@if (phoneError()) {
<span class="field-error">{{ phoneError() }}</span>
}
</label>
<button type="submit" [disabled]="emailSubmitting()">
{{ emailSubmitting() ? ('cart.sending' | translate) : ('cart.send' | translate) }}
</button>
</form>
}
</div> </div>
} }

View File

@@ -722,6 +722,57 @@
color: white; color: white;
margin: 0 auto 20px; margin: 0 auto 20px;
} }
.contact-capture-form {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
text-align: left;
label {
display: flex;
flex-direction: column;
gap: 4px;
}
input {
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
font-size: var(--font-size-sm, 0.875rem);
color: var(--text-primary);
background: var(--surface-color, #fff);
&[aria-invalid='true'] {
border-color: var(--error-color);
}
&:disabled {
opacity: 0.6;
}
}
.field-error {
font-size: var(--font-size-xs, 0.75rem);
color: var(--error-color);
}
button {
padding: 10px 16px;
border: none;
border-radius: var(--radius-md, 8px);
background: var(--primary-color, #2563eb);
color: white;
font-weight: var(--font-weight-bold, 700);
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: default;
}
}
}
} }
&.error { &.error {

View File

@@ -71,6 +71,7 @@ export class CartComponent implements OnDestroy {
emailError = signal<string>(''); emailError = signal<string>('');
phoneError = signal<string>(''); phoneError = signal<string>('');
emailSubmitting = signal<boolean>(false); emailSubmitting = signal<boolean>(false);
purchaseSubmitted = signal<boolean>(false);
paidItems: CartItem[] = []; paidItems: CartItem[] = [];
maxChecks = Math.ceil(PAYMENT_MIN_POLL_SECONDS / (PAYMENT_POLL_INTERVAL_MS / 1000)); maxChecks = Math.ceil(PAYMENT_MIN_POLL_SECONDS / (PAYMENT_POLL_INTERVAL_MS / 1000));
@@ -219,6 +220,7 @@ export class CartComponent implements OnDestroy {
this.emailError.set(''); this.emailError.set('');
this.phoneError.set(''); this.phoneError.set('');
this.emailSubmitting.set(false); this.emailSubmitting.set(false);
this.purchaseSubmitted.set(false);
this.paidItems = [...this.items()]; this.paidItems = [...this.items()];
this.createPayment(paymentMethod); this.createPayment(paymentMethod);
} }
@@ -254,7 +256,7 @@ export class CartComponent implements OnDestroy {
const orderId = this.generateOrderId(); const orderId = this.generateOrderId();
const paymentPayload = { const paymentPayload = {
amount: Number(this.totalWithDelivery()), amount: Number(this.totalWithDelivery()),
currency: 'RUB' as const, currency: this.langService.currentCurrency(),
siteuserID: this.getPaymentUserId(), siteuserID: this.getPaymentUserId(),
siteorderID: orderId, siteorderID: orderId,
redirectUrl: '', redirectUrl: '',
@@ -424,7 +426,6 @@ export class CartComponent implements OnDestroy {
productId: String(item.itemID), productId: String(item.itemID),
name: item.name, name: item.name,
quantity: item.quantity, quantity: item.quantity,
price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price,
})), })),
customer: { customer: {
name: this.getTelegramUsername() || this.i18n.t('common.guest'), name: this.getTelegramUsername() || this.i18n.t('common.guest'),
@@ -433,31 +434,41 @@ export class CartComponent implements OnDestroy {
}, },
payment: { payment: {
method: this.selectedPaymentMethod(), method: this.selectedPaymentMethod(),
currency: 'RUB', currency: this.langService.currentCurrency(),
}, },
}).subscribe({ }).subscribe({
error: (err) => console.error('Error recording order:', err), error: (err) => console.error('Error recording order:', err),
}); });
} }
/**
* Fallback fired a few seconds after payment success if the user hasn't
* already submitted the email/phone form themselves (submitEmail()).
* Navigates home only once the submission result is known, never before -
* and sends whatever the user has typed so far instead of blank fields.
*/
private autoSubmitPurchase(): void { private autoSubmitPurchase(): void {
setTimeout(() => { if (this.purchaseSubmitted()) {
const lang = this.langService.currentLanguage(); return;
this.router.navigate([`/${lang}`]);}, 0); }
const telegramUserId = this.getTelegramUserId(); const telegramUserId = this.getTelegramUserId();
// Telegram ID is mandatory // Telegram ID is mandatory for submitPurchaseEmail.
if (!telegramUserId) { if (!telegramUserId) {
console.error('Cannot submit purchase: Telegram ID is required'); this.notifications.show(this.i18n.t('cart.telegramIdMissing'), 'warning');
this.emailSubmitting.set(false); this.emailSubmitting.set(false);
this.closePaymentPopup();
const lang = this.langService.currentLanguage();
this.router.navigate([`/${lang}`]);
return; return;
} }
this.emailSubmitting.set(true); this.emailSubmitting.set(true);
const emailData = { const emailData = {
email: '', email: this.userEmail().trim(),
phone: '', phone: this.userPhone().replace(/\D/g, ''),
telegramUserId: telegramUserId, telegramUserId: telegramUserId,
items: this.paidItems.map((item: CartItem) => ({ items: this.paidItems.map((item: CartItem) => ({
itemID: item.itemID, itemID: item.itemID,
@@ -473,6 +484,7 @@ export class CartComponent implements OnDestroy {
this.apiService.submitPurchaseEmail(emailData).subscribe({ this.apiService.submitPurchaseEmail(emailData).subscribe({
next: () => { next: () => {
this.purchaseSubmitted.set(true);
this.emailSubmitting.set(false); this.emailSubmitting.set(false);
this.closePaymentPopup(); this.closePaymentPopup();
const lang = this.langService.currentLanguage(); const lang = this.langService.currentLanguage();
@@ -488,8 +500,6 @@ export class CartComponent implements OnDestroy {
} }
}); });
this.paymentStatus.set(null); this.paymentStatus.set(null);
} }
copyPaymentLink(): void { copyPaymentLink(): void {
@@ -542,6 +552,11 @@ export class CartComponent implements OnDestroy {
this.apiService.submitPurchaseEmail(emailData).subscribe({ this.apiService.submitPurchaseEmail(emailData).subscribe({
next: () => { next: () => {
this.purchaseSubmitted.set(true);
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = undefined;
}
this.emailSubmitting.set(false); this.emailSubmitting.set(false);
this.notifications.show(this.i18n.t('cart.emailSuccess'), 'success'); this.notifications.show(this.i18n.t('cart.emailSuccess'), 'success');
// Close popup and redirect to home page // Close popup and redirect to home page
@@ -600,7 +615,7 @@ export class CartComponent implements OnDestroy {
return hostname; return hostname;
} }
return 'Покупка на Маркетплейсе'; return this.i18n.t('cart.paymentDescriptionFallback');
} }
private generateOrderId(): string { private generateOrderId(): string {

View File

@@ -6,6 +6,14 @@
<app-skeleton shape="text" height="16px" /> <app-skeleton shape="text" height="16px" />
<app-skeleton shape="text" width="70%" height="16px" /> <app-skeleton shape="text" width="70%" height="16px" />
</section> </section>
} @else if (error()) {
<section class="static-page__state">
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate">
<div slot="actions">
<app-button variant="primary" [routerLink]="homeRoute()">{{ 'staticPages.backHome' | translate }}</app-button>
</div>
</app-empty-state>
</section>
} @else if (notFound()) { } @else if (notFound()) {
<section class="static-page__state"> <section class="static-page__state">
<app-empty-state title="404" [description]="'staticPages.notFound' | translate"> <app-empty-state title="404" [description]="'staticPages.notFound' | translate">

View File

@@ -29,6 +29,7 @@ export class StaticPageComponent {
readonly loading = signal(true); readonly loading = signal(true);
readonly notFound = signal(false); readonly notFound = signal(false);
readonly error = signal(false);
readonly title = signal(''); readonly title = signal('');
readonly homeRoute = signal(''); readonly homeRoute = signal('');
readonly dir = signal<'ltr' | 'rtl'>('ltr'); readonly dir = signal<'ltr' | 'rtl'>('ltr');
@@ -76,21 +77,32 @@ export class StaticPageComponent {
private loadByKey(key: string): void { private loadByKey(key: string): void {
this.loading.set(true); this.loading.set(true);
this.notFound.set(false); this.notFound.set(false);
this.error.set(false);
this.staticPageResolver.resolveByKey(key, this.languageService.currentLanguage()).subscribe(page => { this.staticPageResolver.resolveByKey(key, this.languageService.currentLanguage()).subscribe({
this.applyPage(page?.title ?? '', page?.html ?? '', !page); next: page => this.applyPage(page?.title ?? '', page?.html ?? '', !page),
error: () => this.applyError()
}); });
} }
private loadByPath(path: string): void { private loadByPath(path: string): void {
this.loading.set(true); this.loading.set(true);
this.notFound.set(false); this.notFound.set(false);
this.error.set(false);
this.staticPageResolver.resolveByRoute(path, this.languageService.currentLanguage()).subscribe(page => { this.staticPageResolver.resolveByRoute(path, this.languageService.currentLanguage()).subscribe({
this.applyPage(page?.title ?? '', page?.html ?? '', !page); next: page => this.applyPage(page?.title ?? '', page?.html ?? '', !page),
error: () => this.applyError()
}); });
} }
private applyError(): void {
this.title.set('');
this.safeHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
this.error.set(true);
this.loading.set(false);
}
private applyPage(title: string, html: string, notFound: boolean): void { private applyPage(title: string, html: string, notFound: boolean): void {
if (notFound) { if (notFound) {
this.title.set(''); this.title.set('');

View File

@@ -42,7 +42,7 @@ export interface QrCreateResponse {
export interface CartPaymentRequest { export interface CartPaymentRequest {
amount: number; amount: number;
currency: 'RUB'; currency: string;
siteuserID: string; siteuserID: string;
siteorderID: string; siteorderID: string;
redirectUrl: string; redirectUrl: string;
@@ -54,7 +54,12 @@ export interface CartPaymentRequest {
} }
export interface CreateOrderRequest { export interface CreateOrderRequest {
items: Array<{ productId: string; name: string; quantity: number; price: number }>; /**
* No `price` field: the backend must price each line item from its own
* catalog by `productId`, never trust a client-supplied amount.
* See BACKEND-API-REFERENCE.md §12.
*/
items: Array<{ productId: string; name: string; quantity: number }>;
customer: { name: string; email: string; phone: string }; customer: { name: string; email: string; phone: string };
payment?: { method: string; currency: string }; payment?: { method: string; currency: string };
shipping?: { address: string; method: string; trackingNumber: string }; shipping?: { address: string; method: string; trackingNumber: string };
@@ -689,6 +694,17 @@ export class ApiService {
return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData); return this.http.post<{ message: string }>(`${this.baseUrl}/purchase-email`, emailData);
} }
/**
* Back-in-stock subscription. No backend endpoint exists for this yet
* (tracked in BACKEND-API-REFERENCE.md §12) - callers should fall back to
* a local-only record (see CartService/UX facade patterns) when this 404s
* or the request otherwise fails, rather than surfacing an error to the
* shopper for something this minor.
*/
subscribeToRestock(itemID: number, contact: { telegramUserId: string | null; email?: string }): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/items/${itemID}/notify-me`, contact);
}
getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> { getRandomItems(count: number = 5, categoryID?: number): Observable<Item[]> {
let params = new HttpParams().set('count', count.toString()); let params = new HttpParams().set('count', count.toString());
if (categoryID) { if (categoryID) {

View File

@@ -203,9 +203,16 @@ export class CartService {
return this.cartItems().findIndex(item => this.isSameCartLine(item, itemID, variant)); return this.cartItems().findIndex(item => this.isSameCartLine(item, itemID, variant));
} }
addItem(itemID: number, quantity: number = 1, variant?: CartVariant): void { /**
* Resolves once the item is actually present in the cart signal - not
* merely once the call was fired. New (not-yet-in-cart) items fetch their
* details from the API first, so callers that navigate straight after
* (e.g. Buy Now -> /cart) must await this rather than treat it as fired
* synchronously.
*/
addItem(itemID: number, quantity: number = 1, variant?: CartVariant): Promise<void> {
// Prevent duplicate API calls for same item // Prevent duplicate API calls for same item
if (this.addingItems.has(itemID)) return; if (this.addingItems.has(itemID)) return Promise.resolve();
const currentItems = this.cartItems(); const currentItems = this.cartItems();
const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant)); const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant));
@@ -213,10 +220,13 @@ export class CartService {
if (existingItem) { if (existingItem) {
// Item exists, increase quantity // Item exists, increase quantity
this.updateQuantity(itemID, existingItem.quantity + quantity, variant); this.updateQuantity(itemID, existingItem.quantity + quantity, variant);
} else { return Promise.resolve();
// Get item details from API and add to cart }
this.addingItems.add(itemID);
import('./api.service').then(({ ApiService }) => { // Get item details from API and add to cart
this.addingItems.add(itemID);
return import('./api.service').then(({ ApiService }) =>
new Promise<void>((resolve) => {
this.injector.get(ApiService).getItem(itemID).subscribe({ this.injector.get(ApiService).getItem(itemID).subscribe({
next: (item) => { next: (item) => {
const cartItem = this.normalizeCartItem({ const cartItem = this.normalizeCartItem({
@@ -229,17 +239,19 @@ export class CartService {
}); });
this.cartItems.set([...this.cartItems(), cartItem]); this.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID); this.addingItems.delete(itemID);
resolve();
}, },
error: (err) => { error: (err) => {
console.error('Error adding to cart:', err); console.error('Error adding to cart:', err);
this.addingItems.delete(itemID); this.addingItems.delete(itemID);
resolve();
} }
}); });
}).catch((err) => { })
console.error('Error loading API service:', err); ).catch((err) => {
this.addingItems.delete(itemID); console.error('Error loading API service:', err);
}); this.addingItems.delete(itemID);
} });
} }
updateQuantity(itemID: number, quantity: number, variant?: CartVariant): void { updateQuantity(itemID: number, quantity: number, variant?: CartVariant): void {

View File

@@ -4,6 +4,13 @@ import { Item } from '../models';
import { getDiscountedPrice, getMainImage } from '../utils/item.utils'; import { getDiscountedPrice, getMainImage } from '../utils/item.utils';
import { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade'; import { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade';
import { ConfigService } from '../core/config/config.service'; import { ConfigService } from '../core/config/config.service';
import { LanguageService } from './language.service';
const OG_LOCALE_MAP: Record<string, string> = {
ru: 'ru_RU',
en: 'en_US',
hy: 'hy_AM',
};
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@@ -14,6 +21,7 @@ export class SeoService {
private doc = inject(DOCUMENT); private doc = inject(DOCUMENT);
private readonly uiRuntime = inject(UiRuntimeFacade); private readonly uiRuntime = inject(UiRuntimeFacade);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private readonly languageService = inject(LanguageService);
constructor() { constructor() {
// Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with // Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with
@@ -38,6 +46,10 @@ export class SeoService {
return this.uiRuntime.marketplaceDisplayName() || 'Marketplace'; return this.uiRuntime.marketplaceDisplayName() || 'Marketplace';
} }
private get ogLocale(): string {
return OG_LOCALE_MAP[this.languageService.currentLanguage()] ?? 'en_US';
}
/** /**
* Set Open Graph & Twitter Card meta tags for a product/item page. * Set Open Graph & Twitter Card meta tags for a product/item page.
*/ */
@@ -59,7 +71,7 @@ export class SeoService {
{ property: 'og:image', content: imageUrl }, { property: 'og:image', content: imageUrl },
{ property: 'og:url', content: itemUrl }, { property: 'og:url', content: itemUrl },
{ property: 'og:site_name', content: this.siteName }, { property: 'og:site_name', content: this.siteName },
{ property: 'og:locale', content: 'ru_RU' }, { property: 'og:locale', content: this.ogLocale },
// Product-specific OG tags // Product-specific OG tags
{ property: 'product:price:amount', content: price.toFixed(2) }, { property: 'product:price:amount', content: price.toFixed(2) },
@@ -74,6 +86,22 @@ export class SeoService {
// Standard meta // Standard meta
{ name: 'description', content: description }, { name: 'description', content: description },
]); ]);
this.setJsonLd({
'@context': 'https://schema.org',
'@type': 'Product',
name: item.name,
description,
image: imageUrl,
url: itemUrl,
offers: {
'@type': 'Offer',
price: price.toFixed(2),
priceCurrency: item.currency || 'RUB',
availability: (item.quantity ?? 0) > 0 ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock',
url: itemUrl,
},
});
} }
/** /**
@@ -111,7 +139,7 @@ export class SeoService {
{ property: 'og:image', content: defaultImage }, { property: 'og:image', content: defaultImage },
{ property: 'og:url', content: this.siteUrl }, { property: 'og:url', content: this.siteUrl },
{ property: 'og:site_name', content: this.siteName }, { property: 'og:site_name', content: this.siteName },
{ property: 'og:locale', content: 'ru_RU' }, { property: 'og:locale', content: this.ogLocale },
{ name: 'twitter:card', content: 'summary_large_image' }, { name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:title', content: defaultTitle }, { name: 'twitter:title', content: defaultTitle },
@@ -127,6 +155,30 @@ export class SeoService {
// Remove product-specific tags // Remove product-specific tags
this.meta.removeTag("property='product:price:amount'"); this.meta.removeTag("property='product:price:amount'");
this.meta.removeTag("property='product:price:currency'"); this.meta.removeTag("property='product:price:currency'");
this.setJsonLd({
'@context': 'https://schema.org',
'@type': 'Organization',
name: this.siteName,
url: this.siteUrl,
...(defaultImage ? { logo: defaultImage } : {}),
});
}
/** Replace (or remove, when data is null) the page's JSON-LD structured-data script tag. */
private setJsonLd(data: Record<string, unknown> | null): void {
const existing = this.doc.getElementById('seo-json-ld');
existing?.remove();
if (!data) {
return;
}
const script = this.doc.createElement('script');
script.id = 'seo-json-ld';
script.type = 'application/ld+json';
script.text = JSON.stringify(data);
this.doc.head.appendChild(script);
} }
private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void { private setOrUpdate(tags: Array<{ property?: string; name?: string; content: string }>): void {

View File

@@ -2,7 +2,21 @@ import { ThemeConfig } from '../../shared/models/config';
import { ThemeCssVariables } from '../tokens/theme-css-variable.model'; import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map'; import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map';
export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariables { /**
* Dark-mode neutral/surface overrides. Brand colors (primary/secondary/
* accent/success/warning/danger/info) are intentionally left as configured -
* only the background/text/border axis flips for dark mode, same as most
* dark-theme implementations.
*/
const DARK_MODE_OVERRIDES: ThemeCssVariables = {
[THEME_VARIABLE_MAP.backgroundPrimary]: '#091413',
[THEME_VARIABLE_MAP.backgroundSecondary]: '#285a48',
[THEME_VARIABLE_MAP.textPrimary]: '#b0e4cc',
[THEME_VARIABLE_MAP.textSecondary]: '#408a71',
[THEME_VARIABLE_MAP.border]: '#285a48',
};
export function mapThemeConfigToCssVariables(theme: ThemeConfig, effectiveMode: 'light' | 'dark' = 'light'): ThemeCssVariables {
const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0 const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0
? theme.spacing.scale ? theme.spacing.scale
: [0.25, 0.5, 1, 1.5, 2]; : [0.25, 0.5, 1, 1.5, 2];
@@ -49,5 +63,5 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab
vars[`--shadow-${key}`] = value; vars[`--shadow-${key}`] = value;
} }
return vars; return effectiveMode === 'dark' ? { ...vars, ...DARK_MODE_OVERRIDES } : vars;
} }

View File

@@ -10,6 +10,9 @@ export class ThemeEngineService {
private readonly document = inject(DOCUMENT); private readonly document = inject(DOCUMENT);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private systemDarkQuery?: MediaQueryList;
private systemDarkListener?: (event: MediaQueryListEvent) => void;
initialize(): void { initialize(): void {
this.configService.loadBootstrap().pipe(take(1)).subscribe({ this.configService.loadBootstrap().pipe(take(1)).subscribe({
next: (bootstrap) => this.applyTheme(bootstrap.theme), next: (bootstrap) => this.applyTheme(bootstrap.theme),
@@ -20,7 +23,20 @@ export class ThemeEngineService {
} }
applyTheme(theme: ThemeConfig): void { applyTheme(theme: ThemeConfig): void {
const variables = mapThemeConfigToCssVariables(theme); this.teardownSystemModeListener();
const effectiveMode = this.resolveEffectiveMode(theme.mode);
this.render(theme, effectiveMode);
if (theme.mode === 'system' && typeof window !== 'undefined' && window.matchMedia) {
this.systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.systemDarkListener = () => this.render(theme, this.resolveEffectiveMode('system'));
this.systemDarkQuery.addEventListener('change', this.systemDarkListener);
}
}
private render(theme: ThemeConfig, effectiveMode: 'light' | 'dark'): void {
const variables = mapThemeConfigToCssVariables(theme, effectiveMode);
const root = this.document.documentElement; const root = this.document.documentElement;
for (const [name, value] of Object.entries(variables)) { for (const [name, value] of Object.entries(variables)) {
@@ -28,7 +44,27 @@ export class ThemeEngineService {
} }
root.setAttribute('data-theme-id', theme.themeId); root.setAttribute('data-theme-id', theme.themeId);
root.setAttribute('data-theme-mode', theme.mode); root.setAttribute('data-theme-mode', effectiveMode);
root.setAttribute('data-icon-set', theme.iconSet); root.setAttribute('data-icon-set', theme.iconSet);
} }
private resolveEffectiveMode(mode: ThemeConfig['mode']): 'light' | 'dark' {
if (mode === 'dark') {
return 'dark';
}
if (mode === 'light') {
return 'light';
}
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
private teardownSystemModeListener(): void {
if (this.systemDarkQuery && this.systemDarkListener) {
this.systemDarkQuery.removeEventListener('change', this.systemDarkListener);
}
this.systemDarkQuery = undefined;
this.systemDarkListener = undefined;
}
} }

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { Observable, of, map, switchMap } from 'rxjs'; import { Observable, of, map, switchMap, catchError } from 'rxjs';
import { CategoryFacade } from '../../facades/platform/category.facade'; import { CategoryFacade } from '../../facades/platform/category.facade';
import { ProductFacade } from '../../facades/platform/product.facade'; import { ProductFacade } from '../../facades/platform/product.facade';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
@@ -19,7 +19,11 @@ export class DataSourceResolverService {
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> { resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
return this.widgetManifest.getWidget(widget.type).pipe( return this.widgetManifest.getWidget(widget.type).pipe(
switchMap((definition) => this.resolveByDefinition(definition, widget, section)) switchMap((definition) => this.resolveByDefinition(definition, widget, section)),
catchError((error) => {
console.error(`Failed to resolve widget data for ${widget.type}:${widget.id}`, error);
return of({ section, settings: {} });
})
); );
} }

View File

@@ -61,6 +61,15 @@ const SWIPE_THRESHOLD_PX = 50;
(click)="goTo($index)" (click)="goTo($index)"
></button> ></button>
} }
@if (data?.autoplay) {
<button
type="button"
class="hero-widget__pause"
[attr.aria-label]="(isPaused() ? 'common.resumeAutoplay' : 'common.pauseAutoplay') | translate"
[attr.aria-pressed]="isPaused()"
(click)="toggleAutoplay()"
>{{ isPaused() ? '▶' : '⏸' }}</button>
}
</div> </div>
} }
} }
@@ -171,6 +180,23 @@ const SWIPE_THRESHOLD_PX = 50;
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; } &:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
} }
.hero-widget__pause {
margin-left: var(--space-sm, 8px);
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid var(--border-color, #d3dad9);
background: #fff;
cursor: pointer;
font-size: 0.7rem;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
&:focus-visible { outline: 2px solid var(--primary-color, #497671); outline-offset: 2px; }
}
@keyframes hero-widget-in { @keyframes hero-widget-in {
from { opacity: 0; transform: translateY(12px); } from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@@ -199,6 +225,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
@Output() ctaClicked = new EventEmitter<void>(); @Output() ctaClicked = new EventEmitter<void>();
readonly activeIndex = signal(0); readonly activeIndex = signal(0);
readonly isPaused = signal(false);
private readonly dataSignal = signal<HeroWidgetData | null>(null); private readonly dataSignal = signal<HeroWidgetData | null>(null);
private autoplayHandle: ReturnType<typeof setInterval> | null = null; private autoplayHandle: ReturnType<typeof setInterval> | null = null;
private swipeStartX: number | null = null; private swipeStartX: number | null = null;
@@ -237,6 +264,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
if (changes['data']) { if (changes['data']) {
this.dataSignal.set(this.data); this.dataSignal.set(this.data);
this.activeIndex.set(0); this.activeIndex.set(0);
this.isPaused.set(false);
this.setupAutoplay(); this.setupAutoplay();
} }
} }
@@ -289,10 +317,20 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
this.ctaClicked.emit(); this.ctaClicked.emit();
} }
/** WCAG 2.2.2: auto-updating content lasting >5s needs a way to pause it. */
toggleAutoplay(): void {
this.isPaused.update(paused => !paused);
this.setupAutoplay();
}
private prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
}
private setupAutoplay(): void { private setupAutoplay(): void {
this.clearAutoplay(); this.clearAutoplay();
const slides = this.allSlides(); const slides = this.allSlides();
if (!this.data?.autoplay || slides.length <= 1) { if (!this.data?.autoplay || slides.length <= 1 || this.isPaused() || this.prefersReducedMotion()) {
return; return;
} }
this.autoplayHandle = setInterval(() => { this.autoplayHandle = setInterval(() => {

View File

@@ -352,6 +352,19 @@
"en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</p>", "en": "<h2>Terms of Service</h2><p>Platform usage is governed by public offer terms.</p>",
"hy": "<h2>Օգտագործման պայմաններ</h2><p>Հարթակի օգտագործումը կարգավորվում է հրապարակային առաջարկի պայմաններով։</p>" "hy": "<h2>Օգտագործման պայմաններ</h2><p>Հարթակի օգտագործումը կարգավորվում է հրապարակային առաջարկի պայմաններով։</p>"
} }
},
"contacts": {
"route": "/contacts",
"title": {
"ru": "Контакты",
"en": "Contacts",
"hy": "Կապ"
},
"html": {
"ru": "<h2>Контакты</h2><p>Здесь будет размещена контактная информация продавца — адрес, телефон, email и часы работы. Заполняется администратором маркетплейса.</p>",
"en": "<h2>Contacts</h2><p>Seller contact details (address, phone, email, business hours) go here. Fill this in from the admin panel.</p>",
"hy": "<h2>Կապ</h2><p>Այստեղ կտեղադրվի վաճառողի կոնտակտային տեղեկատվությունը՝ հասցե, հեռախոս, էլ. փոստ և աշխատանքային ժամեր։ Լրացվում է կայքի ադմինիստրատորի կողմից։</p>"
}
} }
}, },
"pages": [ "pages": [