This commit is contained in:
sdarbinyan
2026-02-20 01:46:14 +04:00
parent 070e254a5c
commit 083b270c74
23 changed files with 878 additions and 109 deletions

3
API.md
View File

@@ -230,6 +230,7 @@ Response 200:
"currency": "USD",
"imgs": ["https://...", "https://..."],
"tags": ["new", "featured"],
"badges": ["new", "exclusive"],
"simpleDescription": "Latest iPhone...",
"description": [
{ "key": "Color", "value": "Black" },
@@ -275,6 +276,7 @@ Body:
"currency": "USD", // USD | EUR | RUB | GBP | UAH
"imgs": ["https://..."],
"tags": ["new"],
"badges": ["new", "exclusive"], // optional - predefined or custom badge labels
"simpleDescription": "Short description",
"description": [
{ "key": "Size", "value": "Large" }
@@ -364,6 +366,7 @@ Response 201:
- Use `PATCH` for partial updates - send only the fields you want to change.
- `priority`: lower number = appears first in the list.
- `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.
- `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.

View File

@@ -5,6 +5,7 @@ import { CategoryEditorComponent } from './pages/category-editor/category-editor
import { SubcategoryEditorComponent } from './pages/subcategory-editor/subcategory-editor.component';
import { ItemsListComponent } from './pages/items-list/items-list.component';
import { ItemEditorComponent } from './pages/item-editor/item-editor.component';
import { ItemPreviewComponent } from './pages/item-preview/item-preview.component';
export const routes: Routes = [
{
@@ -30,6 +31,10 @@ export const routes: Routes = [
{
path: 'item/:itemId',
component: ItemEditorComponent
},
{
path: 'item/:itemId/preview',
component: ItemPreviewComponent
}
]
},

View File

@@ -6,6 +6,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSelectModule } from '@angular/material/select';
export interface CreateDialogData {
title: string;
@@ -13,10 +14,11 @@ export interface CreateDialogData {
fields: {
name: string;
label: string;
type: 'text' | 'number' | 'toggle';
type: 'text' | 'number' | 'toggle' | 'select';
required?: boolean;
value?: any;
hint?: string;
options?: { value: any; label: string }[];
}[];
}
@@ -30,7 +32,8 @@ export interface CreateDialogData {
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatSlideToggleModule
MatSlideToggleModule,
MatSelectModule
],
template: `
<h2 mat-dialog-title>{{ data.title }}</h2>
@@ -43,6 +46,19 @@ export interface CreateDialogData {
{{ field.label }}
</mat-slide-toggle>
</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 {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ field.label }}</mat-label>

View File

@@ -8,6 +8,7 @@ export interface Item {
currency: string;
imgs: string[];
tags: string[];
badges?: string[];
simpleDescription: string;
description: ItemDescriptionField[];
subcategoryId: string;

View File

@@ -1,8 +1,6 @@
<div class="editor-container">
@if (loading()) {
<div class="loading-container">
<mat-spinner></mat-spinner>
</div>
<app-loading-skeleton type="form"></app-loading-skeleton>
} @else if (category()) {
<div class="editor-header">
<button mat-icon-button (click)="goBack()">

View File

@@ -1,5 +1,5 @@
.editor-container {
max-width: 800px;
max-width: 960px;
margin: 0 auto;
}

View File

@@ -13,6 +13,7 @@ import { MatListModule } from '@angular/material/list';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ApiService } from '../../services';
import { Category } from '../../models';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { CreateDialogComponent } from '../../components/create-dialog/create-dialog.component';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
@@ -30,7 +31,8 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
MatProgressSpinnerModule,
MatSnackBarModule,
MatListModule,
MatDialogModule
MatDialogModule,
LoadingSkeletonComponent
],
templateUrl: './category-editor.component.html',
styleUrls: ['./category-editor.component.scss']

View File

@@ -1,8 +1,6 @@
<div class="editor-container">
@if (loading()) {
<div class="loading-container">
<mat-spinner></mat-spinner>
</div>
<app-loading-skeleton type="form"></app-loading-skeleton>
} @else if (item()) {
<div class="editor-header">
<div style="display: flex; align-items: center; gap: 8px;">
@@ -211,6 +209,64 @@
</div>
</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 -->
<mat-tab label="Description">
<div class="tab-content">

View File

@@ -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-section {
h3 {

View File

@@ -1,6 +1,5 @@
import { Component, OnInit, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { environment } from '../../../environments/environment';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
@@ -19,6 +18,7 @@ import { ApiService } from '../../services';
import { ValidationService } from '../../services/validation.service';
import { Item, ItemDescriptionField, Subcategory } from '../../models';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
@Component({
selector: 'app-item-editor',
@@ -37,7 +37,8 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
MatSnackBarModule,
MatTabsModule,
MatDialogModule,
DragDropModule
DragDropModule,
LoadingSkeletonComponent
],
templateUrl: './item-editor.component.html',
styleUrls: ['./item-editor.component.scss']
@@ -58,6 +59,18 @@ export class ItemEditorComponent implements OnInit {
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(
private route: ActivatedRoute,
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) {
const currentItem = this.item();
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
addDescriptionField() {
if (!this.newDescKey.trim() || !this.newDescValue.trim()) return;
@@ -285,28 +298,8 @@ export class ItemEditorComponent implements OnInit {
}
}
async previewInMarketplace() {
// Open marketplace in new tab with this item
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 });
}
previewInMarketplace() {
this.router.navigate(['/project', this.projectId(), 'item', this.itemId(), 'preview']);
}
onImageDrop(event: CdkDragDrop<string[]>) {

View 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>

View 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;
}
}

View 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';
}
}

View File

@@ -76,11 +76,20 @@
<div class="item-image">
@if (item.imgs.length) {
<img [src]="item.imgs[0]" [alt]="item.name">
} @else {
<div class="no-image">
<img [src]="item.imgs[0]" [alt]="item.name" (error)="onImageError($event)">
}
<div class="no-image" [style.display]="item.imgs.length ? 'none' : 'flex'">
<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>

View File

@@ -140,6 +140,7 @@
display: flex;
align-items: center;
justify-content: center;
position: relative;
img {
width: 100%;
@@ -161,6 +162,47 @@
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 {

View File

@@ -194,10 +194,19 @@ export class ItemsListComponent implements OnInit, AfterViewInit, OnDestroy {
}
openItem(itemId: string) {
console.log('Opening item:', itemId, 'projectId:', this.projectId());
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() {
// Navigate back to the project view
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: 'simpleDescription', label: 'Simple Description', type: 'text', required: false },
{ 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: 'visible', label: 'Visible', type: 'toggle', required: false, value: true }
]

View File

@@ -4,11 +4,6 @@
flex-direction: column;
}
mat-toolbar {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.sidenav-container {
flex: 1;
height: calc(100vh - 64px);

View File

@@ -1,6 +1,7 @@
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 { filter } from 'rxjs/operators';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatTreeModule } from '@angular/material/tree';
import { MatIconModule } from '@angular/material/icon';
@@ -75,10 +76,11 @@ export class ProjectViewComponent implements OnInit {
this.loadCategories();
});
// Track selected route
this.router.events.subscribe(() => {
const categoryId = this.route.children[0]?.snapshot.params['categoryId'];
const subcategoryId = this.route.children[0]?.snapshot.params['subcategoryId'];
// Track selected route — filter to NavigationEnd so snapshot is fully resolved
this.router.events.pipe(filter(e => e instanceof NavigationEnd)).subscribe(() => {
const child = this.route.children[0]?.snapshot;
const subcategoryId = child?.params['subcategoryId'];
const categoryId = child?.params['categoryId'];
this.selectedNodeId.set(subcategoryId || categoryId || null);
});
}
@@ -182,7 +184,6 @@ export class ProjectViewComponent implements OnInit {
viewItems(node: CategoryNode, event: Event) {
event.stopPropagation();
if (node.type === 'subcategory') {
console.log('Navigating to items for subcategory:', node.id);
this.router.navigate(['/project', this.projectId(), 'items', node.id]);
}
}

View File

@@ -1,8 +1,6 @@
<div class="editor-container">
@if (loading()) {
<div class="loading-container">
<mat-spinner></mat-spinner>
</div>
<app-loading-skeleton type="form"></app-loading-skeleton>
} @else if (subcategory()) {
<div class="editor-header">
<div style="display: flex; align-items: center; gap: 8px;">

View File

@@ -1,5 +1,5 @@
.editor-container {
max-width: 800px;
max-width: 960px;
margin: 0 auto;
}

View File

@@ -12,6 +12,7 @@ import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { ApiService } from '../../services';
import { Subcategory } from '../../models';
import { LoadingSkeletonComponent } from '../../components/loading-skeleton/loading-skeleton.component';
import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-dialog.component';
@Component({
@@ -27,7 +28,8 @@ import { ConfirmDialogComponent } from '../../components/confirm-dialog/confirm-
MatIconModule,
MatProgressSpinnerModule,
MatSnackBarModule,
MatDialogModule
MatDialogModule,
LoadingSkeletonComponent
],
templateUrl: './subcategory-editor.component.html',
styleUrls: ['./subcategory-editor.component.scss']

View File

@@ -96,6 +96,7 @@ export class MockDataService {
'https://via.placeholder.com/600x400?text=iPhone+Back'
],
tags: ['new', 'featured', 'bestseller'],
badges: ['new', 'featured'],
simpleDescription: 'Latest iPhone with titanium design and A17 Pro chip',
description: [
{ key: 'Color', value: 'Natural Titanium' },
@@ -123,6 +124,7 @@ export class MockDataService {
currency: 'USD',
imgs: ['https://via.placeholder.com/600x400?text=Samsung+S24'],
tags: ['new', 'android'],
badges: ['new'],
simpleDescription: 'Premium Samsung flagship with S Pen',
description: [
{ key: 'Color', value: 'Titanium Gray' },
@@ -141,6 +143,7 @@ export class MockDataService {
currency: 'USD',
imgs: ['https://via.placeholder.com/600x400?text=Pixel+8'],
tags: ['sale', 'android', 'ai'],
badges: ['sale', 'hot'],
simpleDescription: 'Best AI photography phone',
description: [
{ key: 'Color', value: 'Bay Blue' },
@@ -158,6 +161,7 @@ export class MockDataService {
currency: 'USD',
imgs: ['https://via.placeholder.com/600x400?text=MacBook'],
tags: ['featured', 'professional'],
badges: ['exclusive'],
simpleDescription: 'Powerful laptop for professionals',
description: [
{ key: 'Processor', value: 'M3 Max' },
@@ -403,6 +407,7 @@ export class MockDataService {
currency: data.currency || 'USD',
imgs: data.imgs || [],
tags: data.tags || [],
badges: data.badges || [],
simpleDescription: data.simpleDescription || '',
description: data.description || [],
subcategoryId

View File

@@ -68,17 +68,7 @@ export class ValidationService {
}
validateImageUrl(value: string): string | null {
const urlError = 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;
return this.validateUrl(value);
}
validateId(value: string): string | null {