diff --git a/apps/web/src/app/data/services/index.ts b/apps/web/src/app/data/services/index.ts index ef5f682..11ec2e0 100644 --- a/apps/web/src/app/data/services/index.ts +++ b/apps/web/src/app/data/services/index.ts @@ -5,6 +5,7 @@ export * from './balance.service'; export * from './config.service'; export * from './external-wallet.service'; export * from './person.service'; +export * from './price.service'; export * from './product.service'; export * from './setup.service'; export * from './topup.service'; diff --git a/apps/web/src/app/data/services/price.service.ts b/apps/web/src/app/data/services/price.service.ts new file mode 100644 index 0000000..f72b418 --- /dev/null +++ b/apps/web/src/app/data/services/price.service.ts @@ -0,0 +1,56 @@ +import { Injectable, inject, signal, WritableSignal } from '@angular/core'; +import { ApiService } from '../../core'; +import { Price } from '@zoneless/shared-types'; +import { CreatePriceInput, UpdatePriceInput } from '@zoneless/shared-schemas'; + +@Injectable({ + providedIn: 'root', +}) +export class PriceService { + private readonly api = inject(ApiService); + + loading: WritableSignal = signal(false); + + async CreatePrice(data: CreatePriceInput): Promise { + this.loading.set(true); + try { + const price = await this.api.Call('POST', `prices`, data); + return price; + } finally { + this.loading.set(false); + } + } + + async UpdatePrice(priceId: string, data: UpdatePriceInput): Promise { + this.loading.set(true); + try { + const price = await this.api.Call( + 'POST', + `prices/${priceId}`, + data + ); + return price; + } finally { + this.loading.set(false); + } + } + + async DeletePrice(priceId: string): Promise { + this.loading.set(true); + try { + await this.api.Call('DELETE', `prices/${priceId}`); + } finally { + this.loading.set(false); + } + } + + async GetPrice(priceId: string): Promise { + this.loading.set(true); + try { + const price = await this.api.Call('GET', `prices/${priceId}`); + return price; + } finally { + this.loading.set(false); + } + } +} diff --git a/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.html b/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.html new file mode 100644 index 0000000..6c3efa5 --- /dev/null +++ b/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.html @@ -0,0 +1,50 @@ + + + + + + + + + + diff --git a/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.scss b/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.scss new file mode 100644 index 0000000..9bf4ecd --- /dev/null +++ b/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.scss @@ -0,0 +1,41 @@ +@use '../../../../../styles/base.scss' as *; +@use '../../../../../styles/forms.scss' as *; +@use '../../../../../styles/icons.scss' as *; +@use '../../../../../styles/collapsible.scss' as *; +@use '../../../../../styles/align.scss' as *; +@use '../../../../../styles/buttons.scss' as *; + +select { + width: 100%; +} + +.remove-image-button { + display: flex; + align-items: center; + gap: $spacing-extra-small; + cursor: pointer; + opacity: $dimmed; + transition: $transition; + &:hover { + opacity: 1; + } + + span { + font-size: $font-size-small; + text-decoration: underline; + } +} + +.product-image-preview { + width: 100%; + height: 80px; + display: flex; + align-items: center; + justify-content: center; + background-color: $darker-background-color; + img { + max-width: 100%; + height: 100%; + object-fit: cover; + } +} diff --git a/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.ts b/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.ts new file mode 100644 index 0000000..4ceb7fd --- /dev/null +++ b/apps/web/src/app/features/account/products/components/price-actions-host/price-actions-host.component.ts @@ -0,0 +1,51 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + ViewChild, +} from '@angular/core'; + +import { + SlidePanelComponent, + ConfirmDialogComponent, +} from '../../../../../shared'; +import { PriceFormComponent } from '../price-form/price-form.component'; + +import { PriceActionsService } from '../../services/price-actions.service'; + +@Component({ + selector: 'app-price-actions-host', + imports: [SlidePanelComponent, PriceFormComponent, ConfirmDialogComponent], + templateUrl: './price-actions-host.component.html', + styleUrl: './price-actions-host.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PriceActionsHostComponent { + readonly actions = inject(PriceActionsService); + @ViewChild('priceForm') priceForm!: PriceFormComponent; + + GetPanelTitle(): string { + return this.actions.panelMode() === 'create' + ? 'Add a price' + : 'Update a price'; + } + + async OnSubmit(): Promise { + if (!this.priceForm) return; + this.actions.panelShowErrors.set(true); + if (!this.priceForm.ValidateAll()) return; + const data = + this.actions.panelMode() === 'create' + ? this.priceForm.CreatePriceFormData() + : this.priceForm.UpdatePriceFormData(); + try { + await this.actions.Save(data); + } catch (error) { + console.error('Failed to save price:', error); + } + } + + OnValidationChange(isValid: boolean): void { + this.actions.panelShowErrors.set(isValid); + } +} diff --git a/apps/web/src/app/features/account/products/components/price-form/price-form.component.html b/apps/web/src/app/features/account/products/components/price-form/price-form.component.html new file mode 100644 index 0000000..955a34b --- /dev/null +++ b/apps/web/src/app/features/account/products/components/price-form/price-form.component.html @@ -0,0 +1,120 @@ +
+
+
+
+ Recurring +
+
+ One-time +
+
+
+
+ +
+
Choose your pricing model
+
+ +
+
A single, fixed price.
+
+ +

Price

+ +
+
Amount (required)
+
+
+ USDC$ + +
+
+ @if (showErrors && unitAmountError()) { +
{{ unitAmountError() }}
+ } +
+ +
+ +@if(selectedPricing() === 'recurring') { +

Billing period

+
+
+ +
+
+
+} + +

Advanced

+ +
+
Price description
+

Use to organise your prices. Not shown to customers.

+
+ +
+
+ +
+
Lookup key
+

+ Lookup keys make it easier to manage and make future pricing changes by + using a unique key (e.g. standard_monthly) for each price, enabling easy + querying and retrieval of specific prices. Lookup keys should be unique + across all prices in your account. +

+
+ +
+
diff --git a/apps/web/src/app/features/account/products/components/price-form/price-form.component.scss b/apps/web/src/app/features/account/products/components/price-form/price-form.component.scss new file mode 100644 index 0000000..27c05d3 --- /dev/null +++ b/apps/web/src/app/features/account/products/components/price-form/price-form.component.scss @@ -0,0 +1,21 @@ +@use '../../../../../styles/base.scss' as *; +@use '../../../../../styles/forms.scss' as *; +@use '../../../../../styles/icons.scss' as *; +@use '../../../../../styles/collapsible.scss' as *; +@use '../../../../../styles/align.scss' as *; +@use '../../../../../styles/buttons.scss' as *; + +select { + width: 100%; +} + +.pricing-model-selector { + padding: $spacing-plus; + background-color: $dark-background-color; + border-radius: $border-radius-small; +} + +.dimmed-disabled { + opacity: $dimmed-extra !important; + cursor: not-allowed; +} diff --git a/apps/web/src/app/features/account/products/components/price-form/price-form.component.ts b/apps/web/src/app/features/account/products/components/price-form/price-form.component.ts new file mode 100644 index 0000000..0521d8b --- /dev/null +++ b/apps/web/src/app/features/account/products/components/price-form/price-form.component.ts @@ -0,0 +1,201 @@ +import { + Component, + Input, + Output, + EventEmitter, + OnInit, + OnChanges, + SimpleChanges, + signal, + WritableSignal, + ChangeDetectionStrategy, + inject, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Price } from '@zoneless/shared-types'; +import { ConfigService } from '../../../../../data'; +import { CreatePriceInput, UpdatePriceInput } from '@zoneless/shared-schemas'; + +export type PriceFormMode = 'create' | 'edit'; + +@Component({ + selector: 'app-price-form', + standalone: true, + imports: [FormsModule], + templateUrl: './price-form.component.html', + styleUrls: ['./price-form.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PriceFormComponent implements OnInit, OnChanges { + readonly configService = inject(ConfigService); + + @Input() price: Price | null = null; + @Input() mode: PriceFormMode = 'create'; + @Input() showErrors = false; + @Input() isOpen = false; + + @Output() formChange = new EventEmitter< + CreatePriceInput | UpdatePriceInput + >(); + @Output() validationChange = new EventEmitter(); + + selectedPricing: WritableSignal<'one-time' | 'recurring'> = + signal('recurring'); + + unitAmount: WritableSignal = signal(0); + unitAmountError: WritableSignal = signal(''); + + interval: WritableSignal<'day' | 'week' | 'month' | 'year'> = signal('month'); + intervalError: WritableSignal = signal(''); + + nickname: WritableSignal = signal(''); + NICKNAME_MAX_LENGTH = 22; + + lookupKey: WritableSignal = signal(''); + LOOKUP_KEY_MAX_LENGTH = 200; + + ngOnInit(): void { + this.InitializeForm(); + } + + ngOnChanges(changes: SimpleChanges): void { + // Reinitialize form when panel opens + if (changes['isOpen'] && this.isOpen) { + this.InitializeForm(); + } + } + + InitializeForm(): void { + if (this.price) { + this.selectedPricing.set(this.price.recurring ? 'recurring' : 'one-time'); + if (this.price.unit_amount) { + this.unitAmount.set(Math.round(this.price.unit_amount / 100)); + } + this.interval.set(this.price.recurring?.interval || 'month'); + this.nickname.set(this.price.nickname || ''); + this.lookupKey.set(this.price.lookup_key || ''); + } + this.EmitFormChange(); + } + + ChangePricing(pricing: 'one-time' | 'recurring'): void { + if (this.mode === 'edit') { + return; + } + this.selectedPricing.set(pricing); + if (pricing === 'recurring') { + this.interval.set('month'); + } + this.EmitFormChange(); + } + + OnUnitAmountChange(value: number): void { + this.unitAmount.set(value); + this.ValidateUnitAmount(); + this.EmitFormChange(); + } + + ValidateUnitAmount(): void { + const unitAmount = this.unitAmount(); + this.unitAmountError.set(''); + if (this.mode === 'edit') { + //Skip for now, we create prices separately after first creation. + return; + } + if (!unitAmount) { + this.unitAmountError.set('Please enter an amount'); + return; + } + if (unitAmount < 0) { + this.unitAmountError.set('Amount must be greater than 0'); + } + } + + OnIntervalChange(value: 'day' | 'week' | 'month' | 'year'): void { + this.interval.set(value); + this.EmitFormChange(); + } + + OnNicknameChange(value: string): void { + this.nickname.set(value.trim()); + this.EmitFormChange(); + } + + OnLookupKeyChange(value: string): void { + this.lookupKey.set(value.trim()); + this.EmitFormChange(); + } + + ValidateAll(): boolean { + this.ValidateUnitAmount(); + return this.IsValid(); + } + + IsValid(): boolean { + return !this.unitAmountError(); + } + + CreatePriceFormData(): CreatePriceInput { + const data: CreatePriceInput = { + nickname: this.nickname(), + unit_amount: this.FormatUnitAmount(this.unitAmount()), + currency: 'usdc', + }; + if (this.selectedPricing() === 'recurring') { + data.recurring = { + interval: this.interval(), + }; + } + if (this.lookupKey()) { + data.lookup_key = this.lookupKey(); + } + return data; + } + + UpdatePriceFormData(): UpdatePriceInput { + const data: UpdatePriceInput = { + nickname: this.nickname(), + }; + if (this.lookupKey()) { + data.lookup_key = this.lookupKey(); + } + return data; + } + + FormatUnitAmount(amount: number): number { + return Math.round(amount * 100); + } + + private EmitFormChange(): void { + if (this.mode === 'create') { + this.formChange.emit(this.CreatePriceFormData()); + } else { + this.formChange.emit(this.UpdatePriceFormData()); + } + this.validationChange.emit(this.IsValid()); + } + + MetadataToArray( + metadata: Record | null | undefined + ): { key: string; value: string }[] { + if (!metadata || Object.keys(metadata).length === 0) { + return [{ key: '', value: '' }]; + } + return Object.entries(metadata).map(([key, value]) => ({ + key, + value: String(value), + })); + } + + FormatMetadata( + metadataArray: { key: string; value: string }[] + ): Record { + const metadata: Record = {}; + for (const entry of metadataArray) { + if (entry.key !== '') { + metadata[entry.key] = entry.value; + } + } + return metadata; + } +} diff --git a/apps/web/src/app/features/account/products/components/product-form/product-form.component.html b/apps/web/src/app/features/account/products/components/product-form/product-form.component.html index 1105210..920542b 100644 --- a/apps/web/src/app/features/account/products/components/product-form/product-form.component.html +++ b/apps/web/src/app/features/account/products/components/product-form/product-form.component.html @@ -193,10 +193,11 @@ Add line -} @if(mode === 'create'){ +}
+@if(mode === 'create'){
Pricing
@@ -250,11 +251,42 @@
Billing Period
-} } +} } @if(mode === 'edit'){ +
+
Pricing
+
+ +
+
+ + + +
+} + + diff --git a/apps/web/src/app/features/account/products/components/product-form/product-form.component.scss b/apps/web/src/app/features/account/products/components/product-form/product-form.component.scss index 1c56d20..9bf4ecd 100644 --- a/apps/web/src/app/features/account/products/components/product-form/product-form.component.scss +++ b/apps/web/src/app/features/account/products/components/product-form/product-form.component.scss @@ -3,6 +3,7 @@ @use '../../../../../styles/icons.scss' as *; @use '../../../../../styles/collapsible.scss' as *; @use '../../../../../styles/align.scss' as *; +@use '../../../../../styles/buttons.scss' as *; select { width: 100%; diff --git a/apps/web/src/app/features/account/products/components/product-form/product-form.component.ts b/apps/web/src/app/features/account/products/components/product-form/product-form.component.ts index 09707f3..485147c 100644 --- a/apps/web/src/app/features/account/products/components/product-form/product-form.component.ts +++ b/apps/web/src/app/features/account/products/components/product-form/product-form.component.ts @@ -10,27 +10,37 @@ import { WritableSignal, ChangeDetectionStrategy, inject, + ViewChild, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Product, MarketingFeature } from '@zoneless/shared-types'; +import { Product, Price, MarketingFeature } from '@zoneless/shared-types'; import { ConfigService } from '../../../../../data'; import { CreateProductInput, UpdateProductInput, } from '@zoneless/shared-schemas'; - +import { + PaginatedListComponent, + PaginatedListColumn, +} from '../../../../../shared'; +import { PriceActionsHostComponent } from '../price-actions-host/price-actions-host.component'; +import { PriceActionsService } from '../../services/price-actions.service'; +import { Subscription } from 'rxjs'; export type ProductFormMode = 'create' | 'edit'; @Component({ selector: 'app-product-form', standalone: true, - imports: [FormsModule], + imports: [FormsModule, PaginatedListComponent, PriceActionsHostComponent], templateUrl: './product-form.component.html', styleUrls: ['./product-form.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProductFormComponent implements OnInit, OnChanges { readonly configService = inject(ConfigService); + readonly priceActions = inject(PriceActionsService); + private sub?: Subscription; + @ViewChild('pricesList') pricesList?: PaginatedListComponent; @Input() product: Product | null = null; @Input() mode: ProductFormMode = 'create'; @@ -72,8 +82,15 @@ export class ProductFormComponent implements OnInit, OnChanges { detailsExpanded: WritableSignal = signal(false); + priceColumns: WritableSignal = signal([]); + priceQueryParams: WritableSignal> = signal({}); + ngOnInit(): void { this.InitializeForm(); + this.sub = this.priceActions.events$.subscribe(() => { + // Any successful action invalidates the list + this.pricesList?.Reload(); + }); } ngOnChanges(changes: SimpleChanges): void { @@ -97,6 +114,10 @@ export class ProductFormComponent implements OnInit, OnChanges { this.name.set(''); } + if (this.mode === 'edit') { + this.InitPriceList(this.product?.id || ''); + } + this.EmitFormChange(); } @@ -318,4 +339,96 @@ export class ProductFormComponent implements OnInit, OnChanges { ]); this.EmitFormChange(); } + + InitPriceList(productId: string): void { + this.priceColumns.set([ + { + header: 'Price', + field: 'unit_amount', + type: 'text', + bolded: true, + formatter: (item: unknown) => { + const price = item as Price; + if (price === null) { + return 'No prices'; + } + const unitAmount = price.unit_amount ?? 0; + if (price.recurring) { + const recurringData = price.recurring; + if (recurringData?.interval === 'day') { + return `$${(unitAmount / 100).toFixed(2)} / day`; + } + if (recurringData?.interval === 'week') { + return `$${(unitAmount / 100).toFixed(2)} / week`; + } + if (recurringData?.interval === 'month') { + return `$${(unitAmount / 100).toFixed(2)} / month`; + } + if (recurringData?.interval === 'year') { + return `$${(unitAmount / 100).toFixed(2)} / year`; + } + } + return `$${(unitAmount / 100).toFixed(2)}`; + }, + }, + { + header: '', + field: 'active', + type: 'status', + formatter: (item: unknown) => { + const price = item as Price; + if (!price.active) { + return 'archived'; + } + if ((this.product?.default_price as Price)?.id === price.id) { + return 'default'; + } + return ''; + }, + }, + { + header: 'Created', + field: 'created', + type: 'date', + }, + { + header: '', + field: '', + type: 'actions', + actions: [ + { + title: 'Copy price ID', + action: (item: Price) => this.priceActions.CopyPriceId(item), + }, + { + title: 'Edit price', + action: (item: Price) => this.priceActions.OpenEdit(item), + disabled: (item: Price) => !item.active, + }, + { + title: 'Archive price', + action: (item: Price) => this.priceActions.OpenArchive(item), + hidden: (item: Price) => !item.active, + disabled: (item: Price) => + item.id === (this.product?.default_price as Price)?.id, + }, + { + title: 'Unarchive price', + action: (item: Price) => this.priceActions.OpenUnarchive(item), + hidden: (item: Price) => item.active, + }, + ], + }, + ]); + + this.priceQueryParams.set({ + product: productId, + }); + } + + AddPrice(): void { + const productId = this.product?.id; + if (!productId) return; + this.priceActions.OpenCreate(productId); + } } diff --git a/apps/web/src/app/features/account/products/products.routes.ts b/apps/web/src/app/features/account/products/products.routes.ts index c35c7f4..a4f270d 100644 --- a/apps/web/src/app/features/account/products/products.routes.ts +++ b/apps/web/src/app/features/account/products/products.routes.ts @@ -1,10 +1,11 @@ import { Routes } from '@angular/router'; import { ProductActionsService } from './services/product-actions.service'; +import { PriceActionsService } from './services/price-actions.service'; export const productRoutes: Routes = [ { path: '', - providers: [ProductActionsService], + providers: [ProductActionsService, PriceActionsService], children: [ { path: '', diff --git a/apps/web/src/app/features/account/products/services/price-actions.service.ts b/apps/web/src/app/features/account/products/services/price-actions.service.ts new file mode 100644 index 0000000..31e9580 --- /dev/null +++ b/apps/web/src/app/features/account/products/services/price-actions.service.ts @@ -0,0 +1,165 @@ +import { inject, Injectable, signal, WritableSignal } from '@angular/core'; +import type { Price } from '@zoneless/shared-types'; +import { Subject } from 'rxjs'; +import { Router } from '@angular/router'; +import { PriceService } from '../../../../data'; +import { CreatePriceInput, UpdatePriceInput } from '@zoneless/shared-schemas'; + +export type PriceActionEvent = + | { type: 'created'; price: Price } + | { type: 'updated'; price: Price } + | { type: 'archived'; price: Price } + | { type: 'unarchived'; price: Price } + | { type: 'deleted'; priceId: string }; + +@Injectable() +export class PriceActionsService { + private readonly priceService = inject(PriceService); + private readonly router = inject(Router); + + // Edit/create panel state + panelOpen: WritableSignal = signal(false); + panelMode: WritableSignal<'create' | 'edit'> = signal('create'); + panelLoading: WritableSignal = signal(false); + panelShowErrors: WritableSignal = signal(false); + priceToEdit: WritableSignal = signal(null); + + // Archive dialog state + archiveDialogOpen = signal(false); + archiving = signal(false); + priceToArchive = signal(null); + + // Unarchive dialog state + unarchiveDialogOpen = signal(false); + unarchiving = signal(false); + priceToUnarchive = signal(null); + + // Delete dialog state + deleteDialogOpen = signal(false); + deleting = signal(false); + priceToDelete = signal(null); + + productId: WritableSignal = signal(null); + + /** Listen to this in catalogue (to reload list) or detail (to refetch / navigate away). */ + readonly events$ = new Subject(); + + CreateEvent(event: PriceActionEvent): void { + this.events$.next(event); + } + + OpenCreate(productId?: string): void { + this.productId.set(productId ?? null); + this.panelMode.set('create'); + this.priceToEdit.set(null); + this.panelShowErrors.set(false); + this.panelOpen.set(true); + } + + OpenEdit(price: Price): void { + this.panelMode.set('edit'); + this.priceToEdit.set(price); + this.panelShowErrors.set(false); + this.panelOpen.set(true); + } + + ClosePanel(): void { + this.panelOpen.set(false); + this.priceToEdit.set(null); + this.panelShowErrors.set(false); + } + + async Save(data: CreatePriceInput | UpdatePriceInput): Promise { + this.panelLoading.set(true); + try { + if (this.panelMode() === 'create') { + const createData = data as CreatePriceInput; + const productId = this.productId(); + if (productId) { + createData.product = productId; + } + const price = await this.priceService.CreatePrice(createData); + this.CreateEvent({ type: 'created', price }); + } else if (this.panelMode() === 'edit') { + const priceToEdit = this.priceToEdit(); + if (priceToEdit) { + const price = await this.priceService.UpdatePrice( + priceToEdit.id, + data as UpdatePriceInput + ); + this.CreateEvent({ type: 'updated', price }); + } + } + this.ClosePanel(); + } catch (error) { + console.error('Failed to create price:', error); + } finally { + this.panelLoading.set(false); + } + } + + OpenArchive(price: Price): void { + this.priceToArchive.set(price); + this.archiveDialogOpen.set(true); + } + + async ConfirmArchive(): Promise { + const price = this.priceToArchive(); + if (!price) return; + this.archiving.set(true); + try { + const updatedPrice = await this.priceService.UpdatePrice(price.id, { + active: false, + }); + this.CreateEvent({ type: 'archived', price: updatedPrice }); + this.archiveDialogOpen.set(false); + this.priceToArchive.set(null); + } finally { + this.archiving.set(false); + } + } + + OpenUnarchive(price: Price): void { + this.priceToUnarchive.set(price); + this.unarchiveDialogOpen.set(true); + } + + async ConfirmUnarchive(): Promise { + const price = this.priceToUnarchive(); + if (!price) return; + this.unarchiving.set(true); + try { + const updatedPrice = await this.priceService.UpdatePrice(price.id, { + active: true, + }); + this.CreateEvent({ type: 'unarchived', price: updatedPrice }); + this.unarchiveDialogOpen.set(false); + this.priceToUnarchive.set(null); + } finally { + this.unarchiving.set(false); + } + } + + OpenDelete(price: Price): void { + this.priceToDelete.set(price); + this.deleteDialogOpen.set(true); + } + + async ConfirmDelete(): Promise { + const price = this.priceToDelete(); + if (!price) return; + this.deleting.set(true); + try { + await this.priceService.DeletePrice(price.id); + this.CreateEvent({ type: 'deleted', priceId: price.id }); + this.deleteDialogOpen.set(false); + this.priceToDelete.set(null); + } finally { + this.deleting.set(false); + } + } + + CopyPriceId(price: Price): void { + navigator.clipboard.writeText(price.id); + } +} diff --git a/apps/web/src/app/features/account/products/services/product-actions.service.ts b/apps/web/src/app/features/account/products/services/product-actions.service.ts index f247392..7fc75ad 100644 --- a/apps/web/src/app/features/account/products/services/product-actions.service.ts +++ b/apps/web/src/app/features/account/products/services/product-actions.service.ts @@ -1,5 +1,5 @@ import { inject, Injectable, signal, WritableSignal } from '@angular/core'; -import type { Product } from '@zoneless/shared-types'; +import type { Product, Price } from '@zoneless/shared-types'; import { Subject } from 'rxjs'; import { Router } from '@angular/router'; import { ProductService } from '../../../../data'; @@ -157,4 +157,11 @@ export class ProductActionsService { this.deleting.set(false); } } + + async SetDefaultPrice(price: Price): Promise { + const product = await this.productService.UpdateProduct(price.product, { + default_price: price.id, + }); + this.CreateEvent({ type: 'updated', product: product }); + } } diff --git a/apps/web/src/app/features/account/products/views/product-detail/product-detail.component.html b/apps/web/src/app/features/account/products/views/product-detail/product-detail.component.html index 49258b3..6eaf5fb 100644 --- a/apps/web/src/app/features/account/products/views/product-detail/product-detail.component.html +++ b/apps/web/src/app/features/account/products/views/product-detail/product-detail.component.html @@ -70,7 +70,19 @@

{{ p.name }}

-

Pricing

+
+

Pricing

+ +
Metadata } + diff --git a/apps/web/src/app/features/account/products/views/product-detail/product-detail.component.ts b/apps/web/src/app/features/account/products/views/product-detail/product-detail.component.ts index b01c819..d608740 100644 --- a/apps/web/src/app/features/account/products/views/product-detail/product-detail.component.ts +++ b/apps/web/src/app/features/account/products/views/product-detail/product-detail.component.ts @@ -6,6 +6,7 @@ import { WritableSignal, OnInit, OnDestroy, + ViewChild, } from '@angular/core'; import { DecimalPipe, DatePipe } from '@angular/common'; import type { Product, Price } from '@zoneless/shared-types'; @@ -20,6 +21,8 @@ import { PaginatedListColumn, } from '../../../../../shared'; import { EventsListComponent } from '../../../components'; +import { PriceActionsService } from '../../services/price-actions.service'; +import { PriceActionsHostComponent } from '../../components/price-actions-host/price-actions-host.component'; import { Subscription } from 'rxjs'; @@ -32,6 +35,7 @@ import { Subscription } from 'rxjs'; DatePipe, PaginatedListComponent, EventsListComponent, + PriceActionsHostComponent, ], templateUrl: './product-detail.component.html', styleUrl: './product-detail.component.scss', @@ -42,6 +46,9 @@ export class ProductDetailComponent implements OnInit, OnDestroy { private readonly router = inject(Router); private readonly productService = inject(ProductService); readonly actions = inject(ProductActionsService); + readonly priceActions = inject(PriceActionsService); + + @ViewChild('pricesList') pricesList?: PaginatedListComponent; product: WritableSignal = signal(null); loading: WritableSignal = signal(false); @@ -51,6 +58,7 @@ export class ProductDetailComponent implements OnInit, OnDestroy { priceQueryParams: WritableSignal> = signal({}); private sub?: Subscription; + private priceSub?: Subscription; productActions: PopupMenuAction[] = [ { @@ -90,10 +98,16 @@ export class ProductDetailComponent implements OnInit, OnDestroy { this.product.set(event.product); } }); + + this.priceSub = this.priceActions.events$.subscribe(() => { + this.pricesList?.Reload(); + this.LoadProduct(id); + }); } ngOnDestroy(): void { this.sub?.unsubscribe(); + this.priceSub?.unsubscribe(); } private async LoadProduct(id: string): Promise { @@ -184,7 +198,10 @@ export class ProductDetailComponent implements OnInit, OnDestroy { type: 'status', formatter: (item: unknown) => { const price = item as Price; - if (price.product === productId) { + if (!price.active) { + return 'archived'; + } + if ((this.product()?.default_price as Price)?.id === price.id) { return 'default'; } return ''; @@ -221,23 +238,31 @@ export class ProductDetailComponent implements OnInit, OnDestroy { type: 'actions', actions: [ { - title: 'Edit product', - action: (item: Product) => this.actions.OpenEdit(item), - disabled: (item: Product) => !item.active, + title: 'Copy price ID', + action: (item: Price) => this.priceActions.CopyPriceId(item), }, { - title: 'Archive product', - action: (item: Product) => this.actions.OpenArchive(item), - hidden: (item: Product) => !item.active, + title: 'Set as default price', + action: (item: Price) => this.actions.SetDefaultPrice(item), + disabled: (item: Price) => + item.id === (this.product()?.default_price as Price)?.id, }, { - title: 'Unarchive product', - action: (item: Product) => this.actions.OpenUnarchive(item), - hidden: (item: Product) => item.active, + title: 'Edit price', + action: (item: Price) => this.priceActions.OpenEdit(item), + disabled: (item: Price) => !item.active, }, { - title: 'Delete product', - action: (item: Product) => this.actions.OpenDelete(item), + title: 'Archive price', + action: (item: Price) => this.priceActions.OpenArchive(item), + hidden: (item: Price) => !item.active, + disabled: (item: Price) => + item.id === (this.product()?.default_price as Price)?.id, + }, + { + title: 'Unarchive price', + action: (item: Price) => this.priceActions.OpenUnarchive(item), + hidden: (item: Price) => item.active, }, ], }, @@ -246,4 +271,9 @@ export class ProductDetailComponent implements OnInit, OnDestroy { product: productId, }); } + + OnAddPrice(): void { + const p = this.product(); + if (p) this.priceActions.OpenCreate(p.id); + } } diff --git a/apps/web/src/app/shared/ui/status-chip/status-chip.component.html b/apps/web/src/app/shared/ui/status-chip/status-chip.component.html index 7c469dc..89e250f 100644 --- a/apps/web/src/app/shared/ui/status-chip/status-chip.component.html +++ b/apps/web/src/app/shared/ui/status-chip/status-chip.component.html @@ -1,3 +1,5 @@ +@if(status) {
{{ GetDisplayText() | titlecase }}
+} diff --git a/apps/web/src/app/styles/base.scss b/apps/web/src/app/styles/base.scss index 4b1207b..7b012ca 100644 --- a/apps/web/src/app/styles/base.scss +++ b/apps/web/src/app/styles/base.scss @@ -37,6 +37,7 @@ $spacing-extra-extra-large: 64px; $spacing-extra-large: 48px; $spacing-large: 32px; $spacing-medium: 24px; +$spacing-plus: 20px; $spacing: 16px; $spacing-snug: 12px; $spacing-small: 8px; diff --git a/apps/web/src/app/styles/buttons.scss b/apps/web/src/app/styles/buttons.scss index f4c7783..0e3e50d 100644 --- a/apps/web/src/app/styles/buttons.scss +++ b/apps/web/src/app/styles/buttons.scss @@ -112,7 +112,7 @@ } .action-button-wide { - width: 100%; + width: 100% !important; text-align: center; display: flex; justify-content: center;