Compare commits
3 Commits
ebca66dd4c
...
8a91a862ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a91a862ca | ||
|
|
4ef5ea2f58 | ||
|
|
4c4417dc1d |
@@ -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>
|
||||
|
||||
@@ -30,6 +30,8 @@ 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 internalNoteDraft = signal('');
|
||||
@@ -74,6 +76,11 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
<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>
|
||||
|
||||
@@ -30,6 +30,8 @@ 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');
|
||||
|
||||
@@ -123,6 +123,9 @@ 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.',
|
||||
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',
|
||||
|
||||
@@ -123,6 +123,9 @@ export const hy: Translations = {
|
||||
emailNeedsAt: 'Email-ը պետք է պարունակի @',
|
||||
emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)',
|
||||
emailInvalid: 'Սխալ email ձևաչափ',
|
||||
telegramIdMissing: 'Չհաջողվեց հաստատել ձեր Telegram հաշիվը, ուստի կոնտակտային տվյալները չեն պահպանվել։ Վճարումը հաջողությամբ կատարվել է։',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Մուտք գործեք ձևակերպելու համար',
|
||||
loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով',
|
||||
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||
|
||||
@@ -123,6 +123,9 @@ export const ru: Translations = {
|
||||
emailNeedsAt: 'Email должен содержать @',
|
||||
emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)',
|
||||
emailInvalid: 'Некорректный формат email',
|
||||
telegramIdMissing: 'Не удалось определить ваш Telegram-аккаунт, поэтому контактные данные не сохранены. Оплата прошла успешно.',
|
||||
emailPlaceholder: 'you@example.com',
|
||||
phonePlaceholder: '+7 (___) ___-__-__',
|
||||
loginRequired: 'Войдите для оформления',
|
||||
loginRequiredDesc: 'Для оформления заказа войдите через Telegram',
|
||||
loginWithTelegram: 'Войти через Telegram',
|
||||
|
||||
@@ -121,6 +121,9 @@ export interface Translations {
|
||||
emailNeedsAt: string;
|
||||
emailNeedsDomain: string;
|
||||
emailInvalid: string;
|
||||
telegramIdMissing: string;
|
||||
emailPlaceholder: string;
|
||||
phonePlaceholder: string;
|
||||
loginRequired: string;
|
||||
loginRequiredDesc: string;
|
||||
loginWithTelegram: string;
|
||||
|
||||
@@ -271,6 +271,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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '',
|
||||
@@ -432,31 +434,41 @@ 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);
|
||||
if (this.purchaseSubmitted()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const telegramUserId = this.getTelegramUserId();
|
||||
|
||||
// Telegram ID is mandatory
|
||||
// Telegram ID is mandatory for submitPurchaseEmail.
|
||||
if (!telegramUserId) {
|
||||
console.error('Cannot submit purchase: Telegram ID is required');
|
||||
this.notifications.show(this.i18n.t('cart.telegramIdMissing'), 'warning');
|
||||
this.emailSubmitting.set(false);
|
||||
this.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,
|
||||
@@ -472,6 +484,7 @@ export class CartComponent implements OnDestroy {
|
||||
|
||||
this.apiService.submitPurchaseEmail(emailData).subscribe({
|
||||
next: () => {
|
||||
this.purchaseSubmitted.set(true);
|
||||
this.emailSubmitting.set(false);
|
||||
this.closePaymentPopup();
|
||||
const lang = this.langService.currentLanguage();
|
||||
@@ -487,8 +500,6 @@ export class CartComponent implements OnDestroy {
|
||||
}
|
||||
});
|
||||
this.paymentStatus.set(null);
|
||||
|
||||
|
||||
}
|
||||
|
||||
copyPaymentLink(): void {
|
||||
@@ -541,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
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface QrCreateResponse {
|
||||
|
||||
export interface CartPaymentRequest {
|
||||
amount: number;
|
||||
currency: 'RUB';
|
||||
currency: string;
|
||||
siteuserID: string;
|
||||
siteorderID: string;
|
||||
redirectUrl: string;
|
||||
|
||||
Reference in New Issue
Block a user