Add Pay again for unpaid orders and redesign admin modals.
continuous-integration/drone/push Build is passing

Unpaid PayU confirmations can restart payment; initiate reuses the payment row. Admin modals now use the shared K-design shell.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Rafał MiczekandCursor committed 2026-10-19 19:56:17 +02:00
1 parent 182557493a
commit 2786aba3ef
18 files changed
+876 -379

No files matched your search

@@ -223,6 +223,10 @@ export const CS_TRANSLATIONS: TranslationMap = {
'orders.prev': 'Předchozí',
'orders.next': 'Další',
'orders.detail': 'Detail objednávky',
'orders.lines': 'Položky',
'orders.customerShipping': 'Zákazník a doručení',
'orders.guestCustomer': 'Host',
'orders.updateStatus': 'Uložit stav',
'orders.shipping': 'Doručení',
'orders.shipment': 'Zásilka',
'orders.tracking': 'Sledovací číslo',
@@ -223,6 +223,10 @@ export const DE_TRANSLATIONS: TranslationMap = {
'orders.prev': 'Zurück',
'orders.next': 'Weiter',
'orders.detail': 'Bestelldetails',
'orders.lines': 'Positionen',
'orders.customerShipping': 'Kunde & Versand',
'orders.guestCustomer': 'Gastkunde',
'orders.updateStatus': 'Status aktualisieren',
'orders.shipping': 'Versand',
'orders.shipment': 'Sendung',
'orders.tracking': 'Sendungsnummer',
@@ -221,6 +221,10 @@ export const EN_TRANSLATIONS: TranslationMap = {
'orders.prev': 'Previous',
'orders.next': 'Next',
'orders.detail': 'Order details',
'orders.lines': 'Line items',
'orders.customerShipping': 'Customer & shipping',
'orders.guestCustomer': 'Guest customer',
'orders.updateStatus': 'Update status',
'orders.shipping': 'Shipping',
'orders.shipment': 'Shipment',
'orders.tracking': 'Tracking number',
@@ -221,6 +221,10 @@ export const PL_TRANSLATIONS: TranslationMap = {
'orders.prev': 'Poprzednia',
'orders.next': 'Następna',
'orders.detail': 'Szczegóły zamówienia',
'orders.lines': 'Pozycje',
'orders.customerShipping': 'Klient i dostawa',
'orders.guestCustomer': 'Gość',
'orders.updateStatus': 'Zapisz status',
'orders.shipping': 'Dostawa',
'orders.shipment': 'Przesyłka',
'orders.tracking': 'Numer śledzenia',
@@ -1,115 +1,72 @@
@if (open()) {
<div
class="admin-modal fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4"
class="admin-modal"
role="dialog"
aria-modal="true"
(click)="handleBackdrop($event)"
>
<div
class="admin-modal__panel my-4 w-full max-w-2xl bg-white shadow-lg"
class="admin-modal__panel admin-modal__panel--xl"
(click)="$event.stopPropagation()"
>
<div class="flex items-center justify-between border-b border-iled-grey-light px-6 py-4">
<div>
<h2 class="text-lg font-bold">
{{ 'orders.detail' | translate }}
<div class="admin-modal__head">
<div style="min-width: 0">
<h1>
@if (detail(); as d) {
<span class="font-normal text-iled-grey-dark">#{{ d.orderNumber }}</span>
#{{ d.orderNumber }}
} @else {
{{ 'orders.detail' | translate }}
}
</h2>
</h1>
@if (detail(); as d) {
<div style="margin-top: 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
<p class="page-purpose" style="margin: 0">
{{ d.createdAt | date: 'yyyy-MM-dd HH:mm' }}
@if (d.paymentProvider) {
· {{ d.paymentProvider }}
}
</p>
<div class="admin-modal__meta">
<span class="badge" [class]="statusBadgeClass(d.status)">
{{ 'orders.status.' + d.status | translate }}
</span>
@if (d.paymentStatus) {
<span class="badge" [class]="paymentBadgeClass(d.paymentStatus)">
{{ 'orders.paymentStatus.' + d.paymentStatus | translate }}
</span>
}
<app-order-channel-icons
[source]="d.source"
[shippingCarrier]="d.shippingCarrier"
[shippingServiceType]="d.shippingServiceType"
[showLabels]="true"
[iconSize]="15"
[iconSize]="14"
/>
@if (d.externalOrderId) {
<span class="cell-muted text-xs font-mono">
<span class="cell-muted" style="font-family: ui-monospace, monospace; font-size: 12px">
{{ d.externalOrderId }}
</span>
}
</div>
}
</div>
<button
type="button"
class="text-iled-grey-dark hover:text-iled-black"
(click)="handleClose()"
>
✕
</button>
</div>
@if (loading()) {
<div class="px-6 py-6">
<app-admin-skeleton variant="detail" [rows]="4" />
</div>
} @else if (detail(); as d) {
<div class="max-h-[70vh] space-y-5 overflow-y-auto px-6 py-4 text-sm">
<div class="grid gap-4 sm:grid-cols-2">
<div>
<h3 class="mb-2 font-semibold">{{ 'orders.customer' | translate }}</h3>
<p>{{ d.customerFirstName }} {{ d.customerLastName }}</p>
<p class="text-iled-grey-dark">{{ d.customerEmail }}</p>
<p class="text-iled-grey-dark">{{ d.customerPhone }}</p>
</div>
<div>
<h3 class="mb-2 font-semibold">{{ 'orders.shipping' | translate }}</h3>
<p class="font-medium">
{{ 'orders.carrier.' + d.shippingCarrier | translate }}
@if (d.shippingServiceType === 'parcelLocker') {
({{ 'orders.service.parcelLocker' | translate }})
}
</p>
@if (d.parcelPointName) {
<p>{{ d.parcelPointName }}</p>
<p class="text-iled-grey-dark">{{ d.parcelPointAddress }}</p>
} @else {
<p>{{ d.shippingStreet }}</p>
<p>{{ d.shippingPostalCode }} {{ d.shippingCity }}</p>
<p>{{ d.shippingCountry }}</p>
}
</div>
</div>
<div class="rounded border border-iled-grey-light bg-iled-bg p-4">
<h3 class="mb-3 font-semibold">{{ 'orders.shipment' | translate }}</h3>
@if (d.shipment; as s) {
<div class="space-y-1 text-sm">
<p>
<span class="text-iled-grey-dark">{{ 'orders.tracking' | translate }}:</span>
<span class="ml-1 font-mono font-medium">{{ s.trackingNumber || '—' }}</span>
</p>
<p>
<span class="text-iled-grey-dark">{{ 'orders.status' | translate }}:</span>
<span class="ml-1">{{ 'orders.shipmentStatus.' + s.status | translate }}</span>
</p>
@if (s.errorMessage) {
<p class="text-sm" style="color: var(--danger)">{{ s.errorMessage }}</p>
}
@if (s.labelRelativePath) {
<a
[href]="labelUrl(s)"
target="_blank"
rel="noopener noreferrer"
class="mt-2 inline-flex btn btn-secondary btn-sm"
>
{{ 'orders.downloadLabel' | translate }}
</a>
} @else if (canGenerateLabel(d)) {
<button
type="button"
class="btn btn-primary btn-sm mt-16"
[disabled]="generatingLabel()"
(click)="handleGenerateLabel()"
>
{{ 'orders.generateLabel' | translate }}
</button>
}
</div>
<div class="page-actions">
@if (detail(); as d) {
<button
type="button"
class="btn btn-secondary"
(click)="handleInvoice()"
>
{{ 'orders.invoice' | translate }}
</button>
@if (d.shipment?.labelRelativePath) {
<a
[href]="labelUrl(d.shipment!)"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary"
>
{{ 'orders.downloadLabel' | translate }}
</a>
} @else if (canGenerateLabel(d)) {
<button
type="button"
@@ -119,172 +76,324 @@
>
{{ 'orders.generateLabel' | translate }}
</button>
} @else {
<p class="text-sm text-iled-grey-dark">{{ 'orders.labelNotAvailable' | translate }}</p>
}
</div>
}
<button
type="button"
class="admin-modal__close"
(click)="handleClose()"
[attr.aria-label]="'common.close' | translate"
>
✕
</button>
</div>
</div>
<div class="flex flex-wrap gap-4">
<div>
<span class="text-iled-grey-dark">{{ 'orders.status' | translate }}:</span>
<span class="ml-1 font-medium">{{ 'orders.status.' + d.status | translate }}</span>
</div>
<div>
<span class="text-iled-grey-dark">{{ 'orders.payment' | translate }}:</span>
@if (d.paymentStatus) {
<span class="ml-1 font-medium">
{{ 'orders.paymentStatus.' + d.paymentStatus | translate }}
</span>
} @else {
<span class="ml-1">—</span>
}
</div>
@if (d.paymentProvider) {
<div>
<span class="text-iled-grey-dark">{{ 'orders.provider' | translate }}:</span>
<span class="ml-1">{{ d.paymentProvider }}</span>
@if (loading()) {
<div class="admin-modal__body">
<app-admin-skeleton variant="detail" [rows]="5" />
</div>
} @else if (detail(); as d) {
<div class="admin-modal__body">
<div class="split">
<div class="stack" style="gap: 16px">
<div class="card">
<div class="card-head">
<h2>{{ 'orders.lines' | translate }}</h2>
</div>
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>{{ 'products.name' | translate }}</th>
<th>{{ 'orders.qty' | translate }}</th>
<th>{{ 'products.price' | translate }}</th>
@if (canPick()) {
<th>{{ 'orders.pickFrom' | translate }}</th>
<th></th>
}
</tr>
</thead>
<tbody>
@for (line of d.lines; track line.id) {
<tr [class.is-collected]="line.isCollected">
<td>
<div class="row-product">
<div>
<div class="cell-strong">{{ line.productName }}</div>
<div class="cell-muted">
{{ line.sku }}
@if (line.variantColor) {
· {{ line.variantColor }}
}
</div>
</div>
</div>
</td>
<td>{{ line.quantity }}</td>
<td class="cell-strong">
{{ line.lineTotal | i18nCurrency }}
</td>
@if (canPick()) {
<td>
@if (!line.isCollected) {
<select
class="select"
style="height: 32px; min-width: 140px"
[value]="getWarehouse(line.id)"
(change)="
setWarehouse(
line.id,
$any($event.target).value
)
"
>
<option value="">
{{ 'orders.selectWarehouse' | translate }}
</option>
@for (
ws of line.warehouseStock;
track ws.warehouseId
) {
<option [value]="ws.warehouseId">
{{ ws.warehouseName }} ({{ ws.quantity }})
</option>
}
</select>
} @else if (line.pickedFromWarehouseName) {
<span class="cell-muted">
{{ line.pickedFromWarehouseName }}
({{ line.pickedFromWarehouseCode }})
</span>
}
</td>
<td>
@if (line.isCollected) {
<span class="badge badge-success">
{{ 'orders.collected' | translate }}
</span>
} @else {
<button
type="button"
class="btn btn-secondary btn-sm"
[disabled]="!canCollectLine(line) || collecting()"
(click)="handleCollectLine(line)"
>
{{ 'orders.markCollected' | translate }}
</button>
}
</td>
}
</tr>
}
</tbody>
</table>
</div>
<div class="totals">
<div class="totals__row">
<span>{{ 'orders.subtotal' | translate }}</span>
<span>{{ d.subtotal | i18nCurrency }}</span>
</div>
<div class="totals__row">
<span>{{ 'orders.delivery' | translate }}</span>
<span>{{ d.deliveryCost | i18nCurrency }}</span>
</div>
@if (d.discountAmount > 0) {
<div class="totals__row">
<span>{{ 'orders.discount' | translate }}</span>
<span>-{{ d.discountAmount | i18nCurrency }}</span>
</div>
}
<div class="totals__row">
<span>{{ 'orders.vat' | translate }}</span>
<span>{{ d.vatAmount | i18nCurrency }}</span>
</div>
<div class="totals__row totals__row--total">
<span>{{ 'orders.total' | translate }}</span>
<span>{{ d.total | i18nCurrency }}</span>
</div>
</div>
</div>
}
</div>
<div class="flex flex-wrap items-end gap-3">
<div>
<label class="mb-1 block text-xs font-medium text-iled-grey-dark">
{{ 'orders.changeStatus' | translate }}
</label>
<select
[(ngModel)]="selectedStatus"
name="orderStatus"
class="border border-iled-grey-light px-3 py-2 text-sm"
>
@for (s of statuses; track s) {
<option [ngValue]="s">{{ 'orders.status.' + s | translate }}</option>
}
</select>
</div>
<button
type="button"
class="bg-iled-black px-4 py-2 text-sm font-semibold text-white"
(click)="handleSaveStatus()"
>
{{ 'products.save' | translate }}
</button>
<button
type="button"
class="border border-iled-grey-light px-4 py-2 text-sm"
(click)="handleInvoice()"
>
{{ 'orders.invoice' | translate }}
</button>
</div>
<table class="w-full text-left text-sm">
<thead class="border-b border-iled-grey-light bg-iled-bg">
<tr>
<th class="px-3 py-2">{{ 'products.name' | translate }}</th>
<th class="px-3 py-2">SKU</th>
<th class="px-3 py-2">{{ 'orders.qty' | translate }}</th>
<th class="px-3 py-2">{{ 'products.price' | translate }}</th>
@if (canPick()) {
<th class="px-3 py-2">{{ 'orders.pickFrom' | translate }}</th>
<th class="px-3 py-2">{{ 'orders.status' | translate }}</th>
}
</tr>
</thead>
<tbody>
@for (line of d.lines; track line.id) {
<tr class="border-b border-iled-grey-light" [class.bg-green-50]="line.isCollected">
<td class="px-3 py-2">
<div>{{ line.productName }}</div>
<div class="text-xs text-iled-grey-dark">{{ line.variantColor }}</div>
</td>
<td class="px-3 py-2 font-mono text-xs">{{ line.sku }}</td>
<td class="px-3 py-2">{{ line.quantity }}</td>
<td class="px-3 py-2">{{ line.lineTotal | i18nCurrency }}</td>
@if (canPick()) {
<td class="px-3 py-2">
@if (!line.isCollected) {
@if (canPick() && pendingCount(d) > 0) {
<div class="card">
<div class="card-head">
<h2>{{ 'orders.orderWarehouse' | translate }}</h2>
</div>
<div class="card-pad">
<div class="flex items-end gap-8" style="flex-wrap: wrap">
<div class="field" style="flex: 1; min-width: 180px">
<label for="order-wh">
{{ 'orders.orderWarehouse' | translate }}
</label>
<select
class="border border-iled-grey-light px-2 py-1 text-xs"
[value]="getWarehouse(line.id)"
(change)="setWarehouse(line.id, $any($event.target).value)"
id="order-wh"
class="select"
[ngModel]="orderWarehouse"
name="orderWarehouse"
(ngModelChange)="setOrderWarehouse($event)"
>
<option value="">{{ 'orders.selectWarehouse' | translate }}</option>
@for (ws of line.warehouseStock; track ws.warehouseId) {
<option [value]="ws.warehouseId">
{{ ws.warehouseName }} ({{ ws.quantity }})
<option value="">
{{ 'orders.selectWarehouse' | translate }}
</option>
@for (ws of warehouseOptions(d); track ws.warehouseId) {
<option [ngValue]="ws.warehouseId">
{{ ws.warehouseName }} ({{ ws.warehouseCode }})
</option>
}
</select>
} @else if (line.pickedFromWarehouseName) {
<span class="text-xs">
{{ line.pickedFromWarehouseName }}
({{ line.pickedFromWarehouseCode }})
</span>
}
</td>
<td class="px-3 py-2">
@if (line.isCollected) {
<span class="rounded bg-green-100 px-2 py-0.5 text-xs text-green-800">
{{ 'orders.collected' | translate }}
</span>
} @else {
<button
type="button"
class="bg-iled-black px-2 py-1 text-xs text-white disabled:opacity-40"
[disabled]="!canCollectLine(line) || collecting()"
(click)="handleCollectLine(line)"
>
{{ 'orders.markCollected' | translate }}
</button>
}
</td>
}
</tr>
</div>
<button
type="button"
class="btn btn-primary"
[disabled]="!canCollectAll(d) || collecting()"
(click)="handleCollectOrder()"
>
{{ 'orders.collectOrder' | translate }}
</button>
</div>
</div>
</div>
}
</tbody>
</table>
@if (canPick() && pendingCount(d) > 0) {
<div class="flex flex-wrap items-end gap-3 rounded border border-iled-grey-light bg-iled-bg p-4">
<div>
<label class="mb-1 block text-xs font-medium text-iled-grey-dark">
{{ 'orders.orderWarehouse' | translate }}
</label>
<select
class="border border-iled-grey-light px-3 py-2 text-sm"
[ngModel]="orderWarehouse"
name="orderWarehouse"
(ngModelChange)="setOrderWarehouse($event)"
>
<option value="">{{ 'orders.selectWarehouse' | translate }}</option>
@for (ws of warehouseOptions(d); track ws.warehouseId) {
<option [ngValue]="ws.warehouseId">
{{ ws.warehouseName }} ({{ ws.warehouseCode }})
</option>
}
</select>
</div>
<button
type="button"
class="bg-iled-primary px-4 py-2 text-sm font-semibold text-iled-black disabled:opacity-40"
[disabled]="!canCollectAll(d) || collecting()"
(click)="handleCollectOrder()"
>
{{ 'orders.collectOrder' | translate }}
</button>
</div>
}
<div class="space-y-1 text-right">
<div>{{ 'orders.subtotal' | translate }}: {{ d.subtotal | i18nCurrency }}</div>
<div>{{ 'orders.delivery' | translate }}: {{ d.deliveryCost | i18nCurrency }}</div>
@if (d.discountAmount > 0) {
<div>{{ 'orders.discount' | translate }}: -{{ d.discountAmount | i18nCurrency }}</div>
}
<div>{{ 'orders.vat' | translate }}: {{ d.vatAmount | i18nCurrency }}</div>
<div class="text-lg font-bold">
{{ 'orders.total' | translate }}: {{ d.total | i18nCurrency }}
<div class="stack" style="gap: 16px">
<div class="card">
<div class="card-head">
<h2>{{ 'orders.status' | translate }}</h2>
</div>
<div class="card-pad stack" style="gap: 12px">
<div class="field">
<label for="order-status">
{{ 'orders.changeStatus' | translate }}
</label>
<select
id="order-status"
class="select"
[(ngModel)]="selectedStatus"
name="orderStatus"
>
@for (s of statuses; track s) {
<option [ngValue]="s">
{{ 'orders.status.' + s | translate }}
</option>
}
</select>
</div>
<button
type="button"
class="btn btn-primary"
(click)="handleSaveStatus()"
>
{{ 'orders.updateStatus' | translate }}
</button>
</div>
</div>
<div class="card">
<div class="card-head">
<h2>{{ 'orders.customerShipping' | translate }}</h2>
</div>
<div class="section-block">
<div class="cell-strong">
@if (d.customerFirstName || d.customerLastName) {
{{ d.customerFirstName }} {{ d.customerLastName }}
} @else {
{{ 'orders.guestCustomer' | translate }}
}
</div>
@if (d.customerEmail) {
<div class="cell-muted">{{ d.customerEmail }}</div>
}
@if (d.customerPhone) {
<div class="cell-muted">{{ d.customerPhone }}</div>
}
</div>
<div class="section-block">
<h3>{{ 'orders.shipping' | translate }}</h3>
<div class="flex items-center gap-8" style="margin-bottom: 8px">
<app-order-channel-icons
[source]="d.source"
[shippingCarrier]="d.shippingCarrier"
[shippingServiceType]="d.shippingServiceType"
[showDelivery]="true"
[showLabels]="false"
[iconSize]="14"
/>
<span class="cell-strong">
{{ 'orders.carrier.' + d.shippingCarrier | translate }}
·
{{ 'orders.service.' + d.shippingServiceType | translate }}
</span>
</div>
@if (d.parcelPointName) {
<div>{{ d.parcelPointName }}</div>
<div class="cell-muted">{{ d.parcelPointAddress }}</div>
} @else {
<div>{{ d.shippingStreet }}</div>
<div class="cell-muted">
{{ d.shippingPostalCode }} {{ d.shippingCity }}
</div>
<div class="cell-muted">{{ d.shippingCountry }}</div>
}
</div>
<div class="section-block">
<h3>{{ 'orders.shipment' | translate }}</h3>
@if (d.shipment; as s) {
<div class="stack" style="gap: 6px">
<div>
<span class="cell-muted">{{ 'orders.tracking' | translate }}:</span>
<span class="cell-strong" style="margin-left: 6px; font-family: ui-monospace, monospace">
{{ s.trackingNumber || '—' }}
</span>
</div>
<div>
<span class="cell-muted">{{ 'orders.status' | translate }}:</span>
<span style="margin-left: 6px">
{{ 'orders.shipmentStatus.' + s.status | translate }}
</span>
</div>
@if (s.errorMessage) {
<p class="form-error" style="margin: 0">{{ s.errorMessage }}</p>
}
<div class="flex gap-8" style="margin-top: 8px; flex-wrap: wrap">
@if (s.labelRelativePath) {
<a
[href]="labelUrl(s)"
target="_blank"
rel="noopener noreferrer"
class="btn btn-secondary btn-sm"
>
{{ 'orders.downloadLabel' | translate }}
</a>
} @else if (canGenerateLabel(d)) {
<button
type="button"
class="btn btn-primary btn-sm"
[disabled]="generatingLabel()"
(click)="handleGenerateLabel()"
>
{{ 'orders.generateLabel' | translate }}
</button>
}
</div>
</div>
} @else if (canGenerateLabel(d)) {
<button
type="button"
class="btn btn-primary btn-sm"
[disabled]="generatingLabel()"
(click)="handleGenerateLabel()"
>
{{ 'orders.generateLabel' | translate }}
</button>
} @else {
<div class="cell-muted">
{{ 'orders.labelNotAvailable' | translate }}
</div>
}
</div>
</div>
</div>
</div>
</div>
@@ -1,3 +1,4 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -21,6 +22,7 @@ import {
AdminOrderDetailLine,
ORDER_STATUSES,
OrderStatus,
PaymentStatus,
ShipmentInfo,
} from './order.models';
import {
@@ -37,6 +39,7 @@ import {
selector: 'app-order-detail-modal',
standalone: true,
imports: [
DatePipe,
FormsModule,
TranslatePipe,
I18nCurrencyPipe,
@@ -299,6 +302,38 @@ export class OrderDetailModalComponent {
});
}
statusBadgeClass(status: OrderStatus): string {
switch (status) {
case 'paid':
case 'delivered':
return 'badge-success';
case 'processing':
case 'shipped':
return 'badge-info';
case 'pendingPayment':
return 'badge-warning';
case 'cancelled':
case 'refunded':
return 'badge-danger';
default:
return 'badge-neutral';
}
}
paymentBadgeClass(status: PaymentStatus | string): string {
switch (status) {
case 'completed':
return 'badge-success';
case 'pending':
return 'badge-warning';
case 'failed':
case 'refunded':
return 'badge-danger';
default:
return 'badge-neutral';
}
}
#toPickLine(detail: AdminOrderDetail, line: AdminOrderDetailLine) {
return {
orderLineId: line.id,
@@ -1,106 +1,139 @@
@if (open()) {
<div
class="admin-modal invoice-backdrop fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 print:relative print:inset-auto print:block print:bg-white print:p-0"
class="admin-modal invoice-backdrop"
role="dialog"
aria-modal="true"
(click)="handleBackdrop($event)"
>
<div
id="invoice-print-area"
class="admin-modal__panel invoice-panel my-4 w-full max-w-2xl bg-white p-8 shadow-lg print:my-0 print:max-w-none print:shadow-none"
class="admin-modal__panel admin-modal__panel--lg invoice-panel"
(click)="$event.stopPropagation()"
>
<div class="mb-6 flex items-start justify-between print:hidden">
<h2 class="text-lg font-bold">{{ 'orders.invoice' | translate }}</h2>
<div class="flex gap-2">
<div class="admin-modal__head print:hidden">
<div>
<h2>{{ 'orders.invoice' | translate }}</h2>
@if (invoice(); as inv) {
<p class="page-purpose" style="margin: 0">
{{ inv.invoiceNumber }}
· {{ inv.issueDate | date: 'yyyy-MM-dd' }}
</p>
}
</div>
<div class="page-actions">
<button
type="button"
class="bg-iled-primary px-4 py-2 text-sm font-semibold"
class="btn btn-primary"
(click)="handlePrint()"
>
{{ 'orders.print' | translate }}
</button>
<button type="button" class="px-3 text-iled-grey-dark" (click)="handleClose()">
<button
type="button"
class="admin-modal__close"
(click)="handleClose()"
[attr.aria-label]="'common.close' | translate"
>
✕
</button>
</div>
</div>
@if (loading()) {
<app-admin-skeleton variant="detail" [rows]="5" />
} @else if (invoice(); as inv) {
<header class="mb-8 border-b border-iled-grey-light pb-6">
<h1 class="text-2xl font-bold">{{ 'orders.invoice' | translate }}</h1>
<p class="mt-1 text-sm text-iled-grey-dark">{{ inv.invoiceNumber }}</p>
<p class="text-sm text-iled-grey-dark">
{{ 'orders.issueDate' | translate }}:
{{ inv.issueDate | date: 'yyyy-MM-dd' }}
</p>
</header>
<div class="admin-modal__body">
@if (loading()) {
<app-admin-skeleton variant="detail" [rows]="5" />
} @else if (invoice(); as inv) {
<header class="section-block" style="padding-top: 0">
<h1 style="margin: 0 0 6px; font-size: 22px">
{{ 'orders.invoice' | translate }}
</h1>
<div class="cell-muted">{{ inv.invoiceNumber }}</div>
<div class="cell-muted">
{{ 'orders.issueDate' | translate }}:
{{ inv.issueDate | date: 'yyyy-MM-dd' }}
</div>
</header>
<div class="mb-8 grid gap-6 sm:grid-cols-2 text-sm">
<div>
<h3 class="mb-2 font-semibold uppercase text-iled-grey-dark">
{{ 'orders.seller' | translate }}
</h3>
<p class="font-medium">{{ inv.sellerName }}</p>
<p>{{ inv.sellerAddress }}</p>
<p>NIP: {{ inv.sellerTaxId }}</p>
<div class="grid grid-2" style="margin: 8px 0 20px">
<div class="section-block" style="border: 0; padding: 0">
<h3>{{ 'orders.seller' | translate }}</h3>
<div class="cell-strong">{{ inv.sellerName }}</div>
<div class="cell-muted">{{ inv.sellerAddress }}</div>
<div class="cell-muted">NIP: {{ inv.sellerTaxId }}</div>
</div>
<div class="section-block" style="border: 0; padding: 0">
<h3>{{ 'orders.buyer' | translate }}</h3>
<div class="cell-strong">{{ inv.buyerName }}</div>
<div class="cell-muted">{{ inv.buyerEmail }}</div>
<div class="cell-muted">{{ inv.buyerAddress }}</div>
</div>
</div>
<div>
<h3 class="mb-2 font-semibold uppercase text-iled-grey-dark">
{{ 'orders.buyer' | translate }}
</h3>
<p class="font-medium">{{ inv.buyerName }}</p>
<p>{{ inv.buyerEmail }}</p>
<p>{{ inv.buyerAddress }}</p>
</div>
</div>
<table class="mb-6 w-full text-left text-sm">
<thead class="border-b-2 border-iled-black">
<tr>
<th class="py-2">{{ 'products.name' | translate }}</th>
<th class="py-2">{{ 'orders.qty' | translate }}</th>
<th class="py-2 text-right">{{ 'products.price' | translate }}</th>
</tr>
</thead>
<tbody>
@for (line of inv.lines; track line.sku) {
<tr class="border-b border-iled-grey-light">
<td class="py-2">
{{ line.productName }}
<span class="text-iled-grey-dark">({{ line.variantColor }})</span>
</td>
<td class="py-2">{{ line.quantity }}</td>
<td class="py-2 text-right">{{ line.lineTotal | i18nCurrency }}</td>
</tr>
<div class="card" style="box-shadow: none">
<div class="table-wrap">
<table class="data">
<thead>
<tr>
<th>{{ 'products.name' | translate }}</th>
<th>{{ 'orders.qty' | translate }}</th>
<th style="text-align: right">
{{ 'products.price' | translate }}
</th>
</tr>
</thead>
<tbody>
@for (line of inv.lines; track line.sku) {
<tr>
<td>
<div class="cell-strong">{{ line.productName }}</div>
<div class="cell-muted">{{ line.variantColor }}</div>
</td>
<td>{{ line.quantity }}</td>
<td style="text-align: right" class="cell-strong">
{{ line.lineTotal | i18nCurrency }}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="totals">
<div class="totals__row">
<span>{{ 'orders.subtotal' | translate }}</span>
<span>{{ inv.subtotal | i18nCurrency }}</span>
</div>
<div class="totals__row">
<span>{{ 'orders.delivery' | translate }}</span>
<span>{{ inv.deliveryCost | i18nCurrency }}</span>
</div>
@if (inv.discountAmount > 0) {
<div class="totals__row">
<span>{{ 'orders.discount' | translate }}</span>
<span>-{{ inv.discountAmount | i18nCurrency }}</span>
</div>
}
<div class="totals__row">
<span>{{ 'orders.vat' | translate }}</span>
<span>{{ inv.vatAmount | i18nCurrency }}</span>
</div>
<div class="totals__row totals__row--total">
<span>{{ 'orders.total' | translate }}</span>
<span>{{ inv.total | i18nCurrency }}</span>
</div>
</div>
</div>
<p class="cell-muted" style="margin: 16px 0 0; font-size: 12px">
{{ 'orders.payment' | translate }}:
@if (inv.paymentStatus) {
{{ 'orders.paymentStatus.' + inv.paymentStatus | translate }}
} @else {
—
}
</tbody>
</table>
<div class="space-y-1 text-right text-sm">
<div>{{ 'orders.subtotal' | translate }}: {{ inv.subtotal | i18nCurrency }}</div>
<div>{{ 'orders.delivery' | translate }}: {{ inv.deliveryCost | i18nCurrency }}</div>
@if (inv.discountAmount > 0) {
<div>{{ 'orders.discount' | translate }}: -{{ inv.discountAmount | i18nCurrency }}</div>
}
<div>{{ 'orders.vat' | translate }}: {{ inv.vatAmount | i18nCurrency }}</div>
<div class="text-lg font-bold">
{{ 'orders.total' | translate }}: {{ inv.total | i18nCurrency }}
</div>
</div>
<p class="mt-6 text-xs text-iled-grey-dark">
{{ 'orders.payment' | translate }}:
@if (inv.paymentStatus) {
{{ 'orders.paymentStatus.' + inv.paymentStatus | translate }}
} @else {
—
}
· {{ 'orders.orderRef' | translate }}: {{ inv.orderNumber }}
</p>
}
· {{ 'orders.orderRef' | translate }}: {{ inv.orderNumber }}
</p>
}
</div>
</div>
</div>
}
@@ -1,86 +1,91 @@
@if (open()) {
<div
class="admin-modal fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4"
class="admin-modal"
role="dialog"
aria-modal="true"
(click)="handleBackdrop($event)"
>
<form
class="admin-modal__panel my-4 w-full max-w-lg bg-white shadow-lg"
class="admin-modal__panel admin-modal__panel--sm"
(ngSubmit)="handleSubmit($event)"
(click)="$event.stopPropagation()"
>
<div class="flex items-center justify-between border-b border-iled-grey-light px-6 py-4">
<h2 class="text-lg font-bold">
@if (mode() === 'create') {
{{ 'groups.add' | translate }}
} @else {
{{ 'groups.edit' | translate }}
}
</h2>
<div class="admin-modal__head">
<div>
<h2>
@if (mode() === 'create') {
{{ 'groups.add' | translate }}
} @else {
{{ 'groups.edit' | translate }}
}
</h2>
</div>
<button
type="button"
class="text-iled-grey-dark hover:text-iled-black"
class="admin-modal__close"
(click)="handleClose()"
aria-label="{{ 'products.close' | translate }}"
[attr.aria-label]="'common.close' | translate"
>
✕
</button>
</div>
<div class="space-y-4 px-6 py-4">
<div class="admin-modal__body stack" style="gap: 14px">
@if (error()) {
<p class="text-sm text-red-600">{{ error() | translate }}</p>
<p class="form-error" style="margin: 0">{{ error() | translate }}</p>
}
<div class="grid gap-3 sm:grid-cols-2">
<div class="grid grid-2">
@for (locale of locales; track locale.key) {
<div>
<label class="mb-1 block text-xs font-medium text-iled-grey-dark">
<div class="field">
<label [for]="'group-name-' + locale.key">
{{ 'groups.name' | translate }} ({{ locale.label }})
</label>
<input
[id]="'group-name-' + locale.key"
class="input"
[(ngModel)]="names[locale.key]"
[name]="'group-name-' + locale.key"
class="w-full border border-iled-grey-light px-3 py-2 text-sm"
/>
</div>
}
</div>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-iled-grey-dark">
{{ 'groups.code' | translate }}
</label>
<div class="grid grid-2">
<div class="field">
<label for="group-code">{{ 'groups.code' | translate }}</label>
<input
id="group-code"
class="input"
[(ngModel)]="code"
name="groupCode"
[readonly]="mode() === 'edit'"
[class.bg-iled-bg]="mode() === 'edit'"
class="w-full border border-iled-grey-light px-3 py-2 text-sm uppercase"
style="text-transform: uppercase"
[style.background]="
mode() === 'edit' ? 'var(--surface-muted)' : null
"
/>
</div>
<div>
<label class="mb-1 block text-xs font-medium text-iled-grey-dark">
{{ 'groups.sortOrder' | translate }}
</label>
<div class="field">
<label for="group-sort">{{ 'groups.sortOrder' | translate }}</label>
<input
id="group-sort"
class="input"
[(ngModel)]="sortOrder"
name="groupSortOrder"
type="number"
min="1"
class="w-full border border-iled-grey-light px-3 py-2 text-sm"
/>
</div>
</div>
</div>
<div class="flex justify-between border-t border-iled-grey-light px-6 py-4">
<div class="admin-modal__foot">
@if (mode() === 'edit') {
<button
type="button"
class="text-sm text-red-600 hover:underline"
class="btn btn-ghost"
style="color: var(--danger)"
(click)="handleDelete()"
>
{{ 'groups.delete' | translate }}
@@ -88,15 +93,15 @@
} @else {
<span></span>
}
<div class="flex gap-2">
<div class="page-actions">
<button
type="button"
class="border border-iled-grey-light px-5 py-2 text-sm"
class="btn btn-secondary"
(click)="handleClose()"
>
{{ 'products.cancel' | translate }}
</button>
<button type="submit" class="bg-iled-primary px-5 py-2 text-sm font-semibold">
<button type="submit" class="btn btn-primary">
{{ 'products.save' | translate }}
</button>
</div>
@@ -1,25 +1,25 @@
@if (open()) {
<div
class="admin-modal fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4"
class="admin-modal"
role="dialog"
aria-modal="true"
(click)="handleBackdrop($event)"
>
<form
class="admin-modal__panel my-4 w-full max-w-4xl overflow-hidden rounded-[var(--radius-lg)] bg-[var(--surface)] shadow-[var(--shadow-md)]"
class="admin-modal__panel admin-modal__panel--xl"
(ngSubmit)="handleSubmit($event)"
(click)="$event.stopPropagation()"
>
<div class="page-header" style="padding: 20px 24px 12px; margin: 0; border-bottom: 1px solid var(--border)">
<div class="admin-modal__head">
<div>
<h1 style="font-size: 1.25rem; margin: 0">
<h1>
@if (mode() === 'create') {
{{ 'products.add' | translate }}
} @else {
{{ 'products.editTitle' | translate }}
}
</h1>
<p class="page-purpose" style="margin: 4px 0 0">
<p class="page-purpose" style="margin: 0">
@if (mode() === 'edit' && names.pl) {
{{ names.pl }}
@if (productCode) {
@@ -41,20 +41,28 @@
<button type="submit" class="btn btn-primary">
{{ 'products.saveProduct' | translate }}
</button>
<button
type="button"
class="admin-modal__close"
(click)="handleClose()"
[attr.aria-label]="'common.close' | translate"
>
✕
</button>
</div>
</div>
@if (loading()) {
<div class="card-pad">
<div class="admin-modal__body">
<app-admin-skeleton variant="form" [rows]="6" />
</div>
} @else {
<div style="padding: 0 24px">
<div class="admin-modal__body" style="padding-top: 8px">
@if (error()) {
<p class="form-error" style="margin: 12px 0 0">{{ error() | translate }}</p>
<p class="form-error" style="margin: 0 0 12px">{{ error() | translate }}</p>
}
<div class="tabs" role="tablist" style="margin-top: 8px">
<div class="tabs" role="tablist">
<button
type="button"
class="tab"
+5
View File
@@ -55,4 +55,9 @@
.invoice-backdrop {
background: white !important;
}
.admin-modal__head.print\:hidden,
.print\:hidden {
display: none !important;
}
}
+139
View File
@@ -824,6 +824,10 @@ table.data tbody tr:hover {
background: var(--surface-muted);
}
table.data tbody tr.is-collected {
background: color-mix(in srgb, var(--success-soft) 55%, transparent);
}
table.data tbody tr.is-selected {
background: var(--accent-soft);
}
@@ -1535,10 +1539,145 @@ table.data tbody tr.is-selected {
}
/* ——— Modal shells ——— */
.admin-modal {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: flex-start;
justify-content: center;
overflow-y: auto;
padding: 24px 16px;
background: rgba(17, 24, 39, 0.48);
}
.admin-modal__panel {
width: 100%;
max-width: 720px;
margin: 12px 0 32px;
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow-md);
border: 1px solid var(--border);
overflow: hidden;
}
.admin-modal__panel--lg {
max-width: 920px;
}
.admin-modal__panel--xl {
max-width: 1100px;
}
.admin-modal__panel--sm {
max-width: 520px;
}
.admin-modal__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--surface-muted) 65%, transparent) 0%,
var(--surface) 100%
);
}
.admin-modal__head h1,
.admin-modal__head h2 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.25;
}
.admin-modal__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.admin-modal__body {
padding: 20px 24px 24px;
max-height: min(72vh, 820px);
overflow-y: auto;
}
.admin-modal__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 24px;
border-top: 1px solid var(--border);
background: var(--surface-muted);
}
.admin-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-secondary);
cursor: pointer;
}
.admin-modal__close:hover {
color: var(--text);
border-color: var(--border-strong);
background: var(--surface-muted);
}
.admin-modal .split {
gap: 16px;
}
.admin-modal .card {
box-shadow: none;
}
.admin-modal .totals {
display: grid;
gap: 8px;
padding: 16px 20px;
}
.admin-modal .totals__row {
display: flex;
justify-content: space-between;
gap: 16px;
font-size: 13.5px;
color: var(--text-secondary);
}
.admin-modal .totals__row--total {
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid var(--border);
font-size: 16px;
font-weight: 700;
color: var(--text);
}
.admin-modal .row-product {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
/* ——— Premium motion ——— */
@@ -28,6 +28,22 @@ public sealed class InitiatePaymentCommandHandler(
var order = await orderService.GetOrderAsync(
request.OrderId, cancellationToken);
if (order.Status != Domain.Enums.OrderStatus.PendingPayment)
{
return new PaymentResultDto(
false,
null,
"Payment can only be started for unpaid orders.");
}
if (order.PaymentMethod != Domain.Enums.PaymentMethod.PayU)
{
return new PaymentResultDto(
false,
null,
"Only PayU orders can be paid online.");
}
var settings = await integrationSettings.GetAsync(cancellationToken);
var shopBase = configuration["Seo:ShopBaseUrl"] ?? "http://localhost:4200";
var notifyUrl = settings.PayUNotifyUrl
@@ -52,14 +68,32 @@ public sealed class InitiatePaymentCommandHandler(
return new PaymentResultDto(false, null, result.ErrorMessage);
}
var payment = new Domain.Payments.Payment
var payment = await db.Payments
.FirstOrDefaultAsync(
p => p.OrderId == order.Id,
cancellationToken);
if (payment is null)
{
OrderId = order.Id,
ExternalId = result.ExternalId ?? string.Empty,
Amount = order.Total,
RedirectUrl = result.RedirectUrl,
};
db.Add(payment);
payment = new Domain.Payments.Payment
{
OrderId = order.Id,
ExternalId = result.ExternalId ?? string.Empty,
Amount = order.Total,
RedirectUrl = result.RedirectUrl,
Status = Domain.Enums.PaymentStatus.Pending,
};
db.Add(payment);
}
else
{
payment.ExternalId = result.ExternalId ?? payment.ExternalId;
payment.Amount = order.Total;
payment.RedirectUrl = result.RedirectUrl;
payment.Status = Domain.Enums.PaymentStatus.Pending;
payment.UpdatedAt = DateTime.UtcNow;
}
await db.SaveChangesAsync(cancellationToken);
return new PaymentResultDto(true, result.RedirectUrl, null);
@@ -271,6 +271,12 @@ export const CS_TRANSLATIONS: TranslationMap = {
'checkout.payWithPayU': 'Zaplatit přes PayU',
'checkout.orderError': 'Objednávku se nepodařilo vytvořit.',
'checkout.thankYou': 'Děkujeme za objednávku!',
'checkout.paymentPendingTitle': 'Platba nebyla dokončena',
'checkout.paymentPendingBody':
'Objednávka je rezervovaná, ale platba neproběhla. Můžete to zkusit znovu.',
'checkout.payAgain': 'Zaplatit znovu',
'checkout.payingAgain': 'Přesměrování…',
'checkout.payAgainError': 'Platbu se nepodařilo spustit. Zkuste to znovu.',
'checkout.orderNumber': 'Číslo objednávky:',
'checkout.status': 'Stav:',
'checkout.status.PendingPayment': 'Čeká na platbu',
@@ -271,6 +271,13 @@ export const DE_TRANSLATIONS: TranslationMap = {
'checkout.payWithPayU': 'Mit PayU bezahlen',
'checkout.orderError': 'Bestellung fehlgeschlagen.',
'checkout.thankYou': 'Vielen Dank für Ihre Bestellung!',
'checkout.paymentPendingTitle': 'Zahlung nicht abgeschlossen',
'checkout.paymentPendingBody':
'Ihre Bestellung ist reserviert, aber die Zahlung ist fehlgeschlagen. Sie können es erneut versuchen.',
'checkout.payAgain': 'Erneut zahlen',
'checkout.payingAgain': 'Weiterleitung…',
'checkout.payAgainError':
'Zahlung konnte nicht gestartet werden. Bitte erneut versuchen.',
'checkout.orderNumber': 'Bestellnummer:',
'checkout.status': 'Status:',
'checkout.status.PendingPayment': 'Zahlung ausstehend',
@@ -271,6 +271,12 @@ export const EN_TRANSLATIONS: TranslationMap = {
'checkout.payWithPayU': 'Pay with PayU',
'checkout.orderError': 'Order placement failed.',
'checkout.thankYou': 'Thank you for your order!',
'checkout.paymentPendingTitle': 'Payment not completed',
'checkout.paymentPendingBody':
'Your order is reserved, but payment did not go through. You can try again.',
'checkout.payAgain': 'Pay again',
'checkout.payingAgain': 'Redirecting…',
'checkout.payAgainError': 'Could not start payment. Please try again.',
'checkout.orderNumber': 'Order number:',
'checkout.status': 'Status:',
'checkout.status.PendingPayment': 'Pending payment',
@@ -271,6 +271,12 @@ export const PL_TRANSLATIONS: TranslationMap = {
'checkout.payWithPayU': 'Zapłać przez PayU',
'checkout.orderError': 'Błąd składania zamówienia.',
'checkout.thankYou': 'Dziękujemy za zamówienie!',
'checkout.paymentPendingTitle': 'Płatność nie została dokończona',
'checkout.paymentPendingBody':
'Zamówienie jest zarezerwowane, ale płatność nie przeszła. Możesz spróbować ponownie.',
'checkout.payAgain': 'Zapłać ponownie',
'checkout.payingAgain': 'Przekierowanie…',
'checkout.payAgainError': 'Nie udało się uruchomić płatności. Spróbuj ponownie.',
'checkout.orderNumber': 'Numer zamówienia:',
'checkout.status': 'Status:',
'checkout.status.PendingPayment': 'Oczekuje na płatność',
@@ -351,10 +351,13 @@ export class CheckoutComponent implements OnInit {
export class OrderConfirmationComponent implements OnInit {
readonly #orders = inject(OrderService);
readonly #route = inject(ActivatedRoute);
readonly #router = inject(Router);
readonly #i18n = inject(I18nService);
readonly order = signal<OrderDto | null>(null);
readonly syncing = signal(false);
readonly paying = signal(false);
readonly payError = signal('');
ngOnInit(): void {
this.#route.paramMap.subscribe((params) => {
@@ -365,8 +368,43 @@ export class OrderConfirmationComponent implements OnInit {
});
}
needsPayment(order: OrderDto): boolean {
return (
order.paymentMethod === 'PayU' &&
order.status === 'PendingPayment'
);
}
handlePayAgain(): void {
const order = this.order();
if (!order || this.paying()) {
return;
}
this.paying.set(true);
this.payError.set('');
this.#orders.initiatePayment(order.id).subscribe({
next: (pay) => {
this.paying.set(false);
if (pay.success && pay.redirectUrl) {
this.#goToPayment(pay.redirectUrl, order.id);
return;
}
this.payError.set(
pay.errorMessage || this.#i18n.t('checkout.payAgainError'),
);
},
error: () => {
this.paying.set(false);
this.payError.set(this.#i18n.t('checkout.payAgainError'));
},
});
}
async #loadAndSync(orderId: string): Promise<void> {
this.syncing.set(true);
this.payError.set('');
try {
// PayU continueUrl lands here before/without webhook — pull status.
for (let attempt = 0; attempt < 4; attempt++) {
@@ -392,6 +430,25 @@ export class OrderConfirmationComponent implements OnInit {
}
}
/** Internal sandbox paths stay same-origin; live PayU uses absolute URL. */
#goToPayment(redirectUrl: string, orderId: string): void {
if (
redirectUrl.startsWith('/') ||
redirectUrl.includes('/checkout/payu-sandbox/')
) {
try {
const url = new URL(redirectUrl, window.location.origin);
void this.#router.navigateByUrl(url.pathname + url.search);
return;
} catch {
void this.#router.navigate(['/order', orderId]);
return;
}
}
window.location.href = redirectUrl;
}
paymentHint(method: string): string {
const key = `checkout.paymentHint.${enumKey(method)}`;
const translated = this.#i18n.t(key);
@@ -4,9 +4,19 @@
<p class="text-iled-grey-dark">{{ 'common.loading' | translate }}</p>
}
@if (order(); as o) {
<h1 class="mb-4 text-3xl font-bold">
{{ 'checkout.thankYou' | translate }}
</h1>
@if (needsPayment(o)) {
<h1 class="mb-4 text-3xl font-bold">
{{ 'checkout.paymentPendingTitle' | translate }}
</h1>
<p class="mb-6 text-iled-grey-dark">
{{ 'checkout.paymentPendingBody' | translate }}
</p>
} @else {
<h1 class="mb-4 text-3xl font-bold">
{{ 'checkout.thankYou' | translate }}
</h1>
}
<p class="mb-2">
{{ 'checkout.orderNumber' | translate }}
<strong>{{ o.orderNumber }}</strong>
@@ -35,12 +45,33 @@
</p>
}
<p class="mb-8 text-xl font-bold">{{ o.total | i18nCurrency }}</p>
<a
routerLink="/"
class="inline-flex bg-iled-black px-8 py-3 font-medium text-white"
>
{{ 'checkout.backToShop' | translate }}
</a>
@if (payError()) {
<p class="mb-4 text-sm text-red-700">{{ payError() }}</p>
}
<div class="flex flex-wrap items-center justify-center gap-3">
@if (needsPayment(o)) {
<button
type="button"
class="inline-flex bg-iled-primary px-8 py-3 font-semibold text-iled-black disabled:opacity-50"
[disabled]="paying()"
(click)="handlePayAgain()"
>
@if (paying()) {
{{ 'checkout.payingAgain' | translate }}
} @else {
{{ 'checkout.payAgain' | translate }}
}
</button>
}
<a
routerLink="/"
class="inline-flex border border-iled-grey-light bg-white px-8 py-3 font-medium text-iled-black"
>
{{ 'checkout.backToShop' | translate }}
</a>
</div>
}
</div>
</app-shell>