chore(front-end): new seo, and improvements in shop component
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<div class="checkout-page">
|
||||
<div class="container ui-page-hero">
|
||||
<div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
|
||||
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
|
||||
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
|
||||
Servizio CAD
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.checkout-hero {
|
||||
padding-top: calc(var(--space-12) + var(--space-4));
|
||||
}
|
||||
|
||||
.cad-subtitle {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -273,3 +277,9 @@ app-toggle-selector.user-type-selector-compact {
|
||||
.mb-6 {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.checkout-hero {
|
||||
padding-top: calc(var(--space-8) + var(--space-4));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<main class="home-page">
|
||||
<section class="hero">
|
||||
<div class="container hero-grid ui-content-grid ui-content-grid--spacious">
|
||||
<div
|
||||
class="container hero-grid ui-content-grid ui-content-grid--spacious ui-content-grid--split"
|
||||
>
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
|
||||
<h1
|
||||
@@ -25,6 +27,30 @@
|
||||
}}</app-button>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="hero-swiss-card">
|
||||
<div class="hero-swiss-head">
|
||||
<span class="hero-swiss-emblem" aria-hidden="true">
|
||||
<span class="hero-swiss-cross"></span>
|
||||
</span>
|
||||
<p class="hero-swiss-kicker ui-eyebrow ui-eyebrow--compact">
|
||||
{{ "HOME.HERO_SWISS_TITLE" | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="hero-swiss-copy">
|
||||
{{ "HOME.HERO_SWISS_COPY" | translate }}
|
||||
</p>
|
||||
<div class="hero-swiss-locations">
|
||||
<span class="hero-swiss-chip">{{
|
||||
"HOME.HERO_SWISS_LOCATION_1" | translate
|
||||
}}</span>
|
||||
<span class="hero-swiss-chip">{{
|
||||
"HOME.HERO_SWISS_LOCATION_2" | translate
|
||||
}}</span>
|
||||
<span class="hero-swiss-chip">{{
|
||||
"HOME.HERO_SWISS_LOCATION_3" | translate
|
||||
}}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -45,6 +45,99 @@
|
||||
animation: fadeUp 0.8s ease both;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-swiss-card {
|
||||
--swiss-red: #d52b1e;
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
width: min(100%, 340px);
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--swiss-red);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
animation: fadeUp 0.85s ease both;
|
||||
}
|
||||
|
||||
.hero-swiss-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.hero-swiss-emblem {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--swiss-red);
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.hero-swiss-cross {
|
||||
position: relative;
|
||||
width: 0.86rem;
|
||||
height: 0.86rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-swiss-cross::before,
|
||||
.hero-swiss-cross::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.hero-swiss-cross::before {
|
||||
width: 0.28rem;
|
||||
height: 100%;
|
||||
left: calc(50% - 0.14rem);
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.hero-swiss-cross::after {
|
||||
width: 100%;
|
||||
height: 0.28rem;
|
||||
left: 0;
|
||||
top: calc(50% - 0.14rem);
|
||||
}
|
||||
|
||||
.hero-swiss-kicker {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.hero-swiss-copy {
|
||||
margin: 0 0 0.7rem;
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hero-swiss-locations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.hero-swiss-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.75rem;
|
||||
padding: 0.2rem 0.58rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(14, 24, 38, 0.14);
|
||||
background: #fff;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: #2a2f36;
|
||||
}
|
||||
|
||||
.capabilities {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
@@ -165,6 +258,13 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero-swiss-card {
|
||||
align-self: start;
|
||||
justify-self: center;
|
||||
width: min(100%, 340px);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.shop-gallery {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
|
||||
@@ -68,9 +68,12 @@
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<div class="payment-layout ui-two-column-layout">
|
||||
<div class="payment-main">
|
||||
<app-card class="mb-6" *ngIf="o.status === 'PENDING_PAYMENT'">
|
||||
<div
|
||||
class="payment-layout ui-two-column-layout"
|
||||
[class.payment-layout--summary-only]="o.status !== 'PENDING_PAYMENT'"
|
||||
>
|
||||
<div class="payment-main" *ngIf="o.status === 'PENDING_PAYMENT'">
|
||||
<app-card class="mb-6">
|
||||
<div class="ui-card-header">
|
||||
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
|
||||
</div>
|
||||
@@ -174,69 +177,6 @@
|
||||
</app-button>
|
||||
</div>
|
||||
</app-card>
|
||||
|
||||
<app-card class="mb-6">
|
||||
<div class="ui-card-header">
|
||||
<h3 class="ui-card-title">{{ "ORDER.ITEMS_TITLE" | translate }}</h3>
|
||||
<p class="ui-card-subtitle">
|
||||
{{ orderKindLabel(o) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="order-items">
|
||||
<div class="order-item" *ngFor="let item of o.items || []">
|
||||
<div class="order-item-copy">
|
||||
<div class="order-item-name-row">
|
||||
<strong class="order-item-name">{{
|
||||
itemDisplayName(item)
|
||||
}}</strong>
|
||||
<span
|
||||
class="order-item-kind"
|
||||
[class.order-item-kind-shop]="isShopItem(item)"
|
||||
>
|
||||
{{
|
||||
isShopItem(item)
|
||||
? ("ORDER.TYPE_SHOP" | translate)
|
||||
: ("ORDER.TYPE_CALCULATOR" | translate)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="order-item-meta">
|
||||
<span
|
||||
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
|
||||
>
|
||||
<span *ngIf="showItemMaterial(item)">
|
||||
{{ "CHECKOUT.MATERIAL" | translate }}:
|
||||
{{
|
||||
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
|
||||
}}
|
||||
</span>
|
||||
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
||||
{{ "SHOP.VARIANT" | translate }}: {{ variantLabel }}
|
||||
</span>
|
||||
<span class="item-color-chip">
|
||||
<span
|
||||
class="color-swatch"
|
||||
*ngIf="itemColorHex(item) as colorHex"
|
||||
[style.background-color]="colorHex"
|
||||
></span>
|
||||
<span>{{ itemColorLabel(item) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="order-item-tech" *ngIf="showItemPrintMetrics(item)">
|
||||
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
|
||||
{{ item.materialGrams || 0 | number: "1.0-0" }}g
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<strong class="order-item-total">
|
||||
{{ item.lineTotalChf || 0 | currency: "CHF" }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
|
||||
<div class="payment-summary">
|
||||
@@ -271,6 +211,70 @@
|
||||
[currency]="'CHF'"
|
||||
[totalLabelKey]="'PAYMENT.TOTAL'"
|
||||
></app-price-breakdown>
|
||||
|
||||
<div class="summary-items-section" *ngIf="(o.items || []).length > 0">
|
||||
<div class="summary-items-head">
|
||||
<h4>{{ "ORDER.ITEMS_TITLE" | translate }}</h4>
|
||||
<span>{{ (o.items || []).length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="order-items">
|
||||
<div class="order-item" *ngFor="let item of o.items || []">
|
||||
<div class="order-item-copy">
|
||||
<div class="order-item-name-row">
|
||||
<strong class="order-item-name">{{
|
||||
itemDisplayName(item)
|
||||
}}</strong>
|
||||
<span
|
||||
class="order-item-kind"
|
||||
[class.order-item-kind-shop]="isShopItem(item)"
|
||||
>
|
||||
{{
|
||||
isShopItem(item)
|
||||
? ("ORDER.TYPE_SHOP" | translate)
|
||||
: ("ORDER.TYPE_CALCULATOR" | translate)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="order-item-meta">
|
||||
<span
|
||||
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
|
||||
>
|
||||
<span *ngIf="showItemMaterial(item)">
|
||||
{{ "CHECKOUT.MATERIAL" | translate }}:
|
||||
{{
|
||||
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
|
||||
}}
|
||||
</span>
|
||||
<span *ngIf="itemVariantLabel(item) as variantLabel">
|
||||
{{ "SHOP.VARIANT" | translate }}: {{ variantLabel }}
|
||||
</span>
|
||||
<span class="item-color-chip">
|
||||
<span
|
||||
class="color-swatch"
|
||||
*ngIf="itemColorHex(item) as colorHex"
|
||||
[style.background-color]="colorHex"
|
||||
></span>
|
||||
<span>{{ itemColorLabel(item) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="order-item-tech"
|
||||
*ngIf="showItemPrintMetrics(item)"
|
||||
>
|
||||
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
|
||||
{{ item.materialGrams || 0 | number: "1.0-0" }}g
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<strong class="order-item-total">
|
||||
{{ item.lineTotalChf || 0 | currency: "CHF" }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</app-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.payment-layout--summary-only {
|
||||
grid-template-columns: minmax(0, 440px);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.payment-details {
|
||||
margin-bottom: var(--space-6);
|
||||
|
||||
@@ -119,9 +124,52 @@
|
||||
top: var(--space-6);
|
||||
}
|
||||
|
||||
.payment-summary {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.summary-items-section {
|
||||
margin-top: var(--space-6);
|
||||
padding-top: var(--space-5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.summary-items-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-4);
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.8rem;
|
||||
min-height: 1.8rem;
|
||||
padding: 0 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(16, 24, 32, 0.06);
|
||||
color: var(--color-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.order-items {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
gap: var(--space-2);
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
padding-right: var(--space-1);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
@@ -129,7 +177,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
padding: 0.85rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-card);
|
||||
@@ -149,7 +197,7 @@
|
||||
}
|
||||
|
||||
.order-item-name {
|
||||
font-size: 1rem;
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -176,7 +224,7 @@
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.92rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.item-color-chip {
|
||||
@@ -194,13 +242,13 @@
|
||||
}
|
||||
|
||||
.order-item-tech {
|
||||
font-size: 0.86rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.order-item-total {
|
||||
white-space: nowrap;
|
||||
font-size: 1rem;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.order-summary-meta {
|
||||
@@ -325,6 +373,10 @@
|
||||
padding-top: calc(var(--space-8) + var(--space-4));
|
||||
}
|
||||
|
||||
.payment-layout--summary-only {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-timeline {
|
||||
margin-top: var(--space-4);
|
||||
margin-bottom: var(--space-8);
|
||||
@@ -362,4 +414,10 @@
|
||||
.order-summary-meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.order-items {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
.media {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 1 / 1;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: #f2eee5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -15,18 +15,23 @@
|
||||
} @else {
|
||||
@if (product(); as p) {
|
||||
<nav class="breadcrumbs">
|
||||
<a [routerLink]="shopRootLink()">{{
|
||||
<a class="breadcrumbs__item" [routerLink]="shopRootLink()">{{
|
||||
"SHOP.BREADCRUMB_ROOT" | translate
|
||||
}}</a>
|
||||
@for (crumb of p.breadcrumbs; track crumb.id) {
|
||||
<span>/</span>
|
||||
<a [routerLink]="categoryLink(crumb.slug)">{{ crumb.name }}</a>
|
||||
<span class="breadcrumbs__separator">/</span>
|
||||
<a class="breadcrumbs__item" [routerLink]="categoryLink(crumb.slug)"
|
||||
>{{ crumb.name }}</a
|
||||
>
|
||||
}
|
||||
</nav>
|
||||
|
||||
<div class="detail-grid">
|
||||
<section class="visual-column">
|
||||
<div class="hero-media">
|
||||
<div
|
||||
class="hero-media"
|
||||
[class.hero-media--portrait]="selectedImageIsPortrait()"
|
||||
>
|
||||
@if (galleryImages().length > 1) {
|
||||
<button
|
||||
type="button"
|
||||
@@ -51,6 +56,7 @@
|
||||
[src]="imageUrl"
|
||||
[alt]="selectedImage().altText || p.name"
|
||||
class="hero-image"
|
||||
(load)="onHeroImageLoad($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="image-fallback">
|
||||
@@ -129,13 +135,29 @@
|
||||
|
||||
<app-card class="purchase-shell">
|
||||
<div class="purchase-card">
|
||||
<div class="price-row">
|
||||
<div>
|
||||
<div class="offer-header">
|
||||
<div class="offer-price">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||
{{ "SHOP.PRICE_LABEL" | translate }}
|
||||
</p>
|
||||
<h3>{{ priceLabel() | currency: "CHF" }}</h3>
|
||||
@if (selectedVariant(); as activeVariant) {
|
||||
<p class="offer-caption">
|
||||
@if (selectedMaterial()?.label) {
|
||||
<span>{{ selectedMaterial()?.label }}</span>
|
||||
}
|
||||
@if (
|
||||
colorLabel(activeVariant) !== selectedMaterial()?.label
|
||||
) {
|
||||
@if (selectedMaterial()?.label) {
|
||||
<span aria-hidden="true">·</span>
|
||||
}
|
||||
<span>{{ colorLabel(activeVariant) }}</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedVariantCartQuantity() > 0) {
|
||||
<span class="cart-pill">
|
||||
{{
|
||||
@@ -148,32 +170,58 @@
|
||||
</div>
|
||||
|
||||
@if (materialOptions().length > 1) {
|
||||
<div class="material-grid">
|
||||
@for (material of materialOptions(); track material.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="material-option"
|
||||
[class.active]="
|
||||
selectedMaterial()?.key === material.key
|
||||
"
|
||||
(click)="selectMaterial(material.key)"
|
||||
>
|
||||
<span class="material-copy">
|
||||
<strong>{{ material.label }}</strong>
|
||||
<small>
|
||||
{{
|
||||
"SHOP.MATERIAL_COLOR_COUNT"
|
||||
| translate
|
||||
: { count: materialColorCount(material) }
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<strong>{{
|
||||
materialPriceLabel(material) | currency: "CHF"
|
||||
}}</strong>
|
||||
</button>
|
||||
}
|
||||
<div class="material-section">
|
||||
<div class="selector-head">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="material-grid">
|
||||
@for (material of materialOptions(); track material.key) {
|
||||
<button
|
||||
type="button"
|
||||
class="material-option"
|
||||
[class.active]="
|
||||
selectedMaterial()?.key === material.key
|
||||
"
|
||||
(click)="selectMaterial(material.key)"
|
||||
>
|
||||
<span class="material-copy">
|
||||
<strong>{{ material.label }}</strong>
|
||||
<small>
|
||||
{{
|
||||
"SHOP.MATERIAL_COLOR_COUNT"
|
||||
| translate
|
||||
: { count: materialColorCount(material) }
|
||||
}}
|
||||
</small>
|
||||
</span>
|
||||
<strong>{{
|
||||
materialPriceLabel(material) | currency: "CHF"
|
||||
}}</strong>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
@if (selectedMaterial(); as material) {
|
||||
<div class="material-summary">
|
||||
<div class="material-summary__copy">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_MATERIAL" | translate }}
|
||||
</p>
|
||||
<strong>{{ material.label }}</strong>
|
||||
<small>
|
||||
{{
|
||||
"SHOP.MATERIAL_COLOR_COUNT"
|
||||
| translate
|
||||
: { count: materialColorCount(material) }
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (
|
||||
@@ -196,90 +244,95 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="color-selector-block">
|
||||
<div class="selector-head">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_COLOR" | translate }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="selector-layout">
|
||||
<div class="selector-card color-selector-block">
|
||||
<div class="selector-head">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.SELECT_COLOR" | translate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (selectedVariant(); as activeVariant) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-trigger"
|
||||
[class.open]="colorPopupOpen()"
|
||||
(click)="toggleColorPopup()"
|
||||
>
|
||||
<span class="color-trigger__ring">
|
||||
<span
|
||||
class="color-trigger__swatch"
|
||||
[style.background-color]="colorHex(activeVariant)"
|
||||
></span>
|
||||
</span>
|
||||
@if (selectedVariant(); as activeVariant) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-trigger"
|
||||
[class.open]="colorPopupOpen()"
|
||||
(click)="toggleColorPopup()"
|
||||
>
|
||||
<span class="color-trigger__ring">
|
||||
<span
|
||||
class="color-trigger__swatch"
|
||||
[style.background-color]="colorHex(activeVariant)"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
<span class="color-trigger__copy">
|
||||
<strong>{{ colorLabel(activeVariant) }}</strong>
|
||||
<small>{{ selectedMaterial()?.label }}</small>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
<span class="color-trigger__copy">
|
||||
<strong>{{ colorLabel(activeVariant) }}</strong>
|
||||
<small>{{ selectedMaterial()?.label }}</small>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (colorPopupOpen()) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-popup-backdrop"
|
||||
(click)="closeColorPopup()"
|
||||
></button>
|
||||
<div class="color-popup">
|
||||
<div class="color-popup__category">
|
||||
{{ selectedMaterial()?.label || "" | uppercase }}
|
||||
</div>
|
||||
@if (colorPopupOpen()) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-popup-backdrop"
|
||||
(click)="closeColorPopup()"
|
||||
></button>
|
||||
<div class="color-popup">
|
||||
<div class="color-popup__category">
|
||||
{{ selectedMaterial()?.label || "" | uppercase }}
|
||||
</div>
|
||||
|
||||
<div class="color-popup__grid">
|
||||
@for (variant of colorOptions(); track variant.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-popup__item"
|
||||
(click)="selectVariant(variant)"
|
||||
>
|
||||
<span
|
||||
class="color-popup__ring"
|
||||
[class.active]="
|
||||
selectedVariant()?.id === variant.id
|
||||
"
|
||||
<div class="color-popup__grid">
|
||||
@for (variant of colorOptions(); track variant.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="color-popup__item"
|
||||
(click)="selectVariant(variant)"
|
||||
>
|
||||
<span
|
||||
class="color-popup__swatch"
|
||||
[style.background-color]="colorHex(variant)"
|
||||
></span>
|
||||
</span>
|
||||
class="color-popup__ring"
|
||||
[class.active]="
|
||||
selectedVariant()?.id === variant.id
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="color-popup__swatch"
|
||||
[style.background-color]="colorHex(variant)"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
<span class="color-popup__name">{{
|
||||
colorLabel(variant)
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
<span class="color-popup__name">{{
|
||||
colorLabel(variant)
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="quantity-row">
|
||||
<span>{{ "SHOP.QUANTITY" | translate }}</span>
|
||||
<div class="qty-control">
|
||||
<button type="button" (click)="decreaseQuantity()">
|
||||
-
|
||||
</button>
|
||||
<span>{{ quantity() }}</span>
|
||||
<button type="button" (click)="increaseQuantity()">
|
||||
+
|
||||
</button>
|
||||
<div class="selector-card quantity-card">
|
||||
<p class="panel-kicker">
|
||||
{{ "SHOP.QUANTITY" | translate }}
|
||||
</p>
|
||||
<div class="qty-control">
|
||||
<button type="button" (click)="decreaseQuantity()">
|
||||
-
|
||||
</button>
|
||||
<span>{{ quantity() }}</span>
|
||||
<button type="button" (click)="increaseQuantity()">
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<app-button
|
||||
variant="primary"
|
||||
[fullWidth]="true"
|
||||
[disabled]="isAddingToCart()"
|
||||
(click)="addToCart()"
|
||||
>
|
||||
@@ -290,7 +343,11 @@
|
||||
</app-button>
|
||||
|
||||
@if (shopService.cartItemCount() > 0) {
|
||||
<app-button variant="outline" (click)="goToCheckout()">
|
||||
<app-button
|
||||
variant="outline"
|
||||
[fullWidth]="true"
|
||||
(click)="goToCheckout()"
|
||||
>
|
||||
{{ "SHOP.GO_TO_CHECKOUT" | translate }}
|
||||
</app-button>
|
||||
}
|
||||
|
||||
@@ -18,15 +18,51 @@
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.breadcrumbs__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 1.9rem;
|
||||
padding: 0.26rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(16, 24, 32, 0.1);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
color: var(--color-secondary-600);
|
||||
font-weight: 600;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
background 0.18s ease,
|
||||
color 0.18s ease;
|
||||
}
|
||||
|
||||
.breadcrumbs__item:hover {
|
||||
color: var(--color-text);
|
||||
border-color: rgba(16, 24, 32, 0.18);
|
||||
background: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumbs__separator {
|
||||
color: rgba(81, 77, 67, 0.64);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
@@ -53,9 +89,8 @@
|
||||
|
||||
.hero-media {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
min-height: 420px;
|
||||
max-height: 620px;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||
@@ -67,14 +102,18 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
background: #f2eee5;
|
||||
}
|
||||
|
||||
.hero-media--portrait .hero-image {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-fallback {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 420px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: var(--space-6);
|
||||
@@ -111,8 +150,8 @@
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex: 0 0 92px;
|
||||
height: 92px;
|
||||
flex: 0 0 96px;
|
||||
aspect-ratio: 4 / 3;
|
||||
overflow: hidden;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||
@@ -226,15 +265,34 @@ h1 {
|
||||
|
||||
.purchase-card {
|
||||
display: grid;
|
||||
gap: 0.78rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.price-row,
|
||||
.quantity-row {
|
||||
.offer-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.offer-price {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.offer-price h3 {
|
||||
font-size: clamp(1.9rem, 1.5vw + 1.15rem, 2.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.offer-caption {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.cart-pill {
|
||||
@@ -249,6 +307,11 @@ h1 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.material-section {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.material-grid {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
@@ -301,6 +364,31 @@ h1 {
|
||||
font-size: 1.04rem;
|
||||
}
|
||||
|
||||
.material-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.material-summary__copy {
|
||||
display: grid;
|
||||
gap: 0.16rem;
|
||||
}
|
||||
|
||||
.material-summary__copy strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.material-summary__copy small {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.property-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -340,11 +428,30 @@ h1 {
|
||||
border-left: 3px solid rgba(245, 158, 11, 0.7);
|
||||
}
|
||||
|
||||
.selector-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(210px, 0.8fr);
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.selector-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0.82rem 0.9rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.qty-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.2rem;
|
||||
min-height: 3.2rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
@@ -366,10 +473,20 @@ h1 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.quantity-card {
|
||||
justify-items: start;
|
||||
align-content: start;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
|
||||
.quantity-card .qty-control {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.success-note {
|
||||
@@ -459,6 +576,10 @@ h1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.selector-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.property-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -469,15 +590,14 @@ h1 {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.price-row,
|
||||
.quantity-row {
|
||||
.offer-header,
|
||||
.material-summary {
|
||||
flex-direction: column;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hero-media,
|
||||
.image-fallback {
|
||||
min-height: 300px;
|
||||
.hero-media--portrait .hero-image {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-strip {
|
||||
@@ -485,8 +605,7 @@ h1 {
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex-basis: 78px;
|
||||
height: 78px;
|
||||
flex-basis: 84px;
|
||||
}
|
||||
|
||||
.model-launch-row {
|
||||
@@ -514,6 +633,10 @@ h1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.selector-card {
|
||||
padding: 0.74rem 0.78rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep app-card.purchase-shell .card-body {
|
||||
padding: 0.82rem 0.82rem;
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ export class ProductDetailComponent {
|
||||
readonly product = signal<ShopProductDetail | null>(null);
|
||||
readonly selectedVariantId = signal<string | null>(null);
|
||||
readonly selectedImageAssetId = signal<string | null>(null);
|
||||
readonly selectedImageOrientation = signal<
|
||||
'portrait' | 'landscape' | 'square' | null
|
||||
>(null);
|
||||
readonly quantity = signal(1);
|
||||
readonly isAddingToCart = signal(false);
|
||||
readonly addSuccess = signal(false);
|
||||
@@ -191,6 +194,9 @@ export class ProductDetailComponent {
|
||||
readonly selectedVariantCartQuantity = computed(() =>
|
||||
this.shopService.quantityForVariant(this.selectedVariant()?.id),
|
||||
);
|
||||
readonly selectedImageIsPortrait = computed(
|
||||
() => this.selectedImageOrientation() === 'portrait',
|
||||
);
|
||||
|
||||
constructor() {
|
||||
if (!this.shopService.cartLoaded()) {
|
||||
@@ -230,7 +236,7 @@ export class ProductDetailComponent {
|
||||
catchError((error) => {
|
||||
this.product.set(null);
|
||||
this.selectedVariantId.set(null);
|
||||
this.selectedImageAssetId.set(null);
|
||||
this.setSelectedImageAssetId(null);
|
||||
this.modelFile.set(null);
|
||||
this.error.set(
|
||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
||||
@@ -257,7 +263,7 @@ export class ProductDetailComponent {
|
||||
product.defaultVariant ?? product.variants[0] ?? null,
|
||||
),
|
||||
);
|
||||
this.selectedImageAssetId.set(
|
||||
this.setSelectedImageAssetId(
|
||||
product.primaryImage?.mediaAssetId ??
|
||||
product.images[0]?.mediaAssetId ??
|
||||
null,
|
||||
@@ -283,7 +289,7 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
selectImage(mediaAssetId: string): void {
|
||||
this.selectedImageAssetId.set(mediaAssetId);
|
||||
this.setSelectedImageAssetId(mediaAssetId);
|
||||
}
|
||||
|
||||
showPreviousImage(): void {
|
||||
@@ -293,7 +299,7 @@ export class ProductDetailComponent {
|
||||
}
|
||||
const nextIndex =
|
||||
(this.selectedImageIndex() - 1 + images.length) % images.length;
|
||||
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
|
||||
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
|
||||
}
|
||||
|
||||
showNextImage(): void {
|
||||
@@ -302,7 +308,26 @@ export class ProductDetailComponent {
|
||||
return;
|
||||
}
|
||||
const nextIndex = (this.selectedImageIndex() + 1) % images.length;
|
||||
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId);
|
||||
this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
|
||||
}
|
||||
|
||||
onHeroImageLoad(event: Event): void {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLImageElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.naturalHeight > target.naturalWidth) {
|
||||
this.selectedImageOrientation.set('portrait');
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.naturalWidth > target.naturalHeight) {
|
||||
this.selectedImageOrientation.set('landscape');
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedImageOrientation.set('square');
|
||||
}
|
||||
|
||||
selectVariant(variant: ShopProductVariantOption): void {
|
||||
@@ -479,6 +504,11 @@ export class ProductDetailComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private setSelectedImageAssetId(mediaAssetId: string | null): void {
|
||||
this.selectedImageAssetId.set(mediaAssetId);
|
||||
this.selectedImageOrientation.set(null);
|
||||
}
|
||||
|
||||
private normalizeHexColor(value: string | null | undefined): string | null {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) {
|
||||
|
||||
Reference in New Issue
Block a user