boooomb
This commit is contained in:
3
API.md
3
API.md
@@ -230,6 +230,7 @@ Response 200:
|
|||||||
"currency": "USD",
|
"currency": "USD",
|
||||||
"imgs": ["https://...", "https://..."],
|
"imgs": ["https://...", "https://..."],
|
||||||
"tags": ["new", "featured"],
|
"tags": ["new", "featured"],
|
||||||
|
"badges": ["new", "exclusive"],
|
||||||
"simpleDescription": "Latest iPhone...",
|
"simpleDescription": "Latest iPhone...",
|
||||||
"description": [
|
"description": [
|
||||||
{ "key": "Color", "value": "Black" },
|
{ "key": "Color", "value": "Black" },
|
||||||
@@ -275,6 +276,7 @@ Body:
|
|||||||
"currency": "USD", // USD | EUR | RUB | GBP | UAH
|
"currency": "USD", // USD | EUR | RUB | GBP | UAH
|
||||||
"imgs": ["https://..."],
|
"imgs": ["https://..."],
|
||||||
"tags": ["new"],
|
"tags": ["new"],
|
||||||
|
"badges": ["new", "exclusive"], // optional - predefined or custom badge labels
|
||||||
"simpleDescription": "Short description",
|
"simpleDescription": "Short description",
|
||||||
"description": [
|
"description": [
|
||||||
{ "key": "Size", "value": "Large" }
|
{ "key": "Size", "value": "Large" }
|
||||||
@@ -364,6 +366,7 @@ Response 201:
|
|||||||
- Use `PATCH` for partial updates - send only the fields you want to change.
|
- Use `PATCH` for partial updates - send only the fields you want to change.
|
||||||
- `priority`: lower number = appears first in the list.
|
- `priority`: lower number = appears first in the list.
|
||||||
- `currency` supported values: `USD`, `EUR`, `RUB`, `GBP`, `UAH`.
|
- `currency` supported values: `USD`, `EUR`, `RUB`, `GBP`, `UAH`.
|
||||||
|
- `badges`: optional string array. Predefined values with UI colors: `new`, `sale`, `exclusive`, `hot`, `limited`, `bestseller`, `featured`. Custom strings are also allowed.
|
||||||
- `imgs`: always send the **complete** array on update, not individual images.
|
- `imgs`: always send the **complete** array on update, not individual images.
|
||||||
- `description`: array of `{ key, value }` pairs - free-form attributes per item.
|
- `description`: array of `{ key, value }` pairs - free-form attributes per item.
|
||||||
- Auto-save from the backoffice fires `PATCH` with a single field every ~500 ms.
|
- Auto-save from the backoffice fires `PATCH` with a single field every ~500 ms.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CategoryEditorComponent } from './pages/category-editor/category-editor
|
|||||||
import { SubcategoryEditorComponent } from './pages/subcategory-editor/subcategory-editor.component';
|
import { SubcategoryEditorComponent } from './pages/subcategory-editor/subcategory-editor.component';
|
||||||
import { ItemsListComponent } from './pages/items-list/items-list.component';
|
import { ItemsListComponent } from './pages/items-list/items-list.component';
|
||||||
import { ItemEditorComponent } from './pages/item-editor/item-editor.component';
|
import { ItemEditorComponent } from './pages/item-editor/item-editor.component';
|
||||||
|
import { ItemPreviewComponent } from './pages/item-preview/item-preview.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -30,6 +31,10 @@ export const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'item/:itemId',
|
path: 'item/:itemId',
|
||||||
component: ItemEditorComponent
|
component: ItemEditorComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'item/:itemId/preview',
|
||||||
|
component: ItemPreviewComponent
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
|
|
||||||
export interface CreateDialogData {
|
export interface CreateDialogData {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -13,10 +14,11 @@ export interface CreateDialogData {
|
|||||||
fields: {
|
fields: {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'text' | 'number' | 'toggle';
|
type: 'text' | 'number' | 'toggle' | 'select';
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
value?: any;
|
value?: any;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
options?: { value: any; label: string }[];
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,8 @@ export interface CreateDialogData {
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatSlideToggleModule
|
MatSlideToggleModule,
|
||||||
|
MatSelectModule
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||||
@@ -43,6 +46,19 @@ export interface CreateDialogData {
|
|||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</mat-slide-toggle>
|
</mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
|
} @else if (field.type === 'select') {
|
||||||
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
|
<mat-label>{{ field.label }}</mat-label>
|
||||||
|
<mat-select [(ngModel)]="formData[field.name]" [required]="!!field.required">
|
||||||
|
@for (opt of field.options || []; track opt.value) {
|
||||||
|
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
@if (field.hint) { <mat-hint>{{ field.hint }}</mat-hint> }
|
||||||
|
@if (field.required && !formData[field.name]) {
|
||||||
|
<mat-error>{{ field.label }} is required</mat-error>
|
||||||
|
}
|
||||||
|
</mat-form-field>
|
||||||
} @else {
|
} @else {
|
||||||
<mat-form-field appearance="outline" class="full-width">
|
<mat-form-field appearance="outline" class="full-width">
|
||||||
<mat-label>{{ field.label }}</mat-label>
|
<mat-label>{{ field.label }}</mat-label>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Item {
|
|||||||
currency: string;
|
currency: string;
|
||||||
imgs: string[];
|
imgs: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
badges?: string[];
|
||||||
simpleDescription: string;
|
simpleDescription: string;
|
||||||
description: ItemDescriptionField[];
|
description: ItemDescriptionField[];
|
||||||
subcategoryId: string;
|
subcategoryId: string;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-container">
|
<app-loading-skeleton type="form"></app-loading-skeleton>
|
||||||
<mat-spinner></mat-spinner>
|
|
||||||
</div>
|
|
||||||
} @else if (category()) {
|
} @else if (category()) {
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<button mat-icon-button (click)="goBack()">
|
<button mat-icon-button (click)="goBack()">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.editor-container {
|
.editor-container {
|
||||||
max-width: 800px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { MatListModule } from '@angular/material/list';
|
|||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
import { Category } from '../../models';
|
import { Category } from '../../models';
|
||||||
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
|
|||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatListModule,
|
MatListModule,
|
||||||
MatDialogModule
|
MatDialogModule,
|
||||||
|
LoadingSkeletonComponent
|
||||||
],
|
],
|
||||||
templateUrl: './category-editor.component.html',
|
templateUrl: './category-editor.component.html',
|
||||||
styleUrls: ['./category-editor.component.scss']
|
styleUrls: ['./category-editor.component.scss']
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-container">
|
<app-loading-skeleton type="form"></app-loading-skeleton>
|
||||||
<mat-spinner></mat-spinner>
|
|
||||||
</div>
|
|
||||||
} @else if (item()) {
|
} @else if (item()) {
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
@@ -211,6 +209,64 @@
|
|||||||
</div>
|
</div>
|
||||||
</mat-tab>
|
</mat-tab>
|
||||||
|
|
||||||
|
<!-- Badges Tab -->
|
||||||
|
<mat-tab label="Badges">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="badges-section">
|
||||||
|
<h3>Predefined Badges</h3>
|
||||||
|
<p class="hint">Toggle badges to highlight this item in the marketplace.</p>
|
||||||
|
<div class="predefined-badges">
|
||||||
|
@for (badge of predefinedBadges; track badge.value) {
|
||||||
|
<button
|
||||||
|
mat-stroked-button
|
||||||
|
class="badge-toggle"
|
||||||
|
[class.active]="hasBadge(badge.value)"
|
||||||
|
[style.border-color]="badge.color"
|
||||||
|
[style.color]="hasBadge(badge.value) ? '#fff' : badge.color"
|
||||||
|
[style.background-color]="hasBadge(badge.value) ? badge.color : 'transparent'"
|
||||||
|
(click)="toggleBadge(badge.value)">
|
||||||
|
{{ badge.label }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin-top: 1.5rem">Custom Badges</h3>
|
||||||
|
<div class="add-badge-form">
|
||||||
|
<mat-form-field appearance="outline" class="badge-input">
|
||||||
|
<mat-label>Custom Badge</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
[(ngModel)]="newBadge"
|
||||||
|
(keyup.enter)="addCustomBadge()"
|
||||||
|
placeholder="e.g. pre-order">
|
||||||
|
</mat-form-field>
|
||||||
|
<button mat-raised-button color="primary" (click)="addCustomBadge()">
|
||||||
|
<mat-icon>add</mat-icon>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="badges-list">
|
||||||
|
@for (badge of (item()!.badges || []); track $index) {
|
||||||
|
<mat-chip>
|
||||||
|
{{ badge }}
|
||||||
|
<button matChipRemove (click)="removeBadge($index)">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
</button>
|
||||||
|
</mat-chip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!(item()!.badges?.length)) {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>new_releases</mat-icon>
|
||||||
|
<p>No badges yet</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-tab>
|
||||||
|
|
||||||
<!-- Detailed Description Tab -->
|
<!-- Detailed Description Tab -->
|
||||||
<mat-tab label="Description">
|
<mat-tab label="Description">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
|
|||||||
@@ -219,6 +219,59 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Badges Tab
|
||||||
|
.badges-section {
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 0 0 1.5rem 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.predefined-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.badge-toggle {
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 14px;
|
||||||
|
height: 32px;
|
||||||
|
line-height: 32px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-badge-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
.badge-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
mat-chip {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Description Tab
|
// Description Tab
|
||||||
.description-section {
|
.description-section {
|
||||||
h3 {
|
h3 {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Component, OnInit, signal } from '@angular/core';
|
import { Component, OnInit, signal } from '@angular/core';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { environment } from '../../../environments/environment';
|
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
@@ -19,6 +18,7 @@ import { ApiService } from '../../services';
|
|||||||
import { ValidationService } from '../../services/validation.service';
|
import { ValidationService } from '../../services/validation.service';
|
||||||
import { Item, ItemDescriptionField, Subcategory } from '../../models';
|
import { Item, ItemDescriptionField, Subcategory } from '../../models';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-item-editor',
|
selector: 'app-item-editor',
|
||||||
@@ -37,7 +37,8 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
|
|||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatTabsModule,
|
MatTabsModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
DragDropModule
|
DragDropModule,
|
||||||
|
LoadingSkeletonComponent
|
||||||
],
|
],
|
||||||
templateUrl: './item-editor.component.html',
|
templateUrl: './item-editor.component.html',
|
||||||
styleUrls: ['./item-editor.component.scss']
|
styleUrls: ['./item-editor.component.scss']
|
||||||
@@ -58,6 +59,18 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
|
|
||||||
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
currencies = ['USD', 'EUR', 'RUB', 'GBP', 'UAH'];
|
||||||
|
|
||||||
|
predefinedBadges: { label: string; value: string; color: string }[] = [
|
||||||
|
{ label: 'New', value: 'new', color: '#009688' },
|
||||||
|
{ label: 'Sale', value: 'sale', color: '#e53935' },
|
||||||
|
{ label: 'Exclusive', value: 'exclusive', color: '#7b1fa2' },
|
||||||
|
{ label: 'Hot', value: 'hot', color: '#f4511e' },
|
||||||
|
{ label: 'Limited', value: 'limited', color: '#f9a825' },
|
||||||
|
{ label: 'Bestseller', value: 'bestseller', color: '#1976d2' },
|
||||||
|
{ label: 'Featured', value: 'featured', color: '#3949ab' },
|
||||||
|
];
|
||||||
|
|
||||||
|
newBadge = '';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -109,48 +122,6 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async buildCategoryPath(): Promise<string> {
|
|
||||||
// Build path like: /category/subcategory/subsubcategory/item
|
|
||||||
const item = this.item();
|
|
||||||
if (!item) return '';
|
|
||||||
|
|
||||||
const pathSegments: string[] = [];
|
|
||||||
let currentSubcategoryId = item.subcategoryId;
|
|
||||||
|
|
||||||
// Traverse up the subcategory hierarchy
|
|
||||||
while (currentSubcategoryId) {
|
|
||||||
try {
|
|
||||||
const subcategory = await this.apiService.getSubcategory(currentSubcategoryId).toPromise();
|
|
||||||
if (!subcategory) break;
|
|
||||||
|
|
||||||
pathSegments.unshift(subcategory.id); // Add to beginning
|
|
||||||
|
|
||||||
// parentId = direct parent (another subcategory or the root category)
|
|
||||||
// categoryId = always the root category
|
|
||||||
const isDirectCategoryChild =
|
|
||||||
!subcategory.parentId || subcategory.parentId === subcategory.categoryId;
|
|
||||||
|
|
||||||
if (isDirectCategoryChild) {
|
|
||||||
// This subcategory sits directly under a root category
|
|
||||||
const category = await this.apiService.getCategory(subcategory.categoryId).toPromise();
|
|
||||||
if (category) {
|
|
||||||
pathSegments.unshift(category.id);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
// Still inside a parent subcategory — keep traversing up
|
|
||||||
currentSubcategoryId = subcategory.parentId!;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error building path:', err);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pathSegments.push(this.itemId());
|
|
||||||
return '/' + pathSegments.join('/');
|
|
||||||
}
|
|
||||||
|
|
||||||
onFieldChange(field: keyof Item, value: any) {
|
onFieldChange(field: keyof Item, value: any) {
|
||||||
const currentItem = this.item();
|
const currentItem = this.item();
|
||||||
if (!currentItem) return;
|
if (!currentItem) return;
|
||||||
@@ -239,6 +210,48 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Badges handling
|
||||||
|
hasBadge(value: string): boolean {
|
||||||
|
return (this.item()?.badges || []).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleBadge(value: string) {
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (!currentItem) return;
|
||||||
|
const badges = [...(currentItem.badges || [])];
|
||||||
|
const idx = badges.indexOf(value);
|
||||||
|
if (idx > -1) {
|
||||||
|
badges.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
badges.push(value);
|
||||||
|
}
|
||||||
|
currentItem.badges = badges;
|
||||||
|
this.onFieldChange('badges', badges);
|
||||||
|
}
|
||||||
|
|
||||||
|
addCustomBadge() {
|
||||||
|
const badge = this.newBadge.trim().toLowerCase();
|
||||||
|
if (!badge) return;
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (!currentItem) return;
|
||||||
|
const badges = [...(currentItem.badges || [])];
|
||||||
|
if (!badges.includes(badge)) {
|
||||||
|
badges.push(badge);
|
||||||
|
currentItem.badges = badges;
|
||||||
|
this.onFieldChange('badges', badges);
|
||||||
|
}
|
||||||
|
this.newBadge = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
removeBadge(index: number) {
|
||||||
|
const currentItem = this.item();
|
||||||
|
if (!currentItem) return;
|
||||||
|
const badges = [...(currentItem.badges || [])];
|
||||||
|
badges.splice(index, 1);
|
||||||
|
currentItem.badges = badges;
|
||||||
|
this.onFieldChange('badges', badges);
|
||||||
|
}
|
||||||
|
|
||||||
// Description fields handling
|
// Description fields handling
|
||||||
addDescriptionField() {
|
addDescriptionField() {
|
||||||
if (!this.newDescKey.trim() || !this.newDescValue.trim()) return;
|
if (!this.newDescKey.trim() || !this.newDescValue.trim()) return;
|
||||||
@@ -285,28 +298,8 @@ export class ItemEditorComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async previewInMarketplace() {
|
previewInMarketplace() {
|
||||||
// Open marketplace in new tab with this item
|
this.router.navigate(['/project', this.projectId(), 'item', this.itemId(), 'preview']);
|
||||||
const item = this.item();
|
|
||||||
const subcategory = this.subcategory();
|
|
||||||
if (!item || !subcategory) {
|
|
||||||
this.snackBar.open('Item data not loaded', 'Close', { duration: 2000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build the full category hierarchy path
|
|
||||||
const path = await this.buildCategoryPath();
|
|
||||||
if (!path) {
|
|
||||||
this.snackBar.open('Unable to build preview URL', 'Close', { duration: 2000 });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const marketplaceUrl = `${environment.marketplaceUrl}${path}`;
|
|
||||||
window.open(marketplaceUrl, '_blank');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Preview failed:', err);
|
|
||||||
this.snackBar.open('Failed to generate preview URL', 'Close', { duration: 3000 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onImageDrop(event: CdkDragDrop<string[]>) {
|
onImageDrop(event: CdkDragDrop<string[]>) {
|
||||||
|
|||||||
152
src/app/pages/item-preview/item-preview.component.html
Normal file
152
src/app/pages/item-preview/item-preview.component.html
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<div class="preview-page">
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="preview-topbar">
|
||||||
|
<button mat-icon-button (click)="goBack()">
|
||||||
|
<mat-icon>arrow_back</mat-icon>
|
||||||
|
</button>
|
||||||
|
<span class="preview-label">
|
||||||
|
<mat-icon>visibility</mat-icon>
|
||||||
|
Preview
|
||||||
|
</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button mat-raised-button color="primary" (click)="openEdit()">
|
||||||
|
<mat-icon>edit</mat-icon>
|
||||||
|
Edit Item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="loading-wrap">
|
||||||
|
<app-loading-skeleton type="form"></app-loading-skeleton>
|
||||||
|
</div>
|
||||||
|
} @else if (item(); as item) {
|
||||||
|
<div class="preview-layout">
|
||||||
|
|
||||||
|
<!-- Left: image gallery -->
|
||||||
|
<div class="gallery">
|
||||||
|
<div class="main-image">
|
||||||
|
@if (item.imgs.length) {
|
||||||
|
<img
|
||||||
|
[src]="item.imgs[activeImageIndex()]"
|
||||||
|
[alt]="item.name"
|
||||||
|
(error)="onImageError($event)">
|
||||||
|
} @else {
|
||||||
|
<div class="no-image">
|
||||||
|
<mat-icon>image</mat-icon>
|
||||||
|
<span>No image</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Out of stock overlay -->
|
||||||
|
@if (item.quantity === 0) {
|
||||||
|
<div class="oos-banner">Out of Stock</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Badges overlay -->
|
||||||
|
@if (item.badges?.length) {
|
||||||
|
<div class="badges-overlay">
|
||||||
|
@for (badge of item.badges || []; track badge) {
|
||||||
|
<span class="badge" [style.background-color]="badgeColor(badge)">{{ badge }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thumbnails -->
|
||||||
|
@if (item.imgs.length > 1) {
|
||||||
|
<div class="thumbnails">
|
||||||
|
@for (img of item.imgs; track $index) {
|
||||||
|
<div
|
||||||
|
class="thumb"
|
||||||
|
[class.active]="activeImageIndex() === $index"
|
||||||
|
(click)="selectImage($index)">
|
||||||
|
<img [src]="img" [alt]="item.name + ' ' + ($index + 1)" (error)="onImageError($event)">
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: item details -->
|
||||||
|
<div class="details">
|
||||||
|
<h1 class="item-name">{{ item.name }}</h1>
|
||||||
|
|
||||||
|
<div class="price-row">
|
||||||
|
<span class="price">{{ item.price | number:'1.2-2' }} {{ item.currency }}</span>
|
||||||
|
@if (item.quantity > 0) {
|
||||||
|
<span class="in-stock">
|
||||||
|
<mat-icon>check_circle</mat-icon>
|
||||||
|
In stock ({{ item.quantity }})
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="out-of-stock">
|
||||||
|
<mat-icon>cancel</mat-icon>
|
||||||
|
Out of stock
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (item.simpleDescription) {
|
||||||
|
<p class="simple-desc">{{ item.simpleDescription }}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
|
||||||
|
<!-- Description key-value table -->
|
||||||
|
@if (item?.description?.length) {
|
||||||
|
<div class="desc-table">
|
||||||
|
@for (field of item.description; track $index) {
|
||||||
|
<div class="desc-row">
|
||||||
|
<span class="desc-key">{{ field.key }}</span>
|
||||||
|
<span class="desc-value">{{ field.value }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Badges -->
|
||||||
|
@if (item.badges?.length) {
|
||||||
|
<div class="section">
|
||||||
|
<span class="section-label">Badges</span>
|
||||||
|
<div class="badges-row">
|
||||||
|
@for (badge of item.badges || []; track badge) {
|
||||||
|
<span class="badge-chip" [style.background-color]="badgeColor(badge)">{{ badge }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
@if (item?.tags?.length) {
|
||||||
|
<div class="section">
|
||||||
|
<span class="section-label">Tags</span>
|
||||||
|
<div class="tags-row">
|
||||||
|
@for (tag of item.tags; track tag) {
|
||||||
|
<mat-chip>{{ tag }}</mat-chip>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Meta -->
|
||||||
|
<div class="meta-row">
|
||||||
|
<span>Priority: {{ item.priority }}</span>
|
||||||
|
<span>
|
||||||
|
<mat-icon [class.icon-visible]="item.visible" [class.icon-hidden]="!item.visible">
|
||||||
|
{{ item.visible ? 'visibility' : 'visibility_off' }}
|
||||||
|
</mat-icon>
|
||||||
|
{{ item.visible ? 'Visible' : 'Hidden' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="empty-state">
|
||||||
|
<mat-icon>error_outline</mat-icon>
|
||||||
|
<p>Item not found</p>
|
||||||
|
<button mat-button (click)="goBack()">Go back</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
341
src/app/pages/item-preview/item-preview.component.scss
Normal file
341
src/app/pages/item-preview/item-preview.component.scss
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
.preview-page {
|
||||||
|
min-height: 100%;
|
||||||
|
background: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Top bar ──────────────────────────────────────────────────────────────────
|
||||||
|
.preview-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
.preview-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-wrap {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Two-column layout ─────────────────────────────────────────────────────────
|
||||||
|
.preview-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: 2rem;
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gallery ──────────────────────────────────────────────────────────────────
|
||||||
|
.gallery {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
.main-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-image {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #bbb;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.oos-banner {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnails {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover:not(.active) {
|
||||||
|
border-color: #90caf9;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Details panel ─────────────────────────────────────────────────────────────
|
||||||
|
.details {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.in-stock, .out-of-stock {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.in-stock {
|
||||||
|
color: #43a047;
|
||||||
|
}
|
||||||
|
|
||||||
|
.out-of-stock {
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-desc {
|
||||||
|
margin: 0;
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.975rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
.desc-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.55rem 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-key {
|
||||||
|
width: 130px;
|
||||||
|
min-width: 130px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #444;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-value {
|
||||||
|
color: #222;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.badges-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
|
||||||
|
.badge-chip {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #777;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-visible { color: #43a047; }
|
||||||
|
.icon-hidden { color: #bbb; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty / error state ───────────────────────────────────────────────────────
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
padding: 4rem;
|
||||||
|
color: #999;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/app/pages/item-preview/item-preview.component.ts
Normal file
90
src/app/pages/item-preview/item-preview.component.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { Component, OnInit, signal } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
|
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||||
|
import { MatDividerModule } from '@angular/material/divider';
|
||||||
|
import { ApiService } from '../../services';
|
||||||
|
import { Item } from '../../models';
|
||||||
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
|
|
||||||
|
interface BadgeDef { value: string; color: string; }
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-item-preview',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatChipsModule,
|
||||||
|
MatProgressSpinnerModule,
|
||||||
|
MatDividerModule,
|
||||||
|
LoadingSkeletonComponent,
|
||||||
|
],
|
||||||
|
templateUrl: './item-preview.component.html',
|
||||||
|
styleUrls: ['./item-preview.component.scss']
|
||||||
|
})
|
||||||
|
export class ItemPreviewComponent implements OnInit {
|
||||||
|
item = signal<Item | null>(null);
|
||||||
|
loading = signal(true);
|
||||||
|
activeImageIndex = signal(0);
|
||||||
|
itemId = signal<string>('');
|
||||||
|
projectId = signal<string>('');
|
||||||
|
|
||||||
|
private badgeColorMap: Record<string, string> = {
|
||||||
|
new: '#009688',
|
||||||
|
sale: '#e53935',
|
||||||
|
exclusive: '#7b1fa2',
|
||||||
|
hot: '#f4511e',
|
||||||
|
limited: '#f9a825',
|
||||||
|
bestseller: '#1976d2',
|
||||||
|
featured: '#3949ab',
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
|
private apiService: ApiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const params = this.route.snapshot.params;
|
||||||
|
const parentParams = this.route.parent?.snapshot.params;
|
||||||
|
this.itemId.set(params['itemId']);
|
||||||
|
if (parentParams) this.projectId.set(parentParams['projectId']);
|
||||||
|
|
||||||
|
this.apiService.getItem(this.itemId()).subscribe({
|
||||||
|
next: (item) => {
|
||||||
|
this.item.set(item);
|
||||||
|
this.loading.set(false);
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeColor(value: string): string {
|
||||||
|
return this.badgeColorMap[value] ?? '#607d8b';
|
||||||
|
}
|
||||||
|
|
||||||
|
selectImage(index: number) {
|
||||||
|
this.activeImageIndex.set(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'item', this.itemId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
openEdit() {
|
||||||
|
this.router.navigate(['/project', this.projectId(), 'item', this.itemId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageError(event: Event) {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
img.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,10 +76,19 @@
|
|||||||
|
|
||||||
<div class="item-image">
|
<div class="item-image">
|
||||||
@if (item.imgs.length) {
|
@if (item.imgs.length) {
|
||||||
<img [src]="item.imgs[0]" [alt]="item.name">
|
<img [src]="item.imgs[0]" [alt]="item.name" (error)="onImageError($event)">
|
||||||
} @else {
|
}
|
||||||
<div class="no-image">
|
<div class="no-image" [style.display]="item.imgs.length ? 'none' : 'flex'">
|
||||||
<mat-icon>image</mat-icon>
|
<mat-icon>image</mat-icon>
|
||||||
|
</div>
|
||||||
|
@if (item.quantity === 0) {
|
||||||
|
<div class="out-of-stock-overlay">Out of Stock</div>
|
||||||
|
}
|
||||||
|
@if (item.badges?.length) {
|
||||||
|
<div class="item-badges">
|
||||||
|
@for (badge of (item.badges || []).slice(0, 2); track badge) {
|
||||||
|
<span class="badge badge-{{ badge }}">{{ badge }}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -140,6 +140,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -161,6 +162,47 @@
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.out-of-stock-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badges {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&.badge-new { background: #009688; }
|
||||||
|
&.badge-sale { background: #e53935; }
|
||||||
|
&.badge-exclusive { background: #7b1fa2; }
|
||||||
|
&.badge-hot { background: #f4511e; }
|
||||||
|
&.badge-limited { background: #f9a825; color: #333; }
|
||||||
|
&.badge-bestseller{ background: #1976d2; }
|
||||||
|
&.badge-featured { background: #3949ab; }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-info {
|
.item-info {
|
||||||
|
|||||||
@@ -194,10 +194,19 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openItem(itemId: string) {
|
openItem(itemId: string) {
|
||||||
console.log('Opening item:', itemId, 'projectId:', this.projectId());
|
|
||||||
this.router.navigate(['/project', this.projectId(), 'item', itemId]);
|
this.router.navigate(['/project', this.projectId(), 'item', itemId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onImageError(event: Event) {
|
||||||
|
const img = event.target as HTMLImageElement;
|
||||||
|
img.style.display = 'none';
|
||||||
|
const parent = img.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
const placeholder = parent.querySelector('.no-image') as HTMLElement | null;
|
||||||
|
if (placeholder) placeholder.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
goBack() {
|
goBack() {
|
||||||
// Navigate back to the project view
|
// Navigate back to the project view
|
||||||
this.router.navigate(['/project', this.projectId()]);
|
this.router.navigate(['/project', this.projectId()]);
|
||||||
@@ -212,7 +221,15 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
{ name: 'name', label: 'Item Name', type: 'text', required: true },
|
{ name: 'name', label: 'Item Name', type: 'text', required: true },
|
||||||
{ name: 'simpleDescription', label: 'Simple Description', type: 'text', required: false },
|
{ name: 'simpleDescription', label: 'Simple Description', type: 'text', required: false },
|
||||||
{ name: 'price', label: 'Price', type: 'number', required: true },
|
{ name: 'price', label: 'Price', type: 'number', required: true },
|
||||||
{ name: 'currency', label: 'Currency', type: 'text', required: true, value: 'USD' },
|
{ name: 'currency', label: 'Currency', type: 'select', required: true, value: 'USD',
|
||||||
|
options: [
|
||||||
|
{ value: 'USD', label: '🇺🇸 USD' },
|
||||||
|
{ value: 'EUR', label: '🇪🇺 EUR' },
|
||||||
|
{ value: 'RUB', label: '🇷🇺 RUB' },
|
||||||
|
{ value: 'GBP', label: '🇬🇧 GBP' },
|
||||||
|
{ value: 'UAH', label: '🇺🇦 UAH' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{ name: 'quantity', label: 'Quantity', type: 'number', required: true, value: 0 },
|
{ name: 'quantity', label: 'Quantity', type: 'number', required: true, value: 0 },
|
||||||
{ name: 'visible', label: 'Visible', type: 'toggle', required: false, value: true }
|
{ name: 'visible', label: 'Visible', type: 'toggle', required: false, value: true }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,11 +4,6 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
mat-toolbar {
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidenav-container {
|
.sidenav-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: calc(100vh - 64px);
|
height: calc(100vh - 64px);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, OnInit, signal, computed } from '@angular/core';
|
import { Component, OnInit, signal, computed } from '@angular/core';
|
||||||
import { ActivatedRoute, Router, RouterOutlet } from '@angular/router';
|
import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { filter } from 'rxjs/operators';
|
||||||
import { MatSidenavModule } from '@angular/material/sidenav';
|
import { MatSidenavModule } from '@angular/material/sidenav';
|
||||||
import { MatTreeModule } from '@angular/material/tree';
|
import { MatTreeModule } from '@angular/material/tree';
|
||||||
import { MatIconModule } from '@angular/material/icon';
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
@@ -75,10 +76,11 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
this.loadCategories();
|
this.loadCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track selected route
|
// Track selected route — filter to NavigationEnd so snapshot is fully resolved
|
||||||
this.router.events.subscribe(() => {
|
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
|
||||||
const categoryId = this.route.children[0]?.snapshot.params['categoryId'];
|
const child = this.route.children[0]?.snapshot;
|
||||||
const subcategoryId = this.route.children[0]?.snapshot.params['subcategoryId'];
|
const subcategoryId = child?.params['subcategoryId'];
|
||||||
|
const categoryId = child?.params['categoryId'];
|
||||||
this.selectedNodeId.set(subcategoryId || categoryId || null);
|
this.selectedNodeId.set(subcategoryId || categoryId || null);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -182,7 +184,6 @@ export class ProjectViewComponent implements OnInit {
|
|||||||
viewItems(node: CategoryNode, event: Event) {
|
viewItems(node: CategoryNode, event: Event) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (node.type === 'subcategory') {
|
if (node.type === 'subcategory') {
|
||||||
console.log('Navigating to items for subcategory:', node.id);
|
|
||||||
this.router.navigate(['/project', this.projectId(), 'items', node.id]);
|
this.router.navigate(['/project', this.projectId(), 'items', node.id]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<div class="editor-container">
|
<div class="editor-container">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="loading-container">
|
<app-loading-skeleton type="form"></app-loading-skeleton>
|
||||||
<mat-spinner></mat-spinner>
|
|
||||||
</div>
|
|
||||||
} @else if (subcategory()) {
|
} @else if (subcategory()) {
|
||||||
<div class="editor-header">
|
<div class="editor-header">
|
||||||
<div style="display: flex; align-items: center; gap: 8px;">
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.editor-container {
|
.editor-container {
|
||||||
max-width: 800px;
|
max-width: 960px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
|
|||||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||||
import { ApiService } from '../../services';
|
import { ApiService } from '../../services';
|
||||||
import { Subcategory } from '../../models';
|
import { Subcategory } from '../../models';
|
||||||
|
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
|
||||||
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -27,7 +28,8 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
|
|||||||
MatIconModule,
|
MatIconModule,
|
||||||
MatProgressSpinnerModule,
|
MatProgressSpinnerModule,
|
||||||
MatSnackBarModule,
|
MatSnackBarModule,
|
||||||
MatDialogModule
|
MatDialogModule,
|
||||||
|
LoadingSkeletonComponent
|
||||||
],
|
],
|
||||||
templateUrl: './subcategory-editor.component.html',
|
templateUrl: './subcategory-editor.component.html',
|
||||||
styleUrls: ['./subcategory-editor.component.scss']
|
styleUrls: ['./subcategory-editor.component.scss']
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export class MockDataService {
|
|||||||
'https://via.placeholder.com/600x400?text=iPhone+Back'
|
'https://via.placeholder.com/600x400?text=iPhone+Back'
|
||||||
],
|
],
|
||||||
tags: ['new', 'featured', 'bestseller'],
|
tags: ['new', 'featured', 'bestseller'],
|
||||||
|
badges: ['new', 'featured'],
|
||||||
simpleDescription: 'Latest iPhone with titanium design and A17 Pro chip',
|
simpleDescription: 'Latest iPhone with titanium design and A17 Pro chip',
|
||||||
description: [
|
description: [
|
||||||
{ key: 'Color', value: 'Natural Titanium' },
|
{ key: 'Color', value: 'Natural Titanium' },
|
||||||
@@ -123,6 +124,7 @@ export class MockDataService {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
imgs: ['https://via.placeholder.com/600x400?text=Samsung+S24'],
|
imgs: ['https://via.placeholder.com/600x400?text=Samsung+S24'],
|
||||||
tags: ['new', 'android'],
|
tags: ['new', 'android'],
|
||||||
|
badges: ['new'],
|
||||||
simpleDescription: 'Premium Samsung flagship with S Pen',
|
simpleDescription: 'Premium Samsung flagship with S Pen',
|
||||||
description: [
|
description: [
|
||||||
{ key: 'Color', value: 'Titanium Gray' },
|
{ key: 'Color', value: 'Titanium Gray' },
|
||||||
@@ -141,6 +143,7 @@ export class MockDataService {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
imgs: ['https://via.placeholder.com/600x400?text=Pixel+8'],
|
imgs: ['https://via.placeholder.com/600x400?text=Pixel+8'],
|
||||||
tags: ['sale', 'android', 'ai'],
|
tags: ['sale', 'android', 'ai'],
|
||||||
|
badges: ['sale', 'hot'],
|
||||||
simpleDescription: 'Best AI photography phone',
|
simpleDescription: 'Best AI photography phone',
|
||||||
description: [
|
description: [
|
||||||
{ key: 'Color', value: 'Bay Blue' },
|
{ key: 'Color', value: 'Bay Blue' },
|
||||||
@@ -158,6 +161,7 @@ export class MockDataService {
|
|||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
imgs: ['https://via.placeholder.com/600x400?text=MacBook'],
|
imgs: ['https://via.placeholder.com/600x400?text=MacBook'],
|
||||||
tags: ['featured', 'professional'],
|
tags: ['featured', 'professional'],
|
||||||
|
badges: ['exclusive'],
|
||||||
simpleDescription: 'Powerful laptop for professionals',
|
simpleDescription: 'Powerful laptop for professionals',
|
||||||
description: [
|
description: [
|
||||||
{ key: 'Processor', value: 'M3 Max' },
|
{ key: 'Processor', value: 'M3 Max' },
|
||||||
@@ -403,6 +407,7 @@ export class MockDataService {
|
|||||||
currency: data.currency || 'USD',
|
currency: data.currency || 'USD',
|
||||||
imgs: data.imgs || [],
|
imgs: data.imgs || [],
|
||||||
tags: data.tags || [],
|
tags: data.tags || [],
|
||||||
|
badges: data.badges || [],
|
||||||
simpleDescription: data.simpleDescription || '',
|
simpleDescription: data.simpleDescription || '',
|
||||||
description: data.description || [],
|
description: data.description || [],
|
||||||
subcategoryId
|
subcategoryId
|
||||||
|
|||||||
@@ -68,17 +68,7 @@ export class ValidationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
validateImageUrl(value: string): string | null {
|
validateImageUrl(value: string): string | null {
|
||||||
const urlError = this.validateUrl(value);
|
return this.validateUrl(value);
|
||||||
if (urlError) return urlError;
|
|
||||||
|
|
||||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'];
|
|
||||||
const url = value.toLowerCase();
|
|
||||||
const hasValidExtension = imageExtensions.some(ext => url.includes(ext));
|
|
||||||
|
|
||||||
if (!hasValidExtension) {
|
|
||||||
return 'URL should point to an image file';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateId(value: string): string | null {
|
validateId(value: string): string | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user