387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
import { Component, OnInit, PLATFORM_ID, inject, signal } from '@angular/core';
|
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
|
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
|
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
|
import { environment } from '../../../environments/environment';
|
|
import {
|
|
findColorHex,
|
|
resolveLocalizedColorLabel,
|
|
} from '../../core/constants/colors.const';
|
|
import {
|
|
PriceBreakdownComponent,
|
|
PriceBreakdownRow,
|
|
} from '../../shared/components/price-breakdown/price-breakdown.component';
|
|
|
|
interface PublicOrderItem {
|
|
id: string;
|
|
itemType?: string;
|
|
originalFilename?: string;
|
|
displayName?: string;
|
|
materialCode?: string;
|
|
colorCode?: string;
|
|
filamentVariantId?: number;
|
|
shopProductId?: string;
|
|
shopProductVariantId?: string;
|
|
shopProductSlug?: string;
|
|
shopProductName?: string;
|
|
shopVariantLabel?: string;
|
|
shopVariantColorName?: string;
|
|
shopVariantColorLabelIt?: string;
|
|
shopVariantColorLabelEn?: string;
|
|
shopVariantColorLabelDe?: string;
|
|
shopVariantColorLabelFr?: string;
|
|
shopVariantColorHex?: string;
|
|
filamentVariantDisplayName?: string;
|
|
filamentColorName?: string;
|
|
filamentColorLabelIt?: string;
|
|
filamentColorLabelEn?: string;
|
|
filamentColorLabelDe?: string;
|
|
filamentColorLabelFr?: string;
|
|
filamentColorHex?: string;
|
|
quality?: string;
|
|
nozzleDiameterMm?: number;
|
|
layerHeightMm?: number;
|
|
infillPercent?: number;
|
|
infillPattern?: string;
|
|
supportsEnabled?: boolean;
|
|
quantity: number;
|
|
printTimeSeconds?: number;
|
|
materialGrams?: number;
|
|
unitPriceChf?: number;
|
|
lineTotalChf?: number;
|
|
}
|
|
|
|
interface PublicOrder {
|
|
id: string;
|
|
orderNumber?: string;
|
|
status?: string;
|
|
paymentStatus?: string;
|
|
paymentMethod?: string;
|
|
subtotalChf?: number;
|
|
shippingCostChf?: number;
|
|
setupCostChf?: number;
|
|
totalChf?: number;
|
|
cadHours?: number;
|
|
cadTotalChf?: number;
|
|
items?: PublicOrderItem[];
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-order',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
AppButtonComponent,
|
|
AppCardComponent,
|
|
TranslateModule,
|
|
PriceBreakdownComponent,
|
|
],
|
|
templateUrl: './order.component.html',
|
|
styleUrl: './order.component.scss',
|
|
})
|
|
export class OrderComponent implements OnInit {
|
|
private route = inject(ActivatedRoute);
|
|
private router = inject(Router);
|
|
private quoteService = inject(QuoteEstimatorService);
|
|
private translate = inject(TranslateService);
|
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
|
|
|
orderId: string | null = null;
|
|
selectedPaymentMethod: 'twint' | 'bill' | null = 'twint';
|
|
order = signal<PublicOrder | null>(null);
|
|
loading = signal(true);
|
|
error = signal<string | null>(null);
|
|
twintOpenUrl = signal<string | null>(null);
|
|
twintQrUrl = signal<string | null>(null);
|
|
|
|
ngOnInit(): void {
|
|
this.orderId = this.route.snapshot.paramMap.get('orderId');
|
|
if (this.orderId) {
|
|
this.loadOrder();
|
|
this.loadTwintPayment();
|
|
} else {
|
|
this.error.set('ORDER.ERR_ID_NOT_FOUND');
|
|
this.loading.set(false);
|
|
}
|
|
}
|
|
|
|
loadOrder() {
|
|
if (!this.orderId) return;
|
|
this.quoteService.getOrder(this.orderId).subscribe({
|
|
next: (order) => {
|
|
this.order.set(order);
|
|
this.loading.set(false);
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to load order', err);
|
|
this.error.set('ORDER.ERR_LOAD_ORDER');
|
|
this.loading.set(false);
|
|
},
|
|
});
|
|
}
|
|
|
|
selectPayment(method: 'twint' | 'bill'): void {
|
|
this.selectedPaymentMethod = method;
|
|
}
|
|
|
|
downloadQrInvoice() {
|
|
if (!this.isBrowser) {
|
|
return;
|
|
}
|
|
const orderId = this.orderId;
|
|
if (!orderId) return;
|
|
this.quoteService.getOrderConfirmation(orderId).subscribe({
|
|
next: (blob) => {
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
const fallbackOrderNumber = this.extractOrderNumber(orderId);
|
|
const orderNumber = this.order()?.orderNumber ?? fallbackOrderNumber;
|
|
a.download = `qr-invoice-${orderNumber}.pdf`;
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
error: (err) => console.error('Failed to download QR invoice', err),
|
|
});
|
|
}
|
|
|
|
loadTwintPayment() {
|
|
if (!this.orderId) return;
|
|
this.quoteService.getTwintPayment(this.orderId).subscribe({
|
|
next: (res) => {
|
|
const qrPath =
|
|
typeof res.qrImageUrl === 'string'
|
|
? `${res.qrImageUrl}?size=360`
|
|
: null;
|
|
const qrDataUri =
|
|
typeof res.qrImageDataUri === 'string' ? res.qrImageDataUri : null;
|
|
this.twintOpenUrl.set(this.resolveApiUrl(res.openUrl));
|
|
this.twintQrUrl.set(qrDataUri ?? this.resolveApiUrl(qrPath));
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to load TWINT payment details', err);
|
|
},
|
|
});
|
|
}
|
|
|
|
openTwintPayment(): void {
|
|
const openUrl = this.twintOpenUrl();
|
|
if (this.isBrowser && openUrl) {
|
|
window.open(openUrl, '_blank');
|
|
}
|
|
}
|
|
|
|
getTwintQrUrl(): string {
|
|
return this.twintQrUrl() ?? '';
|
|
}
|
|
|
|
getTwintButtonImageUrl(): string {
|
|
const lang = this.translate.currentLang;
|
|
if (lang === 'de') {
|
|
return 'https://go.twint.ch/static/img/button_dark_de.svg';
|
|
}
|
|
if (lang === 'it') {
|
|
return 'https://go.twint.ch/static/img/button_dark_it.svg';
|
|
}
|
|
if (lang === 'fr') {
|
|
return 'https://go.twint.ch/static/img/button_dark_fr.svg';
|
|
}
|
|
// Default to EN for everything else (it, fr, en) as instructed or if not DE
|
|
return 'https://go.twint.ch/static/img/button_dark_en.svg';
|
|
}
|
|
|
|
onTwintQrError(): void {
|
|
this.twintQrUrl.set(null);
|
|
}
|
|
|
|
private resolveApiUrl(urlOrPath: string | null | undefined): string | null {
|
|
if (!urlOrPath) return null;
|
|
if (urlOrPath.startsWith('http://') || urlOrPath.startsWith('https://')) {
|
|
return urlOrPath;
|
|
}
|
|
const base = (environment.apiUrl || '').replace(/\/$/, '');
|
|
const path = urlOrPath.startsWith('/') ? urlOrPath : `/${urlOrPath}`;
|
|
return `${base}${path}`;
|
|
}
|
|
|
|
completeOrder(): void {
|
|
if (!this.orderId || !this.selectedPaymentMethod) {
|
|
return;
|
|
}
|
|
|
|
this.quoteService
|
|
.reportPayment(this.orderId, this.selectedPaymentMethod)
|
|
.subscribe({
|
|
next: (order) => {
|
|
this.order.set(order);
|
|
// The UI will re-render and show the 'REPORTED' state.
|
|
// We stay on this page to let the user see the "In verifica"
|
|
// status along with payment instructions.
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to report payment', err);
|
|
this.error.set('ORDER.ERR_REPORT_PAYMENT');
|
|
},
|
|
});
|
|
}
|
|
|
|
getDisplayOrderNumber(order: any): string {
|
|
if (order?.orderNumber) {
|
|
return order.orderNumber;
|
|
}
|
|
if (order?.id) {
|
|
return this.extractOrderNumber(order.id);
|
|
}
|
|
return this.translate.instant('ORDER.NOT_AVAILABLE');
|
|
}
|
|
|
|
orderPriceBreakdownRows(order: any): PriceBreakdownRow[] {
|
|
return [
|
|
{
|
|
labelKey: 'PAYMENT.SUBTOTAL',
|
|
amount: order?.subtotalChf ?? 0,
|
|
},
|
|
{
|
|
label: `Servizio CAD (${order?.cadHours || 0}h)`,
|
|
amount: order?.cadTotalChf ?? 0,
|
|
visible: (order?.cadTotalChf ?? 0) > 0,
|
|
},
|
|
{
|
|
labelKey: 'PAYMENT.SHIPPING',
|
|
amount: order?.shippingCostChf ?? 0,
|
|
},
|
|
{
|
|
labelKey: 'PAYMENT.SETUP_FEE',
|
|
amount: order?.setupCostChf ?? 0,
|
|
},
|
|
];
|
|
}
|
|
|
|
private extractOrderNumber(orderId: string): string {
|
|
return orderId.split('-')[0];
|
|
}
|
|
|
|
isShopItem(item: PublicOrderItem): boolean {
|
|
return String(item?.itemType ?? '').toUpperCase() === 'SHOP_PRODUCT';
|
|
}
|
|
|
|
itemDisplayName(item: PublicOrderItem): string {
|
|
const displayName = String(item?.displayName ?? '').trim();
|
|
if (displayName) {
|
|
return displayName;
|
|
}
|
|
|
|
const shopName = String(item?.shopProductName ?? '').trim();
|
|
if (shopName) {
|
|
return shopName;
|
|
}
|
|
|
|
return String(
|
|
item?.originalFilename ?? this.translate.instant('ORDER.NOT_AVAILABLE'),
|
|
);
|
|
}
|
|
|
|
itemVariantLabel(item: PublicOrderItem): string | null {
|
|
const variantLabel = String(item?.shopVariantLabel ?? '').trim();
|
|
if (variantLabel) {
|
|
return variantLabel;
|
|
}
|
|
|
|
return this.localizedColorLabel(item, 'shop');
|
|
}
|
|
|
|
itemColorLabel(item: PublicOrderItem): string {
|
|
return (
|
|
this.localizedColorLabel(item, 'shop') ||
|
|
this.localizedColorLabel(item, 'filament') ||
|
|
String(item?.colorCode ?? '').trim() ||
|
|
this.translate.instant('ORDER.NOT_AVAILABLE')
|
|
);
|
|
}
|
|
|
|
itemColorHex(item: PublicOrderItem): string | null {
|
|
const shopHex = String(item?.shopVariantColorHex ?? '').trim();
|
|
if (this.isHexColor(shopHex)) {
|
|
return shopHex;
|
|
}
|
|
|
|
const filamentHex = String(item?.filamentColorHex ?? '').trim();
|
|
if (this.isHexColor(filamentHex)) {
|
|
return filamentHex;
|
|
}
|
|
|
|
const rawColor = String(item?.colorCode ?? '').trim();
|
|
if (this.isHexColor(rawColor)) {
|
|
return rawColor;
|
|
}
|
|
|
|
return findColorHex(rawColor);
|
|
}
|
|
|
|
showItemMaterial(item: PublicOrderItem): boolean {
|
|
return !this.isShopItem(item);
|
|
}
|
|
|
|
showItemPrintMetrics(item: PublicOrderItem): boolean {
|
|
return !this.isShopItem(item);
|
|
}
|
|
|
|
private localizedColorLabel(
|
|
item: PublicOrderItem,
|
|
source: 'shop' | 'filament',
|
|
): string | null {
|
|
if (source === 'shop') {
|
|
return resolveLocalizedColorLabel(this.translate.currentLang, {
|
|
fallback: item.shopVariantColorName,
|
|
it: item.shopVariantColorLabelIt,
|
|
en: item.shopVariantColorLabelEn,
|
|
de: item.shopVariantColorLabelDe,
|
|
fr: item.shopVariantColorLabelFr,
|
|
});
|
|
}
|
|
|
|
return resolveLocalizedColorLabel(this.translate.currentLang, {
|
|
fallback: item.filamentColorName ?? item.colorCode,
|
|
it: item.filamentColorLabelIt,
|
|
en: item.filamentColorLabelEn,
|
|
de: item.filamentColorLabelDe,
|
|
fr: item.filamentColorLabelFr,
|
|
});
|
|
}
|
|
|
|
orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' {
|
|
const items = order?.items ?? [];
|
|
const hasShop = items.some((item) => this.isShopItem(item));
|
|
const hasPrint = items.some((item) => !this.isShopItem(item));
|
|
|
|
if (hasShop && hasPrint) {
|
|
return 'MIXED';
|
|
}
|
|
if (hasShop) {
|
|
return 'SHOP';
|
|
}
|
|
return 'CALCULATOR';
|
|
}
|
|
|
|
orderKindLabel(order: PublicOrder | null): string {
|
|
switch (this.orderKind(order)) {
|
|
case 'SHOP':
|
|
return this.translate.instant('ORDER.TYPE_SHOP');
|
|
case 'MIXED':
|
|
return this.translate.instant('ORDER.TYPE_MIXED');
|
|
default:
|
|
return this.translate.instant('ORDER.TYPE_CALCULATOR');
|
|
}
|
|
}
|
|
|
|
private isHexColor(value?: string): boolean {
|
|
return (
|
|
typeof value === 'string' &&
|
|
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(value)
|
|
);
|
|
}
|
|
}
|