dev #37

Merged
JoeKung merged 47 commits from dev into main 2026-03-10 17:43:46 +01:00
13 changed files with 287 additions and 103 deletions
Showing only changes of commit c24e27a9db - Show all commits

View File

@@ -62,7 +62,9 @@
>
<circle cx="8" cy="21" r="1"></circle>
<circle cx="19" cy="21" r="1"></circle>
<path d="M2.05 2h2l2.4 12.45a2 2 0 0 0 2 1.55h9.7a2 2 0 0 0 1.95-1.57L22 7H6"></path>
<path
d="M2.05 2h2l2.4 12.45a2 2 0 0 0 2 1.55h9.7a2 2 0 0 0 1.95-1.57L22 7H6"
></path>
</svg>
@if (cartItemCount() > 0) {
<span class="cart-badge">{{ cartItemCount() }}</span>
@@ -145,7 +147,9 @@
<div class="qty-control">
<button
type="button"
[disabled]="cartMutating() && busyLineItemId() === item.id"
[disabled]="
cartMutating() && busyLineItemId() === item.id
"
(click)="decreaseQuantity(item)"
>
-
@@ -153,7 +157,9 @@
<span>{{ item.quantity }}</span>
<button
type="button"
[disabled]="cartMutating() && busyLineItemId() === item.id"
[disabled]="
cartMutating() && busyLineItemId() === item.id
"
(click)="increaseQuantity(item)"
>
+
@@ -180,7 +186,9 @@
<div class="cart-totals">
<div class="cart-total-row">
<span>{{ "SHOP.CART_SUBTOTAL" | translate }}</span>
<strong>{{ cart()?.itemsTotalChf || 0 | currency: "CHF" }}</strong>
<strong>{{
cart()?.itemsTotalChf || 0 | currency: "CHF"
}}</strong>
</div>
<div class="cart-total-row">
<span>{{ "SHOP.CART_SHIPPING" | translate }}</span>

View File

@@ -1,11 +1,19 @@
import { CommonModule } from '@angular/common';
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationStart, Router, RouterLink, RouterLinkActive } from '@angular/router';
import {
NavigationStart,
Router,
RouterLink,
RouterLinkActive,
} from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { LanguageService } from '../services/language.service';
import { routes } from '../../app.routes';
import { ShopCartItem, ShopService } from '../../features/shop/services/shop.service';
import {
ShopCartItem,
ShopService,
} from '../../features/shop/services/shop.service';
import { finalize } from 'rxjs';
@Component({
@@ -28,7 +36,9 @@ export class NavbarComponent {
readonly cart = this.shopService.cart;
readonly cartLoading = this.shopService.cartLoading;
readonly cartItems = computed(() =>
(this.cart()?.items ?? []).filter((item) => item.lineItemType === 'SHOP_PRODUCT'),
(this.cart()?.items ?? []).filter(
(item) => item.lineItemType === 'SHOP_PRODUCT',
),
);
readonly cartHasItems = computed(() => this.cartItems().length > 0);
readonly cartItemCount = this.shopService.cartItemCount;
@@ -127,7 +137,9 @@ export class NavbarComponent {
}
cartItemName(item: ShopCartItem): string {
return item.displayName || item.shopProductName || item.originalFilename || '-';
return (
item.displayName || item.shopProductName || item.originalFilename || '-'
);
}
cartItemVariant(item: ShopCartItem): string | null {

View File

@@ -2,9 +2,7 @@
<header class="section-header">
<div class="header-copy">
<h2>Richieste di contatto</h2>
<p>
Richieste preventivo personalizzato ricevute dal sito.
</p>
<p>Richieste preventivo personalizzato ricevute dal sito.</p>
<span class="total-pill ui-pill">{{ requests.length }} richieste</span>
</div>
<button

View File

@@ -2,9 +2,7 @@
<header class="dashboard-header section-header">
<div>
<h1>Ordini</h1>
<p>
Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.
</p>
<p>Seleziona un ordine a sinistra e gestiscilo nel dettaglio a destra.</p>
</div>
<div class="header-actions">
<button

View File

@@ -2,9 +2,7 @@
<header class="section-header">
<div>
<h2>Sessioni quote</h2>
<p>
Sessioni create dal configuratore con stato e conversione ordine.
</p>
<p>Sessioni create dal configuratore con stato e conversione ordine.</p>
</div>
<button
type="button"

View File

@@ -17,7 +17,11 @@
<strong>{{ categories.length }}</strong>
<span>categorie</span>
</article>
<button type="button" class="ui-button ui-button--ghost" (click)="loadWorkspace()">
<button
type="button"
class="ui-button ui-button--ghost"
(click)="loadWorkspace()"
>
Aggiorna
</button>
<button type="button" class="ui-button" (click)="startCreateProduct()">
@@ -26,7 +30,9 @@
</div>
</header>
<p class="ui-banner ui-banner--error" *ngIf="errorMessage">{{ errorMessage }}</p>
<p class="ui-banner ui-banner--error" *ngIf="errorMessage">
{{ errorMessage }}
</p>
<p class="ui-banner ui-banner--success" *ngIf="successMessage">
{{ successMessage }}
</p>
@@ -49,7 +55,9 @@
class="ui-button ui-button--ghost"
(click)="toggleCategoryManager()"
>
{{ showCategoryManager ? "Chiudi categorie" : "Gestisci categorie" }}
{{
showCategoryManager ? "Chiudi categorie" : "Gestisci categorie"
}}
</button>
</div>
@@ -89,7 +97,10 @@
[ngModel]="productStatusFilter"
(ngModelChange)="onProductStatusFilterChange($event)"
>
<option *ngFor="let filter of productStatusFilters" [ngValue]="filter">
<option
*ngFor="let filter of productStatusFilters"
[ngValue]="filter"
>
{{ filter }}
</option>
</select>
@@ -97,7 +108,10 @@
</div>
</div>
<section class="category-manager ui-subpanel ui-subpanel--elevated" *ngIf="showCategoryManager">
<section
class="category-manager ui-subpanel ui-subpanel--elevated"
*ngIf="showCategoryManager"
>
<header class="category-manager__header">
<div>
<h3>Categorie shop</h3>
@@ -146,10 +160,16 @@
<div class="panel-heading">
<div>
<h4>
{{ categoryForm.id ? "Modifica categoria" : "Nuova categoria" }}
{{
categoryForm.id ? "Modifica categoria" : "Nuova categoria"
}}
</h4>
<p *ngIf="categoryForm.id">Aggiorna struttura, SEO e visibilità.</p>
<p *ngIf="!categoryForm.id">Crea una nuova categoria del catalogo.</p>
<p *ngIf="categoryForm.id">
Aggiorna struttura, SEO e visibilità.
</p>
<p *ngIf="!categoryForm.id">
Crea una nuova categoria del catalogo.
</p>
</div>
</div>
@@ -340,9 +360,13 @@
</div>
</td>
<td>{{ product.categoryName }}</td>
<td>{{ product.activeVariantCount }} / {{ product.variantCount }}</td>
<td>
{{ product.priceFromChf | currency: "CHF" : "symbol" : "1.2-2" }}
{{ product.activeVariantCount }} / {{ product.variantCount }}
</td>
<td>
{{
product.priceFromChf | currency: "CHF" : "symbol" : "1.2-2"
}}
<span *ngIf="product.priceToChf !== product.priceFromChf">
-
{{
@@ -366,7 +390,9 @@
</td>
</tr>
<tr *ngIf="filteredProducts.length === 0">
<td colspan="5">Nessun prodotto trovato con i filtri correnti.</td>
<td colspan="5">
Nessun prodotto trovato con i filtri correnti.
</td>
</tr>
</tbody>
</table>
@@ -388,7 +414,11 @@
<div class="detail-header">
<div>
<h2>
{{ productMode === "create" ? "Nuovo prodotto" : selectedProduct?.name }}
{{
productMode === "create"
? "Nuovo prodotto"
: selectedProduct?.name
}}
</h2>
<p *ngIf="productMode === 'create'">
Compila i campi e salva per creare un nuovo prodotto shop.
@@ -412,20 +442,29 @@
</span>
</div>
<p class="detail-loading" *ngIf="detailLoading">Caricamento dettaglio...</p>
<p class="detail-loading" *ngIf="detailLoading">
Caricamento dettaglio...
</p>
<div class="ui-meta-grid" *ngIf="productMode === 'edit' && selectedProduct">
<div
class="ui-meta-grid"
*ngIf="productMode === 'edit' && selectedProduct"
>
<div class="ui-meta-item">
<strong>Categoria</strong>
<span>{{ selectedProduct.categoryName }}</span>
</div>
<div class="ui-meta-item">
<strong>Creato il</strong>
<span>{{ selectedProduct.createdAt | date: "dd.MM.yyyy HH:mm" }}</span>
<span>{{
selectedProduct.createdAt | date: "dd.MM.yyyy HH:mm"
}}</span>
</div>
<div class="ui-meta-item">
<strong>Aggiornato il</strong>
<span>{{ selectedProduct.updatedAt | date: "dd.MM.yyyy HH:mm" }}</span>
<span>{{
selectedProduct.updatedAt | date: "dd.MM.yyyy HH:mm"
}}</span>
</div>
<div class="ui-meta-item">
<strong>Media</strong>
@@ -530,7 +569,9 @@
<div class="panel-heading">
<div>
<h3>Contenuti localizzati</h3>
<p>Nome obbligatorio in tutte le lingue. Descrizioni opzionali.</p>
<p>
Nome obbligatorio in tutte le lingue. Descrizioni opzionali.
</p>
</div>
</div>
@@ -650,7 +691,11 @@
<h3>Varianti</h3>
<p>Colori, materiale interno, SKU e prezzi.</p>
</div>
<button type="button" class="ui-button ui-button--ghost" (click)="addVariant()">
<button
type="button"
class="ui-button ui-button--ghost"
(click)="addVariant()"
>
Aggiungi variante
</button>
</div>
@@ -658,12 +703,20 @@
<div class="variant-stack">
<article
class="variant-card"
*ngFor="let variant of productForm.variants; let index = index; trackBy: trackVariant"
*ngFor="
let variant of productForm.variants;
let index = index;
trackBy: trackVariant
"
>
<div class="variant-card__header">
<div>
<h4>
{{ variant.variantLabel || variant.colorName || "Nuova variante" }}
{{
variant.variantLabel ||
variant.colorName ||
"Nuova variante"
}}
</h4>
<p>Ordine {{ variant.sortOrder }}</p>
</div>
@@ -801,8 +854,8 @@
<div>
<h3>Immagini e modello 3D</h3>
<p>
Upload protetto con whitelist tipi file; il modello 3D è disponibile
solo dopo il primo salvataggio del prodotto.
Upload protetto con whitelist tipi file; il modello 3D è
disponibile solo dopo il primo salvataggio del prodotto.
</p>
</div>
</div>
@@ -811,12 +864,17 @@
Salva prima il prodotto per collegare immagini e modello 3D.
</div>
<div class="media-grid" *ngIf="productMode === 'edit' && selectedProduct">
<div
class="media-grid"
*ngIf="productMode === 'edit' && selectedProduct"
>
<section class="ui-subpanel ui-subpanel--soft">
<div class="panel-heading">
<div>
<h4>Nuova immagine prodotto</h4>
<p>JPG, PNG o WEBP con titolo e alt text in tutte le lingue.</p>
<p>
JPG, PNG o WEBP con titolo e alt text in tutte le lingue.
</p>
</div>
</div>
@@ -840,7 +898,10 @@
</label>
</div>
<div class="preview-card form-field--wide" *ngIf="imageUploadState.previewUrl">
<div
class="preview-card form-field--wide"
*ngIf="imageUploadState.previewUrl"
>
<img [src]="imageUploadState.previewUrl" alt="" />
</div>
@@ -854,7 +915,9 @@
*ngFor="let language of mediaLanguages"
type="button"
class="ui-language-toolbar__button"
[class.active]="imageUploadState.activeLanguage === language"
[class.active]="
imageUploadState.activeLanguage === language
"
[class.complete]="isImageLanguageComplete(language)"
[class.incomplete]="!isImageLanguageComplete(language)"
(click)="setActiveImageLanguage(language)"
@@ -872,7 +935,9 @@
class="ui-form-control"
type="text"
[(ngModel)]="
imageUploadState.translations[imageUploadState.activeLanguage].title
imageUploadState.translations[
imageUploadState.activeLanguage
].title
"
name="productImageTitle"
/>
@@ -880,13 +945,16 @@
<label class="ui-form-field">
<span class="ui-form-caption">
Alt text {{ languageLabels[imageUploadState.activeLanguage] }}
Alt text
{{ languageLabels[imageUploadState.activeLanguage] }}
</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="
imageUploadState.translations[imageUploadState.activeLanguage].altText
imageUploadState.translations[
imageUploadState.activeLanguage
].altText
"
name="productImageAltText"
/>
@@ -922,7 +990,11 @@
(click)="uploadProductImage()"
[disabled]="imageUploadState.saving || !imageUploadState.file"
>
{{ imageUploadState.saving ? "Caricamento..." : "Carica immagine" }}
{{
imageUploadState.saving
? "Caricamento..."
: "Carica immagine"
}}
</button>
</div>
</section>
@@ -931,31 +1003,49 @@
<div class="panel-heading">
<div>
<h4>Immagini attive</h4>
<p>{{ productImages.length }} immagini collegate al prodotto.</p>
<p>
{{ productImages.length }} immagini collegate al prodotto.
</p>
</div>
</div>
<div class="image-stack" *ngIf="productImages.length; else emptyImagesTpl">
<div
class="image-stack"
*ngIf="productImages.length; else emptyImagesTpl"
>
<article
class="image-item"
*ngFor="let image of productImages; trackBy: trackImage"
>
<div class="image-item__preview">
<img *ngIf="image.previewUrl; else noImagePreviewTpl" [src]="image.previewUrl" alt="" />
<img
*ngIf="image.previewUrl; else noImagePreviewTpl"
[src]="image.previewUrl"
alt=""
/>
</div>
<div class="image-item__content">
<div class="image-item__header">
<strong>
{{ image.translations[imageUploadState.activeLanguage].title || "Senza titolo" }}
{{
image.translations[imageUploadState.activeLanguage]
.title || "Senza titolo"
}}
</strong>
<span class="ui-pill ui-pill--soft" *ngIf="image.isPrimary">
<span
class="ui-pill ui-pill--soft"
*ngIf="image.isPrimary"
>
Primaria
</span>
</div>
<p class="image-meta">
{{ image.translations[imageUploadState.activeLanguage].altText || "Alt text mancante" }}
{{
image.translations[imageUploadState.activeLanguage]
.altText || "Alt text mancante"
}}
</p>
<div class="image-item__controls">
@@ -982,7 +1072,9 @@
type="button"
class="ui-button ui-button--ghost"
(click)="setPrimaryImage(image)"
[disabled]="isImageBusy(image.usageId) || image.isPrimary"
[disabled]="
isImageBusy(image.usageId) || image.isPrimary
"
>
{{ image.isPrimary ? "Primaria" : "Rendi primaria" }}
</button>
@@ -1012,7 +1104,10 @@
</div>
</div>
<div class="model-summary" *ngIf="selectedProduct.model3d as model; else noModelTpl">
<div
class="model-summary"
*ngIf="selectedProduct.model3d as model; else noModelTpl"
>
<div class="ui-meta-grid">
<div class="ui-meta-item">
<strong>File</strong>
@@ -1025,7 +1120,8 @@
<div class="ui-meta-item">
<strong>Bounding box</strong>
<span>
{{ model.boundingBoxXMm || 0 }} × {{ model.boundingBoxYMm || 0 }} ×
{{ model.boundingBoxXMm || 0 }} ×
{{ model.boundingBoxYMm || 0 }} ×
{{ model.boundingBoxZMm || 0 }} mm
</span>
</div>
@@ -1053,7 +1149,9 @@
</div>
<div class="ui-form-field">
<span class="ui-form-caption">Carica o sostituisci modello</span>
<span class="ui-form-caption"
>Carica o sostituisci modello</span
>
<input
id="product-model-file"
class="sr-only"
@@ -1127,5 +1225,7 @@
</ng-template>
<ng-template #noModelTpl>
<div class="locked-panel">Nessun modello 3D caricato per questo prodotto.</div>
<div class="locked-panel">
Nessun modello 3D caricato per questo prodotto.
</div>
</ng-template>

View File

@@ -170,7 +170,8 @@ export class AdminShopComponent implements OnInit, OnDestroy {
readonly categoryForm: CategoryFormState = this.createEmptyCategoryForm();
readonly productForm: ProductFormState = this.createEmptyProductForm();
imageUploadState: ProductImageUploadState = this.createEmptyImageUploadState();
imageUploadState: ProductImageUploadState =
this.createEmptyImageUploadState();
modelUploadFile: File | null = null;
ngOnInit(): void {
@@ -220,7 +221,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
const targetProductId =
preferredProductId ??
(this.productMode === 'edit' ? this.selectedProductId : null);
if (targetProductId && products.some((product) => product.id === targetProductId)) {
if (
targetProductId &&
products.some((product) => product.id === targetProductId)
) {
this.openProduct(targetProductId);
return;
}
@@ -524,8 +528,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
addVariant(): void {
const sortOrder =
(this.productForm.variants.at(-1)?.sortOrder ?? -1) + 1;
const sortOrder = (this.productForm.variants.at(-1)?.sortOrder ?? -1) + 1;
const firstVariant = this.productForm.variants.length === 0;
this.productForm.variants = [
...this.productForm.variants,
@@ -621,11 +624,17 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
deleteModel(): void {
if (!this.selectedProductId || this.deletingModel || !this.selectedProduct?.model3d) {
if (
!this.selectedProductId ||
this.deletingModel ||
!this.selectedProduct?.model3d
) {
return;
}
if (!window.confirm('Rimuovere il modello 3D associato a questo prodotto?')) {
if (
!window.confirm('Rimuovere il modello 3D associato a questo prodotto?')
) {
return;
}
@@ -709,11 +718,15 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
getActiveImageTranslation(): AdminMediaTranslation {
return this.imageUploadState.translations[this.imageUploadState.activeLanguage];
return this.imageUploadState.translations[
this.imageUploadState.activeLanguage
];
}
isImageLanguageComplete(language: AdminMediaLanguage): boolean {
return this.isTranslationComplete(this.imageUploadState.translations[language]);
return this.isTranslationComplete(
this.imageUploadState.translations[language],
);
}
uploadProductImage(): void {
@@ -930,7 +943,8 @@ export class AdminShopComponent implements OnInit, OnDestroy {
const searchNeedle = this.productSearchTerm.trim().toLowerCase();
this.filteredProducts = this.products.filter((product) => {
const matchesCategory =
this.categoryFilter === 'ALL' || product.categoryId === this.categoryFilter;
this.categoryFilter === 'ALL' ||
product.categoryId === this.categoryFilter;
const matchesStatus =
this.productStatusFilter === 'ALL' ||
(this.productStatusFilter === 'ACTIVE' && product.isActive) ||
@@ -1215,7 +1229,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
if (!Number.isFinite(price) || price < 0) {
return `La variante "${variant.colorName.trim()}" ha un prezzo non valido.`;
}
if (variant.colorHex.trim() && !/^#[0-9A-Fa-f]{6}$/.test(variant.colorHex.trim())) {
if (
variant.colorHex.trim() &&
!/^#[0-9A-Fa-f]{6}$/.test(variant.colorHex.trim())
) {
return `La variante "${variant.colorName.trim()}" ha un colore HEX non valido.`;
}
if (variant.isDefault) {
@@ -1426,7 +1443,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
}
private deriveDefaultTitle(filename: string): string {
return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim();
return filename
.replace(/\.[^.]+$/, '')
.replace(/[-_]+/g, ' ')
.trim();
}
private optionalValue(value: string): string | undefined {
@@ -1445,7 +1465,9 @@ export class AdminShopComponent implements OnInit, OnDestroy {
private resolveFileExtension(filename: string): string {
const lastDotIndex = filename.lastIndexOf('.');
return lastDotIndex >= 0 ? filename.slice(lastDotIndex + 1).toLowerCase() : '';
return lastDotIndex >= 0
? filename.slice(lastDotIndex + 1).toLowerCase()
: '';
}
private isAllowedImageType(mimeType: string, filename: string): boolean {

View File

@@ -152,7 +152,8 @@ export interface AdminShopProduct {
updatedAt: string;
}
export interface AdminShopMediaUsage extends Omit<AdminMediaUsage, 'translations'> {
export interface AdminShopMediaUsage
extends Omit<AdminMediaUsage, 'translations'> {
translations: Record<AdminMediaLanguage, AdminMediaTranslation>;
}
@@ -214,9 +215,12 @@ export class AdminShopService {
}
getCategoryTree(): Observable<AdminShopCategory[]> {
return this.http.get<AdminShopCategory[]>(`${this.categoriesBaseUrl}/tree`, {
return this.http.get<AdminShopCategory[]>(
`${this.categoriesBaseUrl}/tree`,
{
withCredentials: true,
});
},
);
}
getCategory(categoryId: string): Observable<AdminShopCategory> {
@@ -258,9 +262,12 @@ export class AdminShopService {
}
getProduct(productId: string): Observable<AdminShopProduct> {
return this.http.get<AdminShopProduct>(`${this.productsBaseUrl}/${productId}`, {
return this.http.get<AdminShopProduct>(
`${this.productsBaseUrl}/${productId}`,
{
withCredentials: true,
});
},
);
}
createProduct(
@@ -288,7 +295,10 @@ export class AdminShopService {
});
}
uploadProductModel(productId: string, file: File): Observable<AdminShopProduct> {
uploadProductModel(
productId: string,
file: File,
): Observable<AdminShopProduct> {
const formData = new FormData();
formData.append('file', file);
return this.http.post<AdminShopProduct>(
@@ -299,9 +309,12 @@ export class AdminShopService {
}
deleteProductModel(productId: string): Observable<void> {
return this.http.delete<void>(`${this.productsBaseUrl}/${productId}/model`, {
return this.http.delete<void>(
`${this.productsBaseUrl}/${productId}/model`,
{
withCredentials: true,
});
},
);
}
listMediaAssets(): Observable<AdminMediaAsset[]> {

View File

@@ -40,7 +40,11 @@
align-items: flex-end;
padding: var(--space-5);
background:
radial-gradient(circle at top right, rgba(250, 207, 10, 0.24), transparent 36%),
radial-gradient(
circle at top right,
rgba(250, 207, 10, 0.24),
transparent 36%
),
linear-gradient(160deg, #f7f4ed 0%, #ece7db 100%);
}

View File

@@ -56,7 +56,11 @@
align-items: flex-end;
padding: var(--space-6);
background:
radial-gradient(circle at top right, rgba(250, 207, 10, 0.24), transparent 34%),
radial-gradient(
circle at top right,
rgba(250, 207, 10, 0.24),
transparent 34%
),
linear-gradient(160deg, #f8f4ea 0%, #eee8db 100%);
}
@@ -314,8 +318,7 @@ h1 {
}
.skeleton-block {
background:
linear-gradient(
background: linear-gradient(
110deg,
rgba(255, 255, 255, 0.7) 8%,
rgba(238, 235, 226, 0.95) 18%,

View File

@@ -4,7 +4,9 @@
<p class="ui-simple-hero__subtitle">
{{
selectedCategory()
? (selectedCategory()?.description || ("SHOP.CATEGORY_META" | translate: { count: selectedCategory()?.productCount || 0 }))
? selectedCategory()?.description ||
("SHOP.CATEGORY_META"
| translate: { count: selectedCategory()?.productCount || 0 })
: ("SHOP.CUSTOM_PART_CTA" | translate)
}}
</p>
@@ -20,7 +22,9 @@
<app-card>
<div class="panel-head">
<div>
<p class="panel-kicker">{{ "SHOP.CATEGORY_PANEL_KICKER" | translate }}</p>
<p class="panel-kicker">
{{ "SHOP.CATEGORY_PANEL_KICKER" | translate }}
</p>
<h2 class="panel-title">
{{ "SHOP.CATEGORY_PANEL_TITLE" | translate }}
</h2>
@@ -100,7 +104,9 @@
<div class="qty-control">
<button
type="button"
[disabled]="cartMutating() && busyLineItemId() === item.id"
[disabled]="
cartMutating() && busyLineItemId() === item.id
"
(click)="decreaseQuantity(item)"
>
-
@@ -108,7 +114,9 @@
<span>{{ item.quantity }}</span>
<button
type="button"
[disabled]="cartMutating() && busyLineItemId() === item.id"
[disabled]="
cartMutating() && busyLineItemId() === item.id
"
(click)="increaseQuantity(item)"
>
+
@@ -134,7 +142,9 @@
<div class="cart-totals">
<div class="cart-total-row">
<span>{{ "SHOP.CART_SUBTOTAL" | translate }}</span>
<strong>{{ cart()?.itemsTotalChf || 0 | currency: "CHF" }}</strong>
<strong>{{
cart()?.itemsTotalChf || 0 | currency: "CHF"
}}</strong>
</div>
<div class="cart-total-row">
<span>{{ "SHOP.CART_SHIPPING" | translate }}</span>
@@ -202,7 +212,10 @@
</div>
} @else {
<div class="product-grid">
@for (product of products(); track trackByProduct($index, product)) {
@for (
product of products();
track trackByProduct($index, product)
) {
<app-product-card
[product]="product"
[cartQuantity]="productCartQuantity(product.id)"

View File

@@ -11,7 +11,15 @@ import {
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { catchError, combineLatest, finalize, forkJoin, of, switchMap, tap } from 'rxjs';
import {
catchError,
combineLatest,
finalize,
forkJoin,
of,
switchMap,
tap,
} from 'rxjs';
import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
@@ -68,7 +76,9 @@ export class ShopPageComponent {
() => this.selectedCategory()?.slug ?? this.categorySlug() ?? null,
);
readonly cartItems = computed(() =>
(this.cart()?.items ?? []).filter((item) => item.lineItemType === 'SHOP_PRODUCT'),
(this.cart()?.items ?? []).filter(
(item) => item.lineItemType === 'SHOP_PRODUCT',
),
);
readonly cartHasItems = computed(() => this.cartItems().length > 0);
@@ -86,7 +96,9 @@ export class ShopPageComponent {
combineLatest([
toObservable(this.categorySlug, { injector: this.injector }),
toObservable(this.languageService.currentLang, { injector: this.injector }),
toObservable(this.languageService.currentLang, {
injector: this.injector,
}),
])
.pipe(
tap(() => {
@@ -137,7 +149,9 @@ export class ShopPageComponent {
}
cartItemName(item: ShopCartItem): string {
return item.displayName || item.shopProductName || item.originalFilename || '-';
return (
item.displayName || item.shopProductName || item.originalFilename || '-'
);
}
cartItemVariant(item: ShopCartItem): string | null {
@@ -253,12 +267,14 @@ export class ShopPageComponent {
}
const title =
category.seoTitle || `${category.name} | ${this.translate.instant('SHOP.TITLE')} | 3D fab`;
category.seoTitle ||
`${category.name} | ${this.translate.instant('SHOP.TITLE')} | 3D fab`;
const description =
category.seoDescription ||
category.description ||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
const robots = category.indexable === false ? 'noindex, nofollow' : 'index, follow';
const robots =
category.indexable === false ? 'noindex, nofollow' : 'index, follow';
this.seoService.applyPageSeo({
title,

View File

@@ -35,7 +35,6 @@
margin: var(--space-6) auto;
color: var(--color-text-muted);
font-size: 1.25rem;
}
.ui-simple-hero__actions {