diff --git a/src/app/core/api/api.service.ts b/src/app/core/api/api.service.ts index 49c43cd..f8135bf 100644 --- a/src/app/core/api/api.service.ts +++ b/src/app/core/api/api.service.ts @@ -22,6 +22,10 @@ export class ApiService { return this.http.post(this.url(path), body); } + put(path: string, body: unknown): Observable { + return this.http.put(this.url(path), body); + } + patch(path: string, body: unknown): Observable { return this.http.patch(this.url(path), body); } diff --git a/src/app/core/api/catalogue.service.ts b/src/app/core/api/catalogue.service.ts index 9009dfc..427a2bc 100644 --- a/src/app/core/api/catalogue.service.ts +++ b/src/app/core/api/catalogue.service.ts @@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { Item, Paginated } from '../models/api.models'; +import { environment } from '../../../environments/environment'; import { ApiService } from './api.service'; /** @@ -39,4 +40,28 @@ export class CatalogueService { remove(uuid: string): Observable { return this.api.delete(`/v1/items/${uuid}`); } + + /** + * Upload the product image. + * + * Sent as multipart form data. The Content-Type header is deliberately not + * set: the browser has to add it itself so it can include the multipart + * boundary, and setting it by hand produces a request the server cannot parse. + */ + uploadImage(uuid: string, file: File): Observable { + const form = new FormData(); + form.append('file', file); + return this.api.put(`/v1/items/${uuid}/image`, form); + } + + /** + * Absolute URL of an item's image. + * + * The API stores a relative path so the object store can move without + * rewriting stored data; this puts the configured host in front of it. + */ + imageUrl(item: Item): string | null { + const path = item.media?.main_image; + return path ? `${environment.apiBaseUrl}${path}` : null; + } } diff --git a/src/app/features/dashboard/dashboard.page.html b/src/app/features/dashboard/dashboard.page.html index 416eee9..60d8b80 100644 --- a/src/app/features/dashboard/dashboard.page.html +++ b/src/app/features/dashboard/dashboard.page.html @@ -1,8 +1,3 @@ - - @if (loading()) { } @else { @@ -141,7 +136,7 @@ } - + @if (statusMix().length === 0) {

No orders yet.

} @else { diff --git a/src/app/features/dashboard/dashboard.page.ts b/src/app/features/dashboard/dashboard.page.ts index af02d8c..52dfcef 100644 --- a/src/app/features/dashboard/dashboard.page.ts +++ b/src/app/features/dashboard/dashboard.page.ts @@ -8,13 +8,7 @@ import { CatalogueService } from '../../core/api/catalogue.service'; import { InventoryService } from '../../core/api/inventory.service'; import { OrdersService } from '../../core/api/orders.service'; import { InventoryItem, Item, OrderSummary } from '../../core/models/api.models'; -import { - BadgeComponent, - CardComponent, - MoneyPipe, - PageHeaderComponent, - SpinnerComponent, -} from '../../shared/ui'; +import { BadgeComponent, CardComponent, MoneyPipe, SpinnerComponent } from '../../shared/ui'; /** Order states whose value counts as money actually taken. */ const EARNED: readonly string[] = ['paid', 'ready_to_ship', 'shipped']; @@ -33,15 +27,7 @@ const EARNED: readonly string[] = ['paid', 'ready_to_ship', 'shipped']; @Component({ selector: 'ot-dashboard-page', standalone: true, - imports: [ - CommonModule, - RouterLink, - PageHeaderComponent, - CardComponent, - BadgeComponent, - SpinnerComponent, - MoneyPipe, - ], + imports: [CommonModule, RouterLink, CardComponent, BadgeComponent, SpinnerComponent, MoneyPipe], templateUrl: './dashboard.page.html', }) export class DashboardPage { diff --git a/src/app/features/inventory/inventory.page.html b/src/app/features/inventory/inventory.page.html index fe99938..5e43d86 100644 --- a/src/app/features/inventory/inventory.page.html +++ b/src/app/features/inventory/inventory.page.html @@ -1,10 +1,3 @@ - - Refresh - - @if (error(); as message) {
{{ message }} @@ -27,7 +20,9 @@ [selected]="selected()" (selectionChange)="selected.set($event)" (cleared)="clearFilters()" - /> + > + Refresh +
diff --git a/src/app/features/inventory/inventory.page.ts b/src/app/features/inventory/inventory.page.ts index 0d39270..2a47a87 100644 --- a/src/app/features/inventory/inventory.page.ts +++ b/src/app/features/inventory/inventory.page.ts @@ -11,7 +11,6 @@ import { BadgeComponent, ButtonComponent, EmptyStateComponent, - PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; @@ -28,7 +27,6 @@ import { imports: [ CommonModule, FormsModule, - PageHeaderComponent, ButtonComponent, BadgeComponent, EmptyStateComponent, diff --git a/src/app/features/orders/orders.page.html b/src/app/features/orders/orders.page.html index 77c0807..f3bf4c3 100644 --- a/src/app/features/orders/orders.page.html +++ b/src/app/features/orders/orders.page.html @@ -1,10 +1,3 @@ - - Refresh - - @if (error(); as message) {
{{ message }} @@ -22,7 +15,9 @@ [selected]="selected()" (selectionChange)="selected.set($event)" (cleared)="clearFilters()" - /> + > + Refresh +
diff --git a/src/app/features/orders/orders.page.ts b/src/app/features/orders/orders.page.ts index dc51a07..af74b66 100644 --- a/src/app/features/orders/orders.page.ts +++ b/src/app/features/orders/orders.page.ts @@ -14,7 +14,6 @@ import { EmptyStateComponent, ModalComponent, MoneyPipe, - PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; @@ -40,7 +39,6 @@ const STATUS_TONES: Record - Refresh - New product - +
+ + Refresh + New product + +
@if (error(); as message) {
@@ -32,6 +41,7 @@ + +
Image @for (item of visible(); track item.uuid) {
+ @if (imageUrl(item); as src) { + + } @else { + + } +

{{ item.name }}

{{ item.brand || '—' }}

@@ -154,6 +193,50 @@ + +
+ Product image +
+ @if (imagePreview(); as src) { + Product image preview + } @else { + + } +
+ +

+ JPEG, PNG, GIF or WebP, up to 5 MB. Shown to customers in the store. +

+
+
+
diff --git a/src/app/features/products/products.page.ts b/src/app/features/products/products.page.ts index f6d83c4..f3ae095 100644 --- a/src/app/features/products/products.page.ts +++ b/src/app/features/products/products.page.ts @@ -13,7 +13,6 @@ import { EmptyStateComponent, ModalComponent, MoneyPipe, - PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; @@ -30,7 +29,6 @@ import { imports: [ CommonModule, ReactiveFormsModule, - PageHeaderComponent, ButtonComponent, BadgeComponent, EmptyStateComponent, @@ -55,6 +53,10 @@ export class ProductsPage { readonly formOpen = signal(false); readonly editing = signal(null); + /** File chosen in the form, uploaded after the item itself is saved. */ + readonly pendingImage = signal(null); + readonly imagePreview = signal(null); + readonly search = signal(''); readonly selected = signal>({ status: [] }); readonly sort = createSort<'name' | 'sku' | 'status' | 'price'>({ @@ -138,14 +140,44 @@ export class ProductsPage { }); } + imageUrl(item: Item): string | null { + return this.catalogue.imageUrl(item); + } + + pickImage(event: Event): void { + const input = event.target as HTMLInputElement; + const file = input.files?.[0] ?? null; + this.pendingImage.set(file); + + // Show the chosen file straight away rather than after a round trip, so + // the operator can see they picked the right picture. + const previous = this.imagePreview(); + if (previous?.startsWith('blob:')) { + URL.revokeObjectURL(previous); + } + this.imagePreview.set(file ? URL.createObjectURL(file) : null); + } + + private clearPendingImage(): void { + const preview = this.imagePreview(); + if (preview?.startsWith('blob:')) { + URL.revokeObjectURL(preview); + } + this.pendingImage.set(null); + this.imagePreview.set(null); + } + openCreate(): void { this.editing.set(null); + this.clearPendingImage(); this.form.reset({ status: 'active', currency: 'EUR', amount: 0 }); this.formOpen.set(true); } openEdit(item: Item): void { this.editing.set(item); + this.clearPendingImage(); + this.imagePreview.set(this.catalogue.imageUrl(item)); this.form.reset({ sku: item.sku, name: item.name, @@ -192,10 +224,26 @@ export class ProductsPage { } as Partial); request.subscribe({ - next: () => { - this.saving.set(false); - this.formOpen.set(false); - this.load(); + next: (saved) => { + const image = this.pendingImage(); + if (!image) { + this.finishSave(); + return; + } + // The image can only be attached once the item exists, so it follows + // the save rather than riding along with it. + this.catalogue.uploadImage(saved.uuid, image).subscribe({ + next: () => this.finishSave(), + error: (err) => { + this.saving.set(false); + this.formOpen.set(false); + this.load(); + // The product itself saved; say so, rather than implying it did not. + this.error.set( + this.message(err, 'The product was saved, but the image could not be uploaded.'), + ); + }, + }); }, error: (err) => { this.saving.set(false); @@ -204,6 +252,13 @@ export class ProductsPage { }); } + private finishSave(): void { + this.saving.set(false); + this.formOpen.set(false); + this.clearPendingImage(); + this.load(); + } + remove(item: Item): void { if (!confirm(`Delete “${item.name}”? Customers will no longer see it.`)) { return; diff --git a/src/app/features/returns/returns.page.html b/src/app/features/returns/returns.page.html index 2c5b6ce..6f93e81 100644 --- a/src/app/features/returns/returns.page.html +++ b/src/app/features/returns/returns.page.html @@ -1,8 +1,3 @@ - - @if (error(); as message) {
{{ message }} diff --git a/src/app/features/returns/returns.page.ts b/src/app/features/returns/returns.page.ts index ffe9a65..dabbd8f 100644 --- a/src/app/features/returns/returns.page.ts +++ b/src/app/features/returns/returns.page.ts @@ -4,13 +4,7 @@ import { FormsModule } from '@angular/forms'; import { ReturnsService } from '../../core/api/returns.service'; import { ReturnStatus } from '../../core/models/api.models'; -import { - AlertComponent, - ButtonComponent, - CardComponent, - ModalComponent, - PageHeaderComponent, -} from '../../shared/ui'; +import { AlertComponent, ButtonComponent, CardComponent, ModalComponent } from '../../shared/ui'; /** * Return (RMA) handling. @@ -29,7 +23,6 @@ import { imports: [ CommonModule, FormsModule, - PageHeaderComponent, ButtonComponent, CardComponent, AlertComponent, diff --git a/src/app/features/users/users.page.html b/src/app/features/users/users.page.html index 6a50d0c..5c14f2d 100644 --- a/src/app/features/users/users.page.html +++ b/src/app/features/users/users.page.html @@ -1,10 +1,3 @@ - - Refresh - - @if (error(); as message) {
{{ message }} @@ -27,7 +20,9 @@ [selected]="selected()" (selectionChange)="selected.set($event)" (cleared)="clearFilters()" - /> + > + Refresh +
diff --git a/src/app/features/users/users.page.ts b/src/app/features/users/users.page.ts index 31d627c..d71cfcf 100644 --- a/src/app/features/users/users.page.ts +++ b/src/app/features/users/users.page.ts @@ -11,7 +11,6 @@ import { ButtonComponent, EmptyStateComponent, ModalComponent, - PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; @@ -32,7 +31,6 @@ type PendingAction = 'promote' | 'demote' | 'delete'; standalone: true, imports: [ CommonModule, - PageHeaderComponent, ButtonComponent, BadgeComponent, EmptyStateComponent, diff --git a/src/app/shared/filters/filter-bar.ts b/src/app/shared/filters/filter-bar.ts index 04af88b..b7c81ca 100644 --- a/src/app/shared/filters/filter-bar.ts +++ b/src/app/shared/filters/filter-bar.ts @@ -91,6 +91,12 @@ export interface FilterOption { Clear all } + + +
+ +
@if (chips.length > 0) { diff --git a/src/app/shared/ui/index.ts b/src/app/shared/ui/index.ts index 575d791..02b31d7 100644 --- a/src/app/shared/ui/index.ts +++ b/src/app/shared/ui/index.ts @@ -5,5 +5,4 @@ export * from './card'; export * from './empty-state'; export * from './modal'; export * from './money.pipe'; -export * from './page-header'; export * from './spinner'; diff --git a/src/app/shared/ui/page-header.ts b/src/app/shared/ui/page-header.ts deleted file mode 100644 index f818840..0000000 --- a/src/app/shared/ui/page-header.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Component, Input } from '@angular/core'; - -/** - * The title block at the top of every screen. - * - * Exists so page titles share one rhythm rather than each screen choosing its - * own heading size and spacing. Actions are projected on the right. - */ -@Component({ - selector: 'ot-page-header', - standalone: true, - template: ` -
-
-

{{ title }}

- @if (description) { -

{{ description }}

- } -
-
- -
-
- `, -}) -export class PageHeaderComponent { - @Input({ required: true }) title!: string; - @Input() description?: string; -}