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.
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-XSS-Protection "1; mode=block" 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.
@@ -178,4 +180,6 @@ server {
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" 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 { languageGuard } from './guards/language.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 { adminCategoryDirtyGuard } from './features/admin/categories/guards/admin-category-dirty.guard';
import { adminProductDirtyGuard } from './features/admin/products/guards/admin-product-dirty.guard';
@@ -229,6 +229,7 @@ const coreRoutes: Routes = [
},
{
path: 'users',
canActivate: [requireAdminPermission('users.manage')],
loadComponent: () => import('./features/admin/users/pages/admin-users-page.component').then(m => m.AdminUsersPageComponent),
data: {
titleKey: 'adminShell.pages.users.title',

View File

@@ -1,6 +1,7 @@
import { inject } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { AdminAuthService } from './admin-auth.service';
import { AdminPermissionsService } from './admin-permissions.service';
/** Guards `/admin/**` routes. Never shares state with the customer auth guard/service. */
export const adminAuthGuard: CanActivateFn = () => {
@@ -13,3 +14,22 @@ export const adminAuthGuard: CanActivateFn = () => {
adminAuth.requestLogin();
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;
}
/**
* 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. */
export function authErrorCodeFromStatus(status: number): AuthErrorCode {
switch (status) {

View File

@@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { catchError, switchMap, tap, throwError } from 'rxjs';
import { Observable } from 'rxjs';
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 { Ed25519KeypairService } from './ed25519-keypair.service';
import { SessionService } from './session.service';
@@ -109,7 +109,9 @@ export class AuthService {
private toAuthErrorShape(error: unknown, fallbackCode: AuthError['code']): AuthError {
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) {
return { code: fallbackCode, message: error.message };

View File

@@ -1,20 +1,13 @@
import { InjectionToken, inject } from '@angular/core';
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
import { ApiCategoryRepository } from './repositories/api-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', {
providedIn: 'root',
factory: () => {
const strategy = inject(RuntimeProviderStrategyService);
const apiRepository = inject(ApiCategoryRepository);
switch (strategy.getCategoryProviderMode()) {
case 'mock':
case 'remote-config':
case 'api':
default:
return apiRepository;
}
}
factory: () => inject(ApiCategoryRepository)
});

View File

@@ -1,20 +1,14 @@
import { InjectionToken, inject } from '@angular/core';
import { RuntimeProviderStrategyService } from '../providers/runtime-provider-strategy.service';
import { ApiProductDataProvider } from './providers/api-product-data.provider';
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', {
providedIn: 'root',
factory: () => {
const strategy = inject(RuntimeProviderStrategyService);
const apiProvider = inject(ApiProductDataProvider);
switch (strategy.getProductProviderMode()) {
case 'mock':
case 'remote-config':
case 'api':
default:
return apiProvider;
}
}
factory: () => inject(ApiProductDataProvider)
});

View File

@@ -1,12 +1,15 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { PageConfig } from '../../shared/models/config';
import { PageRenderModel } from '../page-renderer/page-renderer.model';
import { SectionRendererService } from '../section-renderer/section-renderer.service';
import { PlatformLayoutConfig, PlatformLayoutType } from '../../shared/models/config';
import { SectionConfig } from '../../shared/models/config';
import { ConfigService } from '../../core/config/config.service';
@Injectable({ providedIn: 'root' })
export class SectionEngineService {
private readonly configService = inject(ConfigService);
constructor(private readonly sectionRenderer: SectionRendererService) {}
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 {
if (typeof layout === 'string') {
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'] {

View File

@@ -1,5 +1,6 @@
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 {
AdminAnalyticsDateRange,
AdminAnalyticsSeriesPoint,
@@ -62,7 +63,13 @@ export class AdminAnalyticsFacade {
readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info'));
private readonly cancelPreviousLoad$ = new Subject<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.error.set(false);
this.dashboardFacade.ensureLoaded();
@@ -74,10 +81,13 @@ export class AdminAnalyticsFacade {
})),
);
const fail = (): void => { this.loading.set(false); this.error.set(true); };
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: orderResult => {
forkJoin({
orderResult: this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }),
productResult: this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }),
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 inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff);
@@ -89,43 +99,28 @@ export class AdminAnalyticsFacade {
const ordersCount = inRange.length;
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({
next: productResult => {
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;
const products = productResult.items;
const reviews = reviewResult.items;
this.summary.set({
revenueTotal,
currency: inRange[0]?.currency ?? 'RUB',
ordersCount,
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
productsCount: products.length,
categoriesCount: categories.length,
customersCount: uniqueCustomers,
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.summary.set({
revenueTotal,
currency: inRange[0]?.currency ?? 'RUB',
ordersCount,
avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0,
productsCount: products.length,
categoriesCount: categories.length,
customersCount: uniqueCustomers,
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: () => { 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" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (error) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (treeRows.length === 0) {
<app-empty-state [title]="'adminCategories.emptyTitle' | translate" [description]="'adminCategories.emptyDescription' | translate">
<span slot="actions">

View File

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

View File

@@ -29,7 +29,7 @@
<app-form-field [label]="'adminCategories.title' | translate" [required]="true">
<app-input [ngModel]="category.title" (ngModelChange)="updateTitle($event)" />
</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-form-field>
<label><span>{{ 'adminCategories.parent' | translate }}</span>

View File

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

View File

@@ -1,5 +1,5 @@
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 { AdminCategoriesFormFactory } from '../services/admin-categories-form.factory';
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 categories = signal<AdminCategory[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly draft = signal<AdminCategory | null>(null);
readonly editorMode = signal<AdminCategoryEditorMode>('create');
readonly dirty = signal(false);
readonly slugTaken = signal(false);
readonly slugCheckError = signal(false);
readonly mutationError = signal<string | null>(null);
private savedSnapshot: string | null = null;
private draftStorageKey: string | null = null;
@@ -173,6 +176,7 @@ export class AdminCategoriesFacade {
*/
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({
next: categories => {
this.categories.set(categories);
@@ -181,6 +185,7 @@ export class AdminCategoriesFacade {
error: () => {
this.categories.set([]);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
@@ -305,16 +310,28 @@ export class AdminCategoriesFacade {
const draft = this.draft();
if (!draft || !draft.slug) {
this.slugTaken.set(false);
this.slugCheckError.set(false);
return;
}
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(take(1))
.subscribe(taken => this.slugTaken.set(taken));
this.gateway.isSlugTaken(draft.slug, this.editorMode() === 'edit' ? draft.id : null).pipe(
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();
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 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.dirty.set(false);
this.loadList();
}
onSuccess?.();
},
error: () => this.mutationError.set('common.errorDescription')
});
}
@@ -342,7 +361,11 @@ export class AdminCategoriesFacade {
}
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 {

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 { AdminCategoriesFacade } from '../facade/admin-categories.facade';
import { AdminCategoriesListComponent } from '../components/admin-categories-list.component';
import { LanguageService } from '../../../../services/language.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({
selector: 'app-admin-categories-list-page',
standalone: true,
imports: [AdminCategoriesListComponent],
imports: [AdminCategoriesListComponent, ConfirmDialogComponent, DialogComponent, ButtonComponent, TranslatePipe],
template: `<app-admin-categories-list
[treeRows]="facade.visibleTreeRows()"
[flatCategories]="facade.filteredCategories()"
[allCategories]="facade.categories()"
[filters]="facade.filters()"
[loading]="facade.loading()"
[error]="facade.error()"
[viewMode]="facade.viewMode()"
[density]="facade.density()"
[visibleColumns]="facade.visibleColumns()"
@@ -37,14 +42,44 @@ import { TranslateService } from '../../../../i18n/translate.service';
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
(selectAll)="facade.toggleAll($event)"
(bulkVisibility)="facade.applyBulkVisibility($event)"
(bulkDelete)="facade.applyBulkDelete()"
(bulkDelete)="bulkDelete()"
(bulkDuplicate)="facade.applyBulkDuplicate()"
(bulkAssignParent)="facade.applyBulkAssignParent($event)"
(bulkAssignImage)="facade.applyBulkAssignImage($event)"
(bulkExport)="facade.exportSelectedAsCsv()"
(viewModeChange)="facade.setViewMode($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
})
export class AdminCategoriesListPageComponent {
@@ -57,6 +92,10 @@ export class AdminCategoriesListPageComponent {
readonly isExpandedFn = (id: string) => this.facade.isExpanded(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() {
this.facade.loadList();
this.facade.loadDashboardStats();
@@ -75,12 +114,27 @@ export class AdminCategoriesListPageComponent {
deleteOne(id: string): void {
if (!this.facade.canDelete(id)) {
window.alert(this.translate.t('adminCategories.deleteBlocked'));
this.deleteBlockedMessage.set(this.translate.t('adminCategories.deleteBlocked'));
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.pendingDeleteId.set(null);
}
bulkDelete(): void {
this.bulkDeleteConfirmOpen.set(true);
}
confirmBulkDelete(): void {
this.facade.applyBulkDelete();
this.bulkDeleteConfirmOpen.set(false);
}
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 { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({
selector: 'app-admin-category-editor-page',
standalone: true,
imports: [AdminCategoryFormComponent, TranslatePipe],
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>}`,
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()" [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; }`],
changeDetection: ChangeDetectionStrategy.OnPush
})
@@ -41,8 +51,9 @@ export class AdminCategoryEditorPageComponent {
}
save(publish: boolean): void {
this.facade.saveDraft(publish);
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
this.facade.saveDraft(publish, () => {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'categories']);
});
}
private descendantIds(id: string): string[] {

View File

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

View File

@@ -10,8 +10,11 @@ export class AdminCustomersFacade {
readonly customers = signal<AdminCustomer[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly search = signal('');
readonly selected = signal<AdminCustomer | null>(null);
readonly selectedLoading = signal(false);
readonly selectedError = signal<string | null>(null);
private buildCustomers(orders: AdminOrder[]): AdminCustomer[] {
const byEmail = new Map<string, AdminOrder[]>();
@@ -41,6 +44,7 @@ export class AdminCustomersFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({
next: result => {
this.customers.set(this.buildCustomers(result.items));
@@ -49,16 +53,24 @@ export class AdminCustomersFacade {
error: () => {
this.customers.set([]);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
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({
next: result => {
const decoded = decodeURIComponent(email);
const customers = this.buildCustomers(result.items);
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" />
</section>
</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 {
<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" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.filteredCustomers().length === 0) {
<app-empty-state [title]="'adminCustomers.emptyTitle' | translate" [description]="'adminCustomers.emptyDescription' | translate" />
} @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)="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="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>
}
<app-confirm-dialog
[open]="bulkDeleteConfirmOpen()"
[titleText]="'adminModeration.deleteSelected' | translate"
[message]="'adminModeration.confirmBulkDelete' | translate"
[destructive]="true"
(confirmed)="confirmBulkDelete()"
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
@if (facade.loading()) {
<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 { ModerationDashboardComponent } from '../components/moderation-dashboard/moderation-dashboard.component';
import { ReviewHealthWidgetComponent, ReviewHealthItem } from '../components/review-health-widget/review-health-widget.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
@Component({
selector: 'app-admin-reviews-list-page',
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',
styleUrls: ['./admin-reviews-list-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -35,6 +36,7 @@ export class AdminReviewsListPageComponent {
readonly allColumns = ALL_REVIEW_COLUMNS;
protected readonly columnsPanelOpen = signal(false);
protected readonly bulkStatusValue = signal<AdminReviewStatus>('approved');
protected readonly bulkDeleteConfirmOpen = signal(false);
constructor() {
this.facade.loadList();
@@ -45,6 +47,15 @@ export class AdminReviewsListPageComponent {
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 {
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'moderation', id]);
}

View File

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

View File

@@ -55,3 +55,10 @@
font-size: var(--font-size-sm, 0.8125rem);
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. */
note: string;
orderNumber?: string;
actor?: string;
}
/** 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 total = signal(0);
readonly loading = signal(false);
readonly error = signal<string | 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 density = signal<AdminOrdersDensity>((this.localStorage.getItem(DENSITY_KEY) as AdminOrdersDensity) || 'comfortable');
@@ -76,6 +79,7 @@ export class AdminOrdersFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadOrders(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.orders.set(result.items);
@@ -86,6 +90,7 @@ export class AdminOrdersFacade {
this.orders.set([]);
this.total.set(0);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
@@ -96,7 +101,18 @@ export class AdminOrdersFacade {
}
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 {

View File

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

View File

@@ -46,14 +46,17 @@
</div>
<div class="card no-print">
<h3>{{ 'adminOrders.changeStatus' | translate }}</h3>
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)">
@for (status of statuses; track status) {
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="order.status" (ngModelChange)="setStatus(order.id, $event)" [disabled]="isTerminal()">
@for (status of selectableStatuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
}
@if (isTerminal()) {
<option [value]="order.status">{{ ('adminOrders.status.' + order.status) | translate }}</option>
}
</select>
<div class="actions">
<app-button variant="secondary" size="sm" (click)="requestRefund(order.id)">{{ 'adminOrders.requestRefund' | translate }}</app-button>
<app-button variant="danger" size="sm" (click)="cancel(order.id)">{{ 'adminOrders.cancelOrder' | 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)" [disabled]="isTerminal()">{{ 'adminOrders.cancelOrder' | translate }}</app-button>
</div>
</div>
</section>
@@ -85,7 +88,27 @@
<app-button variant="secondary" size="sm" (click)="submitInternalNote(order.id)">{{ 'adminOrders.addNote' | translate }}</app-button>
</div>
</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>
} @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 {
<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 { BadgeComponent } from '../../../../shared/ui/badge/badge.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 TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
@@ -17,7 +18,7 @@ const TERMINAL_STATUSES: AdminOrderStatus[] = ['cancelled', 'refunded'];
@Component({
selector: 'app-admin-order-detail-page',
standalone: true,
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent],
imports: [CommonModule, FormsModule, TranslatePipe, ButtonComponent, BadgeComponent, OrderTimelineComponent, ConfirmDialogComponent],
templateUrl: './admin-order-detail-page.component.html',
styleUrls: ['./admin-order-detail-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -30,8 +31,12 @@ export class AdminOrderDetailPageComponent {
private readonly translate = inject(TranslateService);
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 noteDraft = signal('');
readonly pendingCancelId = signal<string | null>(null);
readonly pendingRefundId = signal<string | null>(null);
readonly internalNoteDraft = signal('');
readonly isTerminal = computed(() => {
@@ -54,6 +59,7 @@ export class AdminOrderDetailPageComponent {
note: this.translate.t('adminOrders.timelineEvent.' + entry.eventKey, {
status: this.translate.t('adminOrders.status.' + entry.status),
}),
actor: entry.actor,
}));
});
@@ -73,19 +79,36 @@ export class AdminOrderDetailPageComponent {
}
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);
}
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.pendingCancelId.set(null);
}
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.pendingRefundId.set(null);
}
submitNote(id: string): void {

View File

@@ -37,23 +37,32 @@
<div class="bulk-actions">
<span>{{ facade.selectedIds().length }} {{ 'adminProducts.selectedCount' | translate }}</span>
<select [attr.aria-label]="'adminOrders.changeStatus' | translate" [ngModel]="bulkStatusValue()" (ngModelChange)="bulkStatusValue.set($event)">
@for (status of statuses; track status) {
@if (status !== 'all') { <option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option> }
@for (status of bulkSelectableStatuses; track status) {
<option [value]="status">{{ ('adminOrders.status.' + status) | translate }}</option>
}
</select>
<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.exportSelectedAsCsv()">{{ 'adminProducts.bulkExportAction' | 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>
}
<app-confirm-dialog
[open]="bulkDeleteConfirmOpen()"
[titleText]="'adminOrders.bulkDeleteTitle' | translate"
[message]="'adminOrders.confirmBulkDelete' | translate"
[destructive]="true"
(confirmed)="confirmBulkDelete()"
(cancelled)="bulkDeleteConfirmOpen.set(false)" />
@if (facade.loading()) {
<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" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.orders().length === 0) {
<app-empty-state [title]="'adminOrders.emptyTitle' | translate" [description]="'adminOrders.emptyDescription' | translate" />
} @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 { CardComponent } from '../../../../shared/ui/card/card.component';
import { OrdersDashboardComponent } from '../components/orders-dashboard/orders-dashboard.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
@Component({
selector: 'app-admin-orders-list-page',
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',
styleUrls: ['./admin-orders-list-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -30,9 +31,12 @@ export class AdminOrdersListPageComponent {
private readonly languageService = inject(LanguageService);
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;
protected readonly columnsPanelOpen = signal(false);
protected readonly bulkStatusValue = signal<AdminOrderStatus>('pending');
protected readonly bulkDeleteConfirmOpen = signal(false);
constructor() {
this.facade.loadList();
@@ -59,6 +63,15 @@ export class AdminOrdersListPageComponent {
this.facade.applyBulkStatus(this.bulkStatusValue());
}
bulkDelete(): void {
this.bulkDeleteConfirmOpen.set(true);
}
confirmBulkDelete(): void {
this.facade.applyBulkDelete();
this.bulkDeleteConfirmOpen.set(false);
}
printSelection(): void {
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 { delay } from 'rxjs/operators';
import { AdminOrder, AdminOrderListFilters, AdminOrdersListResult, AdminOrderStatus } from '../models/admin-order.model';
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 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' })
export class AdminOrdersLocalGateway implements AdminOrdersGateway {
private readonly adminAuth = inject(AdminAuthService);
private cache: AdminOrder[] | null = null;
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
loadOrders(filters: AdminOrderListFilters): Observable<AdminOrdersListResult> {
const all = this.ensureData();
const filtered = all
@@ -36,7 +42,7 @@ export class AdminOrdersLocalGateway implements AdminOrdersGateway {
...order,
status,
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,
payment: { ...order.payment, status: 'refund_requested' },
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: '',
internalNotes: '',
timeline: [
{ status: 'pending', timestamp: createdAt, eventKey: 'created' },
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const }] : []),
{ status: 'pending', timestamp: createdAt, eventKey: 'created', actor: 'system' },
...(status !== 'pending' ? [{ status, timestamp: createdAt, eventKey: 'statusChanged' as const, actor: 'system' }] : []),
],
archived: false,
createdAt,

View File

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

View File

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

View File

@@ -91,6 +91,8 @@ export class AdminProductsFacade {
readonly infiniteScroll = signal(false);
readonly categories = signal<AdminProductCategoryOption[]>([]);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly mutationError = signal<string | null>(null);
readonly selectedIds = signal<string[]>([]);
readonly draft = signal<AdminProduct | null>(null);
readonly dirty = signal(false);
@@ -100,6 +102,7 @@ export class AdminProductsFacade {
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadProducts(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.products.set(result.items);
@@ -110,6 +113,7 @@ export class AdminProductsFacade {
this.products.set([]);
this.total.set(0);
this.loading.set(false);
this.error.set('common.errorDescription');
}
});
}
@@ -302,18 +306,26 @@ export class AdminProductsFacade {
this.dirty.set(true);
}
saveDraft(): void {
saveDraft(onSuccess?: () => void): void {
const draft = this.draft();
if (!draft) return;
this.mutationError.set(null);
const request = this.editorMode() === 'create'
? this.gateway.createProduct(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 {
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 AdminProductSort = 'title' | 'price' | 'priority' | 'stock' | 'updated';
export type AdminProductEditorMode = 'create' | 'edit' | 'duplicate';
@@ -117,7 +119,7 @@ export interface AdminProduct {
* Absent means marketplace-owned, exactly like every product today -
* nothing reads this field yet, nothing breaks by it being undefined.
*/
sellerId?: string;
sellerId?: UUID;
}
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 { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../services/language.service';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
@Component({
selector: 'app-admin-product-editor-page',
standalone: true,
imports: [AdminProductFormComponent, TranslatePipe],
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>}`,
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>}
@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; }`],
changeDetection: ChangeDetectionStrategy.OnPush
})
@@ -33,7 +43,8 @@ export class AdminProductEditorPageComponent {
}
save(): void {
this.facade.saveDraft();
void this.router.navigate([this.languageService.currentLanguage(), 'backoffice', 'products']);
this.facade.saveDraft(() => {
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 { AdminProductsFacade } from '../facade/admin-products.facade';
import { AdminProductsListComponent } from '../components/admin-products-list.component';
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({
selector: 'app-admin-products-list-page',
standalone: true,
imports: [AdminProductsListComponent],
imports: [AdminProductsListComponent, ConfirmDialogComponent, TranslatePipe, DialogComponent, ButtonComponent],
template: `<app-admin-products-list
[products]="facade.products()"
[categories]="facade.categories()"
@@ -15,6 +19,7 @@ import { LanguageService } from '../../../../services/language.service';
[total]="facade.total()"
[selectedIds]="facade.selectedIds()"
[loading]="facade.loading()"
[error]="facade.error()"
[infiniteScroll]="facade.infiniteScroll()"
[viewMode]="facade.viewMode()"
[density]="facade.density()"
@@ -25,7 +30,7 @@ import { LanguageService } from '../../../../services/language.service';
(create)="create()"
(edit)="edit($event)"
(duplicate)="duplicate($event)"
(delete)="facade.deleteOne($event)"
(delete)="deleteOne($event)"
(archive)="facade.archiveOne($event)"
(restore)="facade.restoreOne($event)"
(loadMore)="facade.loadMore()"
@@ -33,14 +38,36 @@ import { LanguageService } from '../../../../services/language.service';
(selectionChange)="facade.toggleSelection($event.id, $event.checked)"
(selectAll)="facade.toggleAll($event)"
(bulkVisibility)="facade.applyBulkVisibility($event)"
(bulkDelete)="facade.applyBulkDelete()"
(bulkDelete)="bulkDelete()"
(bulkDuplicate)="facade.applyBulkDuplicate()"
(bulkAssignCategory)="facade.applyBulkAssignCategory($event)"
(bulkAssignTags)="facade.applyBulkAssignTags($event)"
(bulkExport)="facade.exportSelectedAsCsv()"
(viewModeChange)="facade.setViewMode($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
})
export class AdminProductsListPageComponent {
@@ -50,6 +77,9 @@ export class AdminProductsListPageComponent {
readonly healthFn = (product: Parameters<AdminProductsFacade['health']>[0]) => this.facade.health(product);
readonly pendingDeleteId = signal<string | null>(null);
readonly bulkDeleteConfirmOpen = signal(false);
constructor() {
this.facade.loadCategories();
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']); }
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 {
return this.languageService.currentLanguage();
}

View File

@@ -6,6 +6,8 @@
}
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else {
<div class="report-grid">
<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 { ButtonComponent } from '../../../../shared/ui/button/button.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
@Component({
selector: 'app-admin-reports-page',
standalone: true,
imports: [TranslatePipe, ButtonComponent, SkeletonComponent],
imports: [TranslatePipe, ButtonComponent, SkeletonComponent, EmptyStateComponent],
templateUrl: './admin-reports-page.component.html',
styleUrls: ['./admin-reports-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -11,9 +11,11 @@ export class AdminTransactionsFacade {
readonly transactions = signal<AdminTransaction[]>([]);
readonly total = signal(0);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
loadList(): void {
this.loading.set(true);
this.error.set(null);
this.gateway.loadTransactions(this.filters()).pipe(take(1)).subscribe({
next: result => {
this.transactions.set(result.items);
@@ -24,6 +26,7 @@ export class AdminTransactionsFacade {
this.transactions.set([]);
this.total.set(0);
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" /> }
<span class="sr-only">{{ 'common.loading' | translate }}</span>
</div>
} @else if (facade.error()) {
<app-empty-state [title]="'common.errorTitle' | translate" [description]="'common.errorDescription' | translate" />
} @else if (facade.transactions().length === 0) {
<app-empty-state [title]="'adminTransactions.emptyTitle' | translate" [description]="'adminTransactions.emptyDescription' | translate" />
} @else {

View File

@@ -1,18 +1,24 @@
import { Injectable } from '@angular/core';
import { Injectable, inject } from '@angular/core';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';
import { AdminTransaction, AdminTransactionListFilters, AdminTransactionsListResult } from '../models/admin-transaction.model';
import { AdminTransactionsGateway } from './admin-transactions-gateway.interface';
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'];
@Injectable({ providedIn: 'root' })
export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
private readonly adminAuth = inject(AdminAuthService);
private cache: AdminTransaction[] | null = null;
constructor(private readonly ordersGateway: AdminOrdersLocalGateway) {}
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
loadTransactions(filters: AdminTransactionListFilters): Observable<AdminTransactionsListResult> {
return new Observable<AdminTransactionsListResult>(subscriber => {
this.ensureData().then(() => {
@@ -38,7 +44,7 @@ export class AdminTransactionsLocalGateway implements AdminTransactionsGateway {
...tx,
status: 'retried',
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,
fraudFlag: flagged,
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 { 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';
@Injectable({ providedIn: 'root' })
@@ -8,10 +8,11 @@ export class AdminUsersFacade {
private readonly gateway = inject(AdminUsersLocalGateway);
readonly users = signal<AdminUser[]>([]);
readonly roles = signal<AdminRole[]>([]);
readonly roles = signal<AdminUserRoleRecord[]>([]);
readonly invitations = signal<AdminInvitation[]>([]);
readonly loading = signal(false);
readonly error = signal(false);
readonly mutationError = signal<string | null>(null);
readonly sessionsTarget = signal<AdminUser | null>(null);
readonly sessions = signal<AdminSession[]>([]);
readonly auditTarget = signal<AdminUser | null>(null);
@@ -33,16 +34,28 @@ export class AdminUsersFacade {
}
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 {
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 {
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 {

View File

@@ -2,7 +2,8 @@ export type AdminUserScope = 'marketplace' | 'office';
export type AdminUserStatus = 'active' | 'invited' | 'suspended';
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;
name: string;
permissions: string[];

View File

@@ -120,4 +120,21 @@
<p>{{ entry.timestamp | date:'short' }} — {{ ('adminUsers.actor.' + entry.actor) | translate }} — {{ auditText(entry) }}</p>
}
</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>

View File

@@ -12,11 +12,12 @@ import { TableComponent } from '../../../../shared/ui/table/table.component';
import { DialogComponent } from '../../../../shared/ui/dialog/dialog.component';
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-state.component';
import { ConfirmDialogComponent } from '../../../../shared/ui/confirm-dialog/confirm-dialog.component';
@Component({
selector: 'app-admin-users-page',
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',
styleUrls: ['./admin-users-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@@ -28,6 +29,7 @@ export class AdminUsersPageComponent {
readonly inviteEmail = signal('');
readonly inviteRoleId = signal('viewer');
readonly inviteScope = signal<AdminUserScope>('office');
readonly pendingSuspendUserId = signal<string | null>(null);
constructor() {
this.facade.loadAll();
@@ -40,12 +42,21 @@ export class AdminUsersPageComponent {
toggleStatus(userId: string, current: AdminUserStatus): void {
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;
}
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> = {
'*': 'all',
'products.manage': 'productsManage',

View File

@@ -1,9 +1,9 @@
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 {
loadUsers(): Observable<AdminUser[]>;
loadRoles(): Observable<AdminRole[]>;
loadRoles(): Observable<AdminUserRoleRecord[]>;
loadInvitations(): Observable<AdminInvitation[]>;
loadSessions(userId: string): Observable<AdminSession[]>;
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 { 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 { 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: '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: 'viewer', name: 'Viewer', permissions: ['products.view', 'orders.view'], builtIn: true },
];
@Injectable({ providedIn: 'root' })
export class AdminUsersLocalGateway implements AdminUsersGateway {
private readonly adminAuth = inject(AdminAuthService);
private users: AdminUser[] | null = null;
private roles: AdminRole[] = [...BUILT_IN_ROLES];
private roles: AdminUserRoleRecord[] = [...BUILT_IN_ROLES];
private invitations: AdminInvitation[] = [];
private sessions: Record<string, AdminSession[]> = {};
private audit: Record<string, AdminUserAuditEntry[]> = {};
@@ -23,7 +25,7 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
return of(this.ensureUsers()).pipe(delay(50));
}
loadRoles(): Observable<AdminRole[]> {
loadRoles(): Observable<AdminUserRoleRecord[]> {
return of(this.roles).pipe(delay(50));
}
@@ -90,12 +92,16 @@ export class AdminUsersLocalGateway implements AdminUsersGateway {
this.audit[userId] = [
...(this.audit[userId] ?? []),
roleId
? { eventKey: 'roleChanged', roleId, actor: 'admin', timestamp: new Date().toISOString() }
: { eventKey: 'statusChanged', status, actor: 'admin', timestamp: new Date().toISOString() },
? { eventKey: 'roleChanged', roleId, actor: this.currentActor, timestamp: new Date().toISOString() }
: { eventKey: 'statusChanged', status, actor: this.currentActor, timestamp: new Date().toISOString() },
];
return of(updated).pipe(delay(50));
}
private get currentActor(): string {
return this.adminAuth.displayName() ?? 'admin';
}
private ensureUsers(): AdminUser[] {
if (!this.users) {
this.users = [

View File

@@ -55,40 +55,47 @@ export class SearchFacade {
readonly state = this.store.state;
readonly popularSearches: SearchSuggestion[] = [
{
id: 'popular-smartphones',
type: 'collection',
title: 'Smartphones',
text: 'Smartphones',
icon: 'trendingUp',
target: { route: '/search', query: { q: 'Smartphones' } }
},
{
id: 'popular-sneakers',
type: 'collection',
title: 'Sneakers',
text: 'Sneakers',
icon: 'trendingUp',
target: { route: '/search', query: { q: 'Sneakers' } }
},
{
id: 'popular-headphones',
type: 'collection',
title: 'Headphones',
text: 'Headphones',
icon: 'trendingUp',
target: { route: '/search', query: { q: 'Headphones' } }
},
{
id: 'popular-laptops',
type: 'collection',
title: 'Laptops',
text: 'Laptops',
icon: 'trendingUp',
target: { route: '/search', query: { q: 'Laptops' } }
},
];
/**
* Search query text stays the stable English canonical term (what the
* backend search index matches against); only the displayed title/text
* are translated.
*/
get popularSearches(): SearchSuggestion[] {
return [
{
id: 'popular-smartphones',
type: 'collection',
title: this.translate.t('search.popularSmartphones'),
text: this.translate.t('search.popularSmartphones'),
icon: 'trendingUp',
target: { route: '/search', query: { q: 'Smartphones' } }
},
{
id: 'popular-sneakers',
type: 'collection',
title: this.translate.t('search.popularSneakers'),
text: this.translate.t('search.popularSneakers'),
icon: 'trendingUp',
target: { route: '/search', query: { q: 'Sneakers' } }
},
{
id: 'popular-headphones',
type: 'collection',
title: this.translate.t('search.popularHeadphones'),
text: this.translate.t('search.popularHeadphones'),
icon: 'trendingUp',
target: { route: '/search', query: { q: 'Headphones' } }
},
{
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() {
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 { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
const RANGE_DEBOUNCE_MS = 350;
@Component({
selector: 'app-catalog-filters-panel',
standalone: true,
@@ -11,12 +13,20 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
styleUrls: ['./filters-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CatalogFiltersPanelComponent {
export class CatalogFiltersPanelComponent implements OnDestroy {
@Input() definitions: FilterGroup[] = [];
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
@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>>({});
toggleGroup(filterId: string): void {
@@ -79,34 +89,51 @@ export class CatalogFiltersPanelComponent {
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
const value = rawValue.trim().length ? Number(rawValue) : undefined;
const current = this.state.ranges[filterId] ?? {};
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...current,
[key]: Number.isFinite(value as number) ? value : undefined
this.debounce(`range:${filterId}:${key}`, () => {
const current = this.state.ranges[filterId] ?? {};
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...current,
[key]: Number.isFinite(value as number) ? value : undefined
}
}
}
});
});
}
updateSlider(filterId: string, value: string): void {
const numeric = Number(value);
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...this.state.ranges[filterId],
max: Number.isFinite(numeric) ? numeric : undefined,
this.debounce(`slider:${filterId}`, () => {
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[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 {
this.stateChange.emit({
...this.state,

View File

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

View File

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

View File

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

View File

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

View File

@@ -229,6 +229,7 @@
(productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)"
(quickView)="openQuickView($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(compareToggled)="onCompareToggled($event)"
(shareRequested)="onShareRequested($event)" />
@@ -295,3 +296,9 @@
}
}
</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 { FeatureConfigService } from '../../../../core/config/feature-config.service';
import { CategoryFacade } from '../../../../facades/platform/category.facade';
import { ProductFacade } from '../../../../facades/platform/product.facade';
import { SearchFacade } from '../../../../facades/platform/search.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
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 { ButtonComponent } from '../../../../shared/ui/button/button.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 { ProductShareService } from '../../user-experience/services/product-share.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
@@ -56,7 +58,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
EmptyStateComponent,
SkeletonComponent,
ButtonComponent,
IconComponent
IconComponent,
QuickViewDialogComponent
],
templateUrl: './catalog-container.component.html',
styleUrls: ['./catalog-container.component.scss'],
@@ -68,6 +71,7 @@ export class CatalogContainerComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService);
private readonly categoryFacade = inject(CategoryFacade);
private readonly productFacade = inject(ProductFacade);
private readonly searchFacade = inject(SearchFacade);
private readonly featureConfig = inject(FeatureConfigService);
private readonly cartService = inject(CartService);
@@ -287,6 +291,31 @@ export class CatalogContainerComponent {
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 {
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 { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
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 { ProductActionsComponent } from '../components/product-actions/product-actions.component';
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
@@ -70,6 +77,11 @@ export class ProductDetailsContainerComponent {
private readonly languageService = inject(LanguageService);
private readonly translate = inject(TranslateService);
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 userExperienceConfig = signal(this.resolveUserExperienceConfig());
@@ -229,6 +241,8 @@ export class ProductDetailsContainerComponent {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(params => this.loadProduct(Number(params.get('id'))));
this.destroyRef.onDestroy(() => this.seoService.resetToDefaults());
}
loadProduct(productId: number): void {
@@ -261,6 +275,7 @@ export class ProductDetailsContainerComponent {
}
this.product.set(product);
this.seoService.setItemMeta(product);
if (this.userExperienceConfig().recentlyViewed.enabled) {
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
}
@@ -303,10 +318,10 @@ export class ProductDetailsContainerComponent {
}
}
addToCart(): void {
addToCart(): Promise<void> {
const current = this.product();
if (!current) return;
this.cartService.addItem(current.itemID, 1, {
if (!current) return Promise.resolve();
return this.cartService.addItem(current.itemID, 1, {
colour: this.selectedColour() ?? undefined,
size: this.selectedSize() ?? undefined,
price: this.effectivePrice(),
@@ -314,9 +329,9 @@ export class ProductDetailsContainerComponent {
});
}
buyNow(): void {
this.addToCart();
this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
async buyNow(): Promise<void> {
await this.addToCart();
void this.router.navigate([`/${this.languageService.currentLanguage()}/cart`]);
}
toggleWishlist(): void {
@@ -353,8 +368,36 @@ export class ProductDetailsContainerComponent {
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 {
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 {

View File

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

View File

@@ -6,7 +6,7 @@
<th scope="col">{{ 'ux.compareAttribute' | translate }}</th>
@for (product of products; track product.itemID) {
<th scope="col">
<div class="compare-product-title">{{ product.name }}</div>
<div class="compare-product-title">{{ productTitle(product) }}</div>
</th>
}
</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 { TranslateService } from '../../../../../i18n/translate.service';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { LanguageService } from '../../../../../services/language.service';
import { getTranslatedField } from '../../../../../utils/item.utils';
interface CompareRow {
key: string;
@@ -27,6 +29,7 @@ const STOCK_LABEL_KEYS: Record<string, string> = {
})
export class CompareTableComponent {
private readonly i18n = inject(TranslateService);
private readonly languageService = inject(LanguageService);
@Input() products: Product[] = [];
@Input() hideIdentical = false;
@@ -61,6 +64,10 @@ export class CompareTableComponent {
return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows;
});
productTitle(product: Product): string {
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
}
isDifferentRow(row: CompareRow): boolean {
return this.highlightDifferences && !row.identical;
}

View File

@@ -26,7 +26,7 @@
<section class="compare-products-list">
@for (product of products(); track product.itemID) {
<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>
</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 { TranslatePipe } from '../../../../../i18n/translate.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 { CompareTableComponent } from '../components/compare-table.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 {
private readonly uxFacade = inject(UserExperienceFacade);
private readonly configService = inject(ConfigService);
private readonly languageService = inject(LanguageService);
private readonly compareConfig = this.resolveCompareConfig();
@@ -39,6 +42,10 @@ export class ComparePageComponent {
this.uxFacade.clearCompare();
}
productTitle(product: Product): string {
return getTranslatedField(product, 'name', this.languageService.currentLanguage());
}
private resolveCompareConfig() {
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {};
return {

View File

@@ -123,6 +123,10 @@ export const en: Translations = {
emailNeedsAt: 'Email must contain @',
emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)',
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',
loginRequiredDesc: 'Please log in via Telegram to place your order',
loginWithTelegram: 'Log in with Telegram',
@@ -140,6 +144,10 @@ export const en: Translations = {
noResultsHint: 'Try changing your query or using different keywords',
emptyResultsAria: 'Empty search results',
popularCategories: 'Popular categories',
popularSmartphones: 'Smartphones',
popularSneakers: 'Sneakers',
popularHeadphones: 'Headphones',
popularLaptops: 'Laptops',
recommendedProducts: 'Recommended products',
aiSuggestionHint: 'AI suggestion (future)',
suggestionType: {
@@ -269,6 +277,7 @@ export const en: Translations = {
compare: 'Compare',
share: 'Share',
quickView: 'Quick view',
quickViewDetails: 'View full details',
stockHigh: 'In stock',
stockMedium: 'Limited stock',
stockLow: 'Almost gone',
@@ -317,6 +326,7 @@ export const en: Translations = {
compare: 'Compare',
share: 'Share',
notifyMe: 'Notify me',
notifyMeConfirmed: 'We\'ll let you know when this is back in stock.',
zoom: 'Zoom',
fullscreen: 'Fullscreen',
pdfDocument: 'Product PDF document',
@@ -1119,6 +1129,8 @@ export const en: Translations = {
nextProducts: 'Next products',
previousSlide: 'Previous slide',
nextSlide: 'Next slide',
pauseAutoplay: 'Pause slideshow',
resumeAutoplay: 'Resume slideshow',
closeDialog: 'Close dialog',
dismiss: 'Dismiss',
qrCode: 'QR Code',
@@ -1299,7 +1311,8 @@ export const en: Translations = {
notAvailable: 'Not available yet',
changeStatus: 'Change status',
hideSelected: 'Hide',
archiveSelected: 'Archive',
deleteSelected: 'Delete',
confirmBulkDelete: 'Delete the selected reviews? This cannot be undone.',
reportsQueue: 'Reports queue',
reportsColumn: 'Reports',
reportTarget: 'Reported item',
@@ -1357,6 +1370,8 @@ export const en: Translations = {
},
},
adminOrders: {
bulkDeleteTitle: 'Delete orders',
confirmBulkDelete: 'Delete the selected orders? This cannot be undone.',
back: 'Back to orders',
search: 'Search by order number, name, email, or phone…',
export: 'Export',
@@ -1533,6 +1548,7 @@ export const en: Translations = {
title: 'Title',
slug: 'URL slug',
slugTaken: 'This slug is already used by another category.',
slugCheckError: 'Could not verify this slug is unique. Try again before saving.',
parent: 'Parent category',
noParent: 'No parent (top level)',
icon: 'Icon',
@@ -1545,6 +1561,8 @@ export const en: Translations = {
saveDraft: 'Save draft',
publish: 'Publish',
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?',
deleteBlocked: 'This category can\'t be deleted while it has subcategories or assigned products.',
emptyTitle: 'No categories yet',
@@ -1611,6 +1629,9 @@ export const en: Translations = {
},
adminProducts: {
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',
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.',

View File

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

View File

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

View File

@@ -1,14 +1,42 @@
import { Pipe, PipeTransform, inject } from '@angular/core';
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({
name: 'translate',
pure: false,
})
export class TranslatePipe implements PipeTransform {
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 {
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;
emailNeedsDomain: string;
emailInvalid: string;
telegramIdMissing: string;
paymentDescriptionFallback: string;
emailPlaceholder: string;
phonePlaceholder: string;
loginRequired: string;
loginRequiredDesc: string;
loginWithTelegram: string;
@@ -138,6 +142,10 @@ export interface Translations {
noResultsHint: string;
emptyResultsAria: string;
popularCategories: string;
popularSmartphones: string;
popularSneakers: string;
popularHeadphones: string;
popularLaptops: string;
recommendedProducts: string;
aiSuggestionHint: string;
suggestionType: {
@@ -267,6 +275,7 @@ export interface Translations {
compare: string;
share: string;
quickView: string;
quickViewDetails: string;
stockHigh: string;
stockMedium: string;
stockLow: string;
@@ -315,6 +324,7 @@ export interface Translations {
compare: string;
share: string;
notifyMe: string;
notifyMeConfirmed: string;
zoom: string;
fullscreen: string;
pdfDocument: string;
@@ -1118,6 +1128,8 @@ export interface Translations {
nextProducts: string;
previousSlide: string;
nextSlide: string;
pauseAutoplay: string;
resumeAutoplay: string;
closeDialog: string;
dismiss: string;
qrCode: string;
@@ -1298,7 +1310,8 @@ export interface Translations {
notAvailable: string;
changeStatus: string;
hideSelected: string;
archiveSelected: string;
deleteSelected: string;
confirmBulkDelete: string;
reportsQueue: string;
reportsColumn: string;
reportTarget: string;
@@ -1356,6 +1369,8 @@ export interface Translations {
};
};
adminOrders: {
bulkDeleteTitle: string;
confirmBulkDelete: string;
back: string;
search: string;
export: string;
@@ -1541,6 +1556,7 @@ export interface Translations {
title: string;
slug: string;
slugTaken: string;
slugCheckError: string;
parent: string;
noParent: string;
icon: string;
@@ -1553,6 +1569,8 @@ export interface Translations {
saveDraft: string;
publish: string;
confirmDelete: string;
confirmBulkDelete: string;
deleteTitle: string;
confirmLeaveUnsaved: string;
deleteBlocked: string;
emptyTitle: string;
@@ -1619,6 +1637,9 @@ export interface Translations {
};
adminProducts: {
confirmLeaveUnsaved: string;
deleteTitle: string;
confirmDelete: string;
confirmBulkDelete: string;
emptyTitle: string;
emptyDescription: string;
emptyGuide: string;

View File

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

View File

@@ -98,7 +98,13 @@
</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" />
</button>
</div>
@@ -271,6 +277,44 @@
<div class="payment-status-screen success" role="status" aria-live="polite">
<div class="success-icon" aria-hidden="true"></div>
<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>
}

View File

@@ -722,6 +722,57 @@
color: white;
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 {

View File

@@ -71,6 +71,7 @@ export class CartComponent implements OnDestroy {
emailError = signal<string>('');
phoneError = signal<string>('');
emailSubmitting = signal<boolean>(false);
purchaseSubmitted = signal<boolean>(false);
paidItems: CartItem[] = [];
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.phoneError.set('');
this.emailSubmitting.set(false);
this.purchaseSubmitted.set(false);
this.paidItems = [...this.items()];
this.createPayment(paymentMethod);
}
@@ -254,7 +256,7 @@ export class CartComponent implements OnDestroy {
const orderId = this.generateOrderId();
const paymentPayload = {
amount: Number(this.totalWithDelivery()),
currency: 'RUB' as const,
currency: this.langService.currentCurrency(),
siteuserID: this.getPaymentUserId(),
siteorderID: orderId,
redirectUrl: '',
@@ -424,7 +426,6 @@ export class CartComponent implements OnDestroy {
productId: String(item.itemID),
name: item.name,
quantity: item.quantity,
price: item.discount > 0 ? item.price * (1 - item.discount / 100) : item.price,
})),
customer: {
name: this.getTelegramUsername() || this.i18n.t('common.guest'),
@@ -433,36 +434,46 @@ export class CartComponent implements OnDestroy {
},
payment: {
method: this.selectedPaymentMethod(),
currency: 'RUB',
currency: this.langService.currentCurrency(),
},
}).subscribe({
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 {
setTimeout(() => {
const lang = this.langService.currentLanguage();
this.router.navigate([`/${lang}`]);}, 0);
const telegramUserId = this.getTelegramUserId();
// Telegram ID is mandatory
if (!telegramUserId) {
console.error('Cannot submit purchase: Telegram ID is required');
this.emailSubmitting.set(false);
if (this.purchaseSubmitted()) {
return;
}
const telegramUserId = this.getTelegramUserId();
// Telegram ID is mandatory for submitPurchaseEmail.
if (!telegramUserId) {
this.notifications.show(this.i18n.t('cart.telegramIdMissing'), 'warning');
this.emailSubmitting.set(false);
this.closePaymentPopup();
const lang = this.langService.currentLanguage();
this.router.navigate([`/${lang}`]);
return;
}
this.emailSubmitting.set(true);
const emailData = {
email: '',
phone: '',
email: this.userEmail().trim(),
phone: this.userPhone().replace(/\D/g, ''),
telegramUserId: telegramUserId,
items: this.paidItems.map((item: CartItem) => ({
itemID: item.itemID,
name: item.name,
price: item.discount > 0
price: item.discount > 0
? item.price * (1 - item.discount / 100)
: item.price,
currency: item.currency,
@@ -470,9 +481,10 @@ export class CartComponent implements OnDestroy {
...(item.selectedDelivery ? { delivery: [item.selectedDelivery] } : {})
}))
};
this.apiService.submitPurchaseEmail(emailData).subscribe({
next: () => {
this.purchaseSubmitted.set(true);
this.emailSubmitting.set(false);
this.closePaymentPopup();
const lang = this.langService.currentLanguage();
@@ -488,8 +500,6 @@ export class CartComponent implements OnDestroy {
}
});
this.paymentStatus.set(null);
}
copyPaymentLink(): void {
@@ -542,6 +552,11 @@ export class CartComponent implements OnDestroy {
this.apiService.submitPurchaseEmail(emailData).subscribe({
next: () => {
this.purchaseSubmitted.set(true);
if (this.closeTimeout) {
clearTimeout(this.closeTimeout);
this.closeTimeout = undefined;
}
this.emailSubmitting.set(false);
this.notifications.show(this.i18n.t('cart.emailSuccess'), 'success');
// Close popup and redirect to home page
@@ -600,7 +615,7 @@ export class CartComponent implements OnDestroy {
return hostname;
}
return 'Покупка на Маркетплейсе';
return this.i18n.t('cart.paymentDescriptionFallback');
}
private generateOrderId(): string {

View File

@@ -6,6 +6,14 @@
<app-skeleton shape="text" height="16px" />
<app-skeleton shape="text" width="70%" height="16px" />
</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()) {
<section class="static-page__state">
<app-empty-state title="404" [description]="'staticPages.notFound' | translate">

View File

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

View File

@@ -42,7 +42,7 @@ export interface QrCreateResponse {
export interface CartPaymentRequest {
amount: number;
currency: 'RUB';
currency: string;
siteuserID: string;
siteorderID: string;
redirectUrl: string;
@@ -54,7 +54,12 @@ export interface CartPaymentRequest {
}
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 };
payment?: { method: string; currency: 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);
}
/**
* 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[]> {
let params = new HttpParams().set('count', count.toString());
if (categoryID) {

View File

@@ -203,20 +203,30 @@ export class CartService {
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
if (this.addingItems.has(itemID)) return;
if (this.addingItems.has(itemID)) return Promise.resolve();
const currentItems = this.cartItems();
const existingItem = currentItems.find(i => this.isSameCartLine(i, itemID, variant));
if (existingItem) {
// Item exists, increase quantity
this.updateQuantity(itemID, existingItem.quantity + quantity, variant);
} else {
// Get item details from API and add to cart
this.addingItems.add(itemID);
import('./api.service').then(({ ApiService }) => {
return Promise.resolve();
}
// 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({
next: (item) => {
const cartItem = this.normalizeCartItem({
@@ -229,17 +239,19 @@ export class CartService {
});
this.cartItems.set([...this.cartItems(), cartItem]);
this.addingItems.delete(itemID);
resolve();
},
error: (err) => {
console.error('Error adding to cart:', err);
this.addingItems.delete(itemID);
resolve();
}
});
}).catch((err) => {
console.error('Error loading API service:', err);
this.addingItems.delete(itemID);
});
}
})
).catch((err) => {
console.error('Error loading API service:', err);
this.addingItems.delete(itemID);
});
}
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 { UiRuntimeFacade } from '../facades/runtime/ui-runtime.facade';
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({
providedIn: 'root'
@@ -14,6 +21,7 @@ export class SeoService {
private doc = inject(DOCUMENT);
private readonly uiRuntime = inject(UiRuntimeFacade);
private readonly configService = inject(ConfigService);
private readonly languageService = inject(LanguageService);
constructor() {
// Keep the runtime <title>/OG/Twitter/canonical/robots tags in sync with
@@ -38,6 +46,10 @@ export class SeoService {
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.
*/
@@ -59,7 +71,7 @@ export class SeoService {
{ property: 'og:image', content: imageUrl },
{ property: 'og:url', content: itemUrl },
{ property: 'og:site_name', content: this.siteName },
{ property: 'og:locale', content: 'ru_RU' },
{ property: 'og:locale', content: this.ogLocale },
// Product-specific OG tags
{ property: 'product:price:amount', content: price.toFixed(2) },
@@ -74,6 +86,22 @@ export class SeoService {
// Standard meta
{ 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:url', content: this.siteUrl },
{ 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:title', content: defaultTitle },
@@ -127,6 +155,30 @@ export class SeoService {
// Remove product-specific tags
this.meta.removeTag("property='product:price:amount'");
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 {

View File

@@ -2,7 +2,21 @@ import { ThemeConfig } from '../../shared/models/config';
import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
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
? theme.spacing.scale
: [0.25, 0.5, 1, 1.5, 2];
@@ -49,5 +63,5 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab
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 configService = inject(ConfigService);
private systemDarkQuery?: MediaQueryList;
private systemDarkListener?: (event: MediaQueryListEvent) => void;
initialize(): void {
this.configService.loadBootstrap().pipe(take(1)).subscribe({
next: (bootstrap) => this.applyTheme(bootstrap.theme),
@@ -20,7 +23,20 @@ export class ThemeEngineService {
}
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;
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-mode', theme.mode);
root.setAttribute('data-theme-mode', effectiveMode);
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 { Observable, of, map, switchMap } from 'rxjs';
import { Observable, of, map, switchMap, catchError } from 'rxjs';
import { CategoryFacade } from '../../facades/platform/category.facade';
import { ProductFacade } from '../../facades/platform/product.facade';
import { LanguageService } from '../../services/language.service';
@@ -19,7 +19,11 @@ export class DataSourceResolverService {
resolve(widget: WidgetConfig, section: SectionConfig): Observable<unknown> {
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)"
></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>
}
}
@@ -171,6 +180,23 @@ const SWIPE_THRESHOLD_PX = 50;
&: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 {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
@@ -199,6 +225,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
@Output() ctaClicked = new EventEmitter<void>();
readonly activeIndex = signal(0);
readonly isPaused = signal(false);
private readonly dataSignal = signal<HeroWidgetData | null>(null);
private autoplayHandle: ReturnType<typeof setInterval> | null = null;
private swipeStartX: number | null = null;
@@ -237,6 +264,7 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
if (changes['data']) {
this.dataSignal.set(this.data);
this.activeIndex.set(0);
this.isPaused.set(false);
this.setupAutoplay();
}
}
@@ -289,10 +317,20 @@ export class HeroWidgetComponent implements OnChanges, OnDestroy {
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 {
this.clearAutoplay();
const slides = this.allSlides();
if (!this.data?.autoplay || slides.length <= 1) {
if (!this.data?.autoplay || slides.length <= 1 || this.isPaused() || this.prefersReducedMotion()) {
return;
}
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>",
"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": [