diff --git a/API.md b/API.md index 744f122..e105ca4 100644 --- a/API.md +++ b/API.md @@ -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. diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index acf0d69..31397b8 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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 } ] }, diff --git a/src/app/components/create-dialog/create-dialog.component.ts b/src/app/components/create-dialog/create-dialog.component.ts index 7b5bdce..000c8bd 100644 --- a/src/app/components/create-dialog/create-dialog.component.ts +++ b/src/app/components/create-dialog/create-dialog.component.ts @@ -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: `

{{ data.title }}

@@ -43,6 +46,19 @@ export interface CreateDialogData { {{ field.label }} + } @else if (field.type === 'select') { + + {{ field.label }} + + @for (opt of field.options || []; track opt.value) { + {{ opt.label }} + } + + @if (field.hint) { {{ field.hint }} } + @if (field.required && !formData[field.name]) { + {{ field.label }} is required + } + } @else { {{ field.label }} diff --git a/src/app/models/item.model.ts b/src/app/models/item.model.ts index 5c1bd84..9b1a9a2 100644 --- a/src/app/models/item.model.ts +++ b/src/app/models/item.model.ts @@ -8,6 +8,7 @@ export interface Item { currency: string; imgs: string[]; tags: string[]; + badges?: string[]; simpleDescription: string; description: ItemDescriptionField[]; subcategoryId: string; diff --git a/src/app/pages/category-editor/category-editor.component.html b/src/app/pages/category-editor/category-editor.component.html index e95696e..76102cc 100644 --- a/src/app/pages/category-editor/category-editor.component.html +++ b/src/app/pages/category-editor/category-editor.component.html @@ -1,8 +1,6 @@
@if (loading()) { -
- -
+ } @else if (category()) {
+ } +
+ +

Custom Badges

+
+ + Custom Badge + + + +
+ +
+ @for (badge of (item()!.badges || []); track $index) { + + {{ badge }} + + + } +
+ + @if (!(item()!.badges?.length)) { +
+ new_releases +

No badges yet

+
+ } +
+ + +
diff --git a/src/app/pages/item-editor/item-editor.component.scss b/src/app/pages/item-editor/item-editor.component.scss index c5c83cb..22f77fb 100644 --- a/src/app/pages/item-editor/item-editor.component.scss +++ b/src/app/pages/item-editor/item-editor.component.scss @@ -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 { diff --git a/src/app/pages/item-editor/item-editor.component.ts b/src/app/pages/item-editor/item-editor.component.ts index 64b230e..e0e43f8 100644 --- a/src/app/pages/item-editor/item-editor.component.ts +++ b/src/app/pages/item-editor/item-editor.component.ts @@ -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 { - // 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) { diff --git a/src/app/pages/item-preview/item-preview.component.html b/src/app/pages/item-preview/item-preview.component.html new file mode 100644 index 0000000..1e4630d --- /dev/null +++ b/src/app/pages/item-preview/item-preview.component.html @@ -0,0 +1,152 @@ +
+ +
+ + + visibility + Preview + + + +
+ + @if (loading()) { +
+ +
+ } @else if (item(); as item) { +
+ + + + + +
+

{{ item.name }}

+ +
+ {{ item.price | number:'1.2-2' }} {{ item.currency }} + @if (item.quantity > 0) { + + check_circle + In stock ({{ item.quantity }}) + + } @else { + + cancel + Out of stock + + } +
+ + @if (item.simpleDescription) { +

{{ item.simpleDescription }}

+ } + + + + + @if (item?.description?.length) { +
+ @for (field of item.description; track $index) { +
+ {{ field.key }} + {{ field.value }} +
+ } +
+ + } + + + @if (item.badges?.length) { +
+ +
+ @for (badge of item.badges || []; track badge) { + {{ badge }} + } +
+
+ } + + + @if (item?.tags?.length) { +
+ +
+ @for (tag of item.tags; track tag) { + {{ tag }} + } +
+
+ } + + +
+ Priority: {{ item.priority }} + + + {{ item.visible ? 'visibility' : 'visibility_off' }} + + {{ item.visible ? 'Visible' : 'Hidden' }} + +
+
+ +
+ } @else { +
+ error_outline +

Item not found

+ +
+ } +
diff --git a/src/app/pages/item-preview/item-preview.component.scss b/src/app/pages/item-preview/item-preview.component.scss new file mode 100644 index 0000000..b58ca4f --- /dev/null +++ b/src/app/pages/item-preview/item-preview.component.scss @@ -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; + } +} diff --git a/src/app/pages/item-preview/item-preview.component.ts b/src/app/pages/item-preview/item-preview.component.ts new file mode 100644 index 0000000..ecafd75 --- /dev/null +++ b/src/app/pages/item-preview/item-preview.component.ts @@ -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(null); + loading = signal(true); + activeImageIndex = signal(0); + itemId = signal(''); + projectId = signal(''); + + private badgeColorMap: Record = { + 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'; + } +} diff --git a/src/app/pages/items-list/items-list.component.html b/src/app/pages/items-list/items-list.component.html index dd0dc18..0166a18 100644 --- a/src/app/pages/items-list/items-list.component.html +++ b/src/app/pages/items-list/items-list.component.html @@ -76,10 +76,19 @@
@if (item.imgs.length) { - - } @else { -
- image + + } +
+ image +
+ @if (item.quantity === 0) { +
Out of Stock
+ } + @if (item.badges?.length) { +
+ @for (badge of (item.badges || []).slice(0, 2); track badge) { + {{ badge }} + }
}
diff --git a/src/app/pages/items-list/items-list.component.scss b/src/app/pages/items-list/items-list.component.scss index b388f82..08f7b9a 100644 --- a/src/app/pages/items-list/items-list.component.scss +++ b/src/app/pages/items-list/items-list.component.scss @@ -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 { diff --git a/src/app/pages/items-list/items-list.component.ts b/src/app/pages/items-list/items-list.component.ts index 2e23999..b75cf84 100644 --- a/src/app/pages/items-list/items-list.component.ts +++ b/src/app/pages/items-list/items-list.component.ts @@ -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 } ] diff --git a/src/app/pages/project-view/project-view.component.scss b/src/app/pages/project-view/project-view.component.scss index 698ce02..f7980ce 100644 --- a/src/app/pages/project-view/project-view.component.scss +++ b/src/app/pages/project-view/project-view.component.scss @@ -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); diff --git a/src/app/pages/project-view/project-view.component.ts b/src/app/pages/project-view/project-view.component.ts index 8800601..9a4667b 100644 --- a/src/app/pages/project-view/project-view.component.ts +++ b/src/app/pages/project-view/project-view.component.ts @@ -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]); } } diff --git a/src/app/pages/subcategory-editor/subcategory-editor.component.html b/src/app/pages/subcategory-editor/subcategory-editor.component.html index 0818cb6..467efb6 100644 --- a/src/app/pages/subcategory-editor/subcategory-editor.component.html +++ b/src/app/pages/subcategory-editor/subcategory-editor.component.html @@ -1,8 +1,6 @@
@if (loading()) { -
- -
+ } @else if (subcategory()) {
diff --git a/src/app/pages/subcategory-editor/subcategory-editor.component.scss b/src/app/pages/subcategory-editor/subcategory-editor.component.scss index d92317b..f382122 100644 --- a/src/app/pages/subcategory-editor/subcategory-editor.component.scss +++ b/src/app/pages/subcategory-editor/subcategory-editor.component.scss @@ -1,5 +1,5 @@ .editor-container { - max-width: 800px; + max-width: 960px; margin: 0 auto; } diff --git a/src/app/pages/subcategory-editor/subcategory-editor.component.ts b/src/app/pages/subcategory-editor/subcategory-editor.component.ts index e6e7b83..9b92066 100644 --- a/src/app/pages/subcategory-editor/subcategory-editor.component.ts +++ b/src/app/pages/subcategory-editor/subcategory-editor.component.ts @@ -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'] diff --git a/src/app/services/mock-data.service.ts b/src/app/services/mock-data.service.ts index 77c4744..6a8b1b3 100644 --- a/src/app/services/mock-data.service.ts +++ b/src/app/services/mock-data.service.ts @@ -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 diff --git a/src/app/services/validation.service.ts b/src/app/services/validation.service.ts index 35a3e71..93c0a6b 100644 --- a/src/app/services/validation.service.ts +++ b/src/app/services/validation.service.ts @@ -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 {