Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/core/api/api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export class ApiService {
return this.http.post<T>(this.url(path), body);
}

put<T>(path: string, body: unknown): Observable<T> {
return this.http.put<T>(this.url(path), body);
}

patch<T>(path: string, body: unknown): Observable<T> {
return this.http.patch<T>(this.url(path), body);
}
Expand Down
25 changes: 25 additions & 0 deletions src/app/core/api/catalogue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -39,4 +40,28 @@ export class CatalogueService {
remove(uuid: string): Observable<void> {
return this.api.delete<void>(`/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<Item> {
const form = new FormData();
form.append('file', file);
return this.api.put<Item>(`/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;
}
}
7 changes: 1 addition & 6 deletions src/app/features/dashboard/dashboard.page.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
<ot-page-header
title="Overview"
description="Money, fulfilment and stock across the most recent 100 orders."
/>

@if (loading()) {
<ot-spinner label="Gathering the shop's state…" />
} @else {
Expand Down Expand Up @@ -141,7 +136,7 @@
}
</ot-card>

<ot-card title="Order mix" description="Where the recent orders currently sit.">
<ot-card title="Order mix" description="Across the most recent 100 orders.">
@if (statusMix().length === 0) {
<p class="py-6 text-center text-sm text-ink-muted">No orders yet.</p>
} @else {
Expand Down
18 changes: 2 additions & 16 deletions src/app/features/dashboard/dashboard.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand All @@ -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 {
Expand Down
11 changes: 3 additions & 8 deletions src/app/features/inventory/inventory.page.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
<ot-page-header
title="Inventory"
description="Stock per SKU. Available is what a customer can buy — reserved units are held by checkouts in progress and cannot be edited here."
>
<ot-button variant="secondary" size="sm" (click)="load()">Refresh</ot-button>
</ot-page-header>

@if (error(); as message) {
<div class="mb-4">
<ot-alert tone="danger">{{ message }}</ot-alert>
Expand All @@ -27,7 +20,9 @@
[selected]="selected()"
(selectionChange)="selected.set($event)"
(cleared)="clearFilters()"
/>
>
<ot-button barActions variant="secondary" size="sm" (click)="load()">Refresh</ot-button>
</ot-filter-bar>
</div>

<section class="card mb-6 p-5">
Expand Down
2 changes: 0 additions & 2 deletions src/app/features/inventory/inventory.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
BadgeComponent,
ButtonComponent,
EmptyStateComponent,
PageHeaderComponent,
SpinnerComponent,
} from '../../shared/ui';

Expand All @@ -28,7 +27,6 @@ import {
imports: [
CommonModule,
FormsModule,
PageHeaderComponent,
ButtonComponent,
BadgeComponent,
EmptyStateComponent,
Expand Down
11 changes: 3 additions & 8 deletions src/app/features/orders/orders.page.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,3 @@
<ot-page-header
title="Orders"
description="Everything customers have bought. Open an order to see its lines, payment and shipment."
>
<ot-button variant="secondary" size="sm" (click)="load()">Refresh</ot-button>
</ot-page-header>

@if (error(); as message) {
<div class="mb-4">
<ot-alert tone="danger">{{ message }}</ot-alert>
Expand All @@ -22,7 +15,9 @@
[selected]="selected()"
(selectionChange)="selected.set($event)"
(cleared)="clearFilters()"
/>
>
<ot-button barActions variant="secondary" size="sm" (click)="load()">Refresh</ot-button>
</ot-filter-bar>
</div>

<div class="card">
Expand Down
2 changes: 0 additions & 2 deletions src/app/features/orders/orders.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
EmptyStateComponent,
ModalComponent,
MoneyPipe,
PageHeaderComponent,
SpinnerComponent,
} from '../../shared/ui';

Expand All @@ -40,7 +39,6 @@ const STATUS_TONES: Record<OrderStatus, 'ok' | 'warn' | 'danger' | 'info' | 'neu
imports: [
CommonModule,
FormsModule,
PageHeaderComponent,
ButtonComponent,
BadgeComponent,
EmptyStateComponent,
Expand Down
97 changes: 90 additions & 7 deletions src/app/features/products/products.page.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
<ot-page-header
title="Products"
description="The catalogue customers browse. Only active products are visible in the store — draft and archived stay hidden without being deleted."
>
<ot-button variant="secondary" size="sm" (click)="load()">Refresh</ot-button>
<ot-button size="sm" (click)="openCreate()">New product</ot-button>
</ot-page-header>
<div class="mb-6">
<ot-filter-bar
searchId="product-search"
searchLabel="Search products"
searchPlaceholder="Name, SKU or brand"
[search]="search()"
(searchChange)="search.set($event)"
[facets]="facets"
[selected]="selected()"
(selectionChange)="selected.set($event)"
(cleared)="clearFilters()"
>
<ot-button barActions variant="secondary" size="sm" (click)="load()">Refresh</ot-button>
<ot-button barActions size="sm" (click)="openCreate()">New product</ot-button>
</ot-filter-bar>
</div>

@if (error(); as message) {
<div class="mb-4">
Expand Down Expand Up @@ -32,6 +41,7 @@
<table class="data-table">
<thead>
<tr>
<th class="w-px"><span class="sr-only">Image</span></th>
<th
ot-sort-header
[active]="sort.state().key === 'name'"
Expand Down Expand Up @@ -71,6 +81,35 @@
<tbody>
@for (item of visible(); track item.uuid) {
<tr otRowLink (activated)="openEdit(item)">
<td>
@if (imageUrl(item); as src) {
<img
[src]="src"
[alt]="item.name"
class="size-10 rounded-[var(--radius-control)] border border-line object-cover"
loading="lazy"
/>
} @else {
<div
class="flex size-10 items-center justify-center rounded-[var(--radius-control)] border border-dashed border-line-strong text-ink-subtle"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5 8 12l3 3 4-5 6 7.5M3 5.5h18v13H3z"
/>
</svg>
</div>
}
</td>
<td>
<p class="font-medium text-ink">{{ item.name }}</p>
<p class="text-xs text-ink-subtle">{{ item.brand || '—' }}</p>
Expand Down Expand Up @@ -154,6 +193,50 @@
<label class="field-label" for="short">Short description</label>
<input id="short" class="field-control" formControlName="short_description" />
</div>

<div>
<span class="field-label">Product image</span>
<div class="flex items-center gap-4">
@if (imagePreview(); as src) {
<img
[src]="src"
alt="Product image preview"
class="size-20 rounded-[var(--radius-card)] border border-line object-cover"
/>
} @else {
<div
class="flex size-20 items-center justify-center rounded-[var(--radius-card)] border border-dashed border-line-strong text-ink-subtle"
aria-hidden="true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5 8 12l3 3 4-5 6 7.5M3 5.5h18v13H3z"
/>
</svg>
</div>
}
<div class="min-w-0">
<input
id="image"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
class="block w-full text-sm text-ink-muted file:mr-3 file:rounded-[var(--radius-control)] file:border-0 file:bg-surface-sunken file:px-3 file:py-1.5 file:text-sm file:text-ink hover:file:bg-line"
(change)="pickImage($event)"
/>
<p class="mt-1 text-xs text-ink-subtle">
JPEG, PNG, GIF or WebP, up to 5 MB. Shown to customers in the store.
</p>
</div>
</div>
</div>
</form>

<ng-container modalActions>
Expand Down
Loading
Loading