From f5ad6e3d2f3012d0e007b6a1825a07a7c6447c44 Mon Sep 17 00:00:00 2001 From: Ben Stokes Date: Fri, 22 May 2026 09:26:17 +0100 Subject: [PATCH] feat: individual price detail page --- .../app/features/account/account.routes.ts | 5 + .../product-form/product-form.component.html | 1 + .../product-form/product-form.component.ts | 4 + .../account/products/prices.routes.ts | 23 ++ .../price-detail/price-detail.component.html | 219 ++++++++++++++++++ .../price-detail/price-detail.component.scss | 55 +++++ .../price-detail/price-detail.component.ts | 92 ++++++++ .../product-detail.component.html | 1 + .../product-detail.component.ts | 4 + apps/web/src/app/styles/spacing.scss | 41 ++++ apps/web/src/assets/icons/content_copy.svg | 2 +- apps/web/src/assets/icons/edit_square.svg | 1 + apps/web/src/assets/icons/sell_outline.svg | 1 + apps/web/src/assets/icons/undo.svg | 1 + apps/web/src/styles.scss | 183 +-------------- 15 files changed, 454 insertions(+), 179 deletions(-) create mode 100644 apps/web/src/app/features/account/products/prices.routes.ts create mode 100644 apps/web/src/app/features/account/products/views/price-detail/price-detail.component.html create mode 100644 apps/web/src/app/features/account/products/views/price-detail/price-detail.component.scss create mode 100644 apps/web/src/app/features/account/products/views/price-detail/price-detail.component.ts create mode 100644 apps/web/src/app/styles/spacing.scss create mode 100644 apps/web/src/assets/icons/edit_square.svg create mode 100644 apps/web/src/assets/icons/sell_outline.svg create mode 100644 apps/web/src/assets/icons/undo.svg diff --git a/apps/web/src/app/features/account/account.routes.ts b/apps/web/src/app/features/account/account.routes.ts index 77cef4c..d5331b4 100644 --- a/apps/web/src/app/features/account/account.routes.ts +++ b/apps/web/src/app/features/account/account.routes.ts @@ -38,4 +38,9 @@ export const accountRoutes: Routes = [ loadChildren: () => import('./products/products.routes').then((m) => m.productRoutes), }, + { + path: 'prices', + loadChildren: () => + import('./products/prices.routes').then((m) => m.priceRoutes), + }, ]; 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 920542b..fc3e397 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 @@ -270,6 +270,7 @@ [queryParams]="priceQueryParams()" [paginationEnabled]="false" [hideColumnHeadings]="true" + (rowClick)="OnPriceListClick($event)" > 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 485147c..15c08c4 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 @@ -431,4 +431,8 @@ export class ProductFormComponent implements OnInit, OnChanges { if (!productId) return; this.priceActions.OpenCreate(productId); } + + OnPriceListClick(price: Price): void { + this.priceActions.OpenEdit(price); + } } diff --git a/apps/web/src/app/features/account/products/prices.routes.ts b/apps/web/src/app/features/account/products/prices.routes.ts new file mode 100644 index 0000000..26d86e4 --- /dev/null +++ b/apps/web/src/app/features/account/products/prices.routes.ts @@ -0,0 +1,23 @@ +import { Routes } from '@angular/router'; +import { PriceActionsService } from './services/price-actions.service'; + +export const priceRoutes: Routes = [ + { + path: '', + providers: [PriceActionsService], + children: [ + { + path: '', + redirectTo: '/account/products', + pathMatch: 'full', + }, + { + path: ':priceId', + loadComponent: () => + import('./views/price-detail/price-detail.component').then( + (m) => m.PriceDetailComponent + ), + }, + ], + }, +]; diff --git a/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.html b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.html new file mode 100644 index 0000000..3f17019 --- /dev/null +++ b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.html @@ -0,0 +1,219 @@ +@if(price(); as price){ @if(!price.active && archivedBannedOpen()){ +
+
+

This price has been archived

+
+ This price can't be added to new invoices, subscriptions, or payment + links. Any existing subscriptions remain active until cancelled and any + existing payment links are deactivated. +
+
+ + Close icon +
+} + +
+
+ Sale tag icon +
PRICE
+
+
+
{{ price.id }}
+ Copy icon +
+
+ +
+ @if(price.nickname){ +

{{ price.nickname }}

+ } @else { +

Price for {{ price.product }}

+ } +
+ + + +
+
+ +
+ +
+
+
Product
+ {{ price.product }} +
+
+
+
Unit Price
+
+ ${{ (price.unit_amount ?? 0) / 100 | number : '1.2-2' }} + @if (price.recurring) { + / + } @switch (price.recurring?.interval) { @case ('day') { + day + } @case ('week') { + week + } @case ('month') { + month + } @case ('year') { + year + } @default { + + } } +
+
+
+
+
+
Trial Period Days
+ Legacy +
+
+
+
+
+
Subscriptions
+
+
+
+
+
MRR
+
+
+
+ +
+

Metadata

+
+ +
+
+
+ Use metadata to store custom additional information. + View docs +
+
+
No metadata
+
+ +

Pricing

+
+ + + + + + + + + + + + + @if(price.recurring){ @switch (price.recurring.interval) { @case ('day') { + + } @case ('week') { + + } @case ('month') { + + } @case ('year') { + + } @default { + + } } } @else { + + } + + + + + + + + + + +
TypeFlat rate
Currency{{ price.currency | uppercase }}
IntervalDailyWeeklyMonthlyYearlyOne-time
Price per unitUSDC${{ (price.unit_amount ?? 0) / 100 | number : '1.2-2' }}
Default price-
+
+ +

Currencies

+
+
No other currency options
+
+ +
+

Upsells

+
Boosts revenue
+
+
+
+ Upsells to + +
+
+ +

Events

+
+ +} + + diff --git a/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.scss b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.scss new file mode 100644 index 0000000..900e281 --- /dev/null +++ b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.scss @@ -0,0 +1,55 @@ +@use '../../../../../styles/base.scss' as *; +@use '../../../../../styles/chips.scss' as *; +@use '../../../../../styles/buttons.scss' as *; +@use '../../../../../styles/account.scss' as *; +@use '../../../../../styles/align.scss' as *; +@use '../../../../../styles/spacing.scss' as *; +@use '../../../../../styles/forms.scss' as *; + +.archived-banner { + padding: $spacing; + background-color: $darker-background-color; + border-radius: $border-radius-small; + margin-bottom: $spacing-large; +} + +.archived-banner .action-button { + background-color: $background-color; +} + +.archived-banner h4 { + margin-bottom: $spacing-small; +} + +.vertical-line { + width: 1px; + align-self: stretch; + flex-shrink: 0; + background-color: $darkest-background-color; +} + +table { + width: 100%; + max-width: 400px; + border-collapse: collapse; +} + +table td { + padding: $spacing-extra-small 0px; +} + +.cross-sell-input { + span { + white-space: nowrap; + } + + input { + max-width: 360px; + } +} + +@media only screen and (max-width: 1000px) { + table { + max-width: 100%; + } +} diff --git a/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.ts b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.ts new file mode 100644 index 0000000..c6f01e9 --- /dev/null +++ b/apps/web/src/app/features/account/products/views/price-detail/price-detail.component.ts @@ -0,0 +1,92 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, + WritableSignal, +} from '@angular/core'; +import { DecimalPipe, UpperCasePipe } from '@angular/common'; +import type { Price } from '@zoneless/shared-types'; +import { PriceService } from '../../../../../data'; +import { PriceActionsService } from '../../services/price-actions.service'; +import { PriceActionsHostComponent } from '../../components/price-actions-host/price-actions-host.component'; +import { ActivatedRoute, Router } from '@angular/router'; +import { PopupMenuAction, PopupMenuComponent } from '../../../../../shared'; +import { EventsListComponent } from '../../../components'; + +import { Subscription } from 'rxjs'; + +@Component({ + selector: 'app-price-detail', + imports: [ + PriceActionsHostComponent, + PopupMenuComponent, + DecimalPipe, + UpperCasePipe, + EventsListComponent, + ], + templateUrl: './price-detail.component.html', + styleUrl: './price-detail.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PriceDetailComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly priceService = inject(PriceService); + readonly priceActions = inject(PriceActionsService); + + loading: WritableSignal = signal(false); + archivedBannedOpen: WritableSignal = signal(true); + + price: WritableSignal = signal(null); + private sub?: Subscription; + + popupMenuActions: PopupMenuAction[] = [ + { + title: 'Archive Price', + action: () => this.priceActions.OpenArchive(this.price() as Price), + hidden: (item: Price) => !item.active, + }, + { + title: 'Unarchive Price', + action: () => this.priceActions.OpenUnarchive(this.price() as Price), + hidden: (item: Price) => item.active, + }, + ]; + + async ngOnInit(): Promise { + const id = this.route.snapshot.paramMap.get('priceId'); + if (!id) return; + await this.LoadPrice(id); + this.sub = this.priceActions.events$.subscribe((event) => { + if (event.type === 'deleted' && event.priceId === id) { + this.router.navigate(['/account/products']); + } else if ( + (event.type === 'updated' || + event.type === 'archived' || + event.type === 'unarchived') && + event.price.id === id + ) { + this.price.set(event.price); + } + }); + } + + private async LoadPrice(id: string): Promise { + this.loading.set(true); + try { + this.price.set(await this.priceService.GetPrice(id)); + console.log(this.price()); + } finally { + this.loading.set(false); + } + } + + CloseArchivedBanned(): void { + this.archivedBannedOpen.set(false); + } + + GoToProduct(): void { + this.router.navigate(['/account/products', this.price()?.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 6eaf5fb..d5fb7d4 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 @@ -90,6 +90,7 @@

Pricing

[limit]="10" [queryParams]="priceQueryParams()" [paginationEnabled]="false" + (rowClick)="OnPriceListClick($event)" >
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 d608740..289ce53 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 @@ -276,4 +276,8 @@ export class ProductDetailComponent implements OnInit, OnDestroy { const p = this.product(); if (p) this.priceActions.OpenCreate(p.id); } + + OnPriceListClick(price: Price): void { + this.router.navigate(['/account/prices', price.id]); + } } diff --git a/apps/web/src/app/styles/spacing.scss b/apps/web/src/app/styles/spacing.scss new file mode 100644 index 0000000..6e30a30 --- /dev/null +++ b/apps/web/src/app/styles/spacing.scss @@ -0,0 +1,41 @@ +@use './base.scss' as *; + +.mb-zero { + margin-bottom: 0; +} + +.mb-extra-small { + margin-bottom: $spacing-extra-small; +} + +.mb-small { + margin-bottom: $spacing-small; +} + +.mb-snug { + margin-bottom: $spacing-snug; +} + +.mb { + margin-bottom: $spacing; +} + +.mb-plus { + margin-bottom: $spacing-plus; +} + +.mb-medium { + margin-bottom: $spacing-medium; +} + +.mb-large { + margin-bottom: $spacing-large; +} + +.mb-extra-large { + margin-bottom: $spacing-extra-large; +} + +.mb-extra-extra-large { + margin-bottom: $spacing-extra-extra-large; +} diff --git a/apps/web/src/assets/icons/content_copy.svg b/apps/web/src/assets/icons/content_copy.svg index 5d2b365..e066eb9 100644 --- a/apps/web/src/assets/icons/content_copy.svg +++ b/apps/web/src/assets/icons/content_copy.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/apps/web/src/assets/icons/edit_square.svg b/apps/web/src/assets/icons/edit_square.svg new file mode 100644 index 0000000..d5dd627 --- /dev/null +++ b/apps/web/src/assets/icons/edit_square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/assets/icons/sell_outline.svg b/apps/web/src/assets/icons/sell_outline.svg new file mode 100644 index 0000000..ed983ac --- /dev/null +++ b/apps/web/src/assets/icons/sell_outline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/assets/icons/undo.svg b/apps/web/src/assets/icons/undo.svg new file mode 100644 index 0000000..6411851 --- /dev/null +++ b/apps/web/src/assets/icons/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 5fb0954..8cfb4ee 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -24,10 +24,6 @@ src: url(/assets/fonts/InstrumentSans-Medium.ttf) format('truetype'); } -.grecaptcha-badge { - visibility: hidden; -} - * { box-sizing: border-box; } @@ -153,64 +149,6 @@ em { margin: auto; } -.site-wrapper-narrow { - max-width: 1000px; -} - -.prompts-wrapper { - padding-left: $spacing-extra-extra-large; - padding-top: $spacing-extra-large; - padding-bottom: $spacing-extra-large; - max-width: 2000px; - margin: auto; -} - -.browse-section { - text-align: center; -} - -.two-col { - display: flex; - justify-content: center; - text-align: left; -} - -.left-col { - padding-right: $spacing-large; - width: 50%; - text-align: left; -} - -.right-col { - width: 50%; -} - -@media only screen and (max-width: 1000px) { - .site-wrapper { - padding: 16px; - padding-top: 32px; - } - - .prompts-wrapper { - padding-left: 16px; - } - - .two-col { - display: block; - } - - .left-col, - .right-col { - width: 100%; - padding: 0px; - text-align: center; - } - - .left-col { - padding-bottom: $spacing-large; - } -} - .icon { width: 16px; height: 16px; @@ -231,41 +169,6 @@ em { opacity: 1; } -.info-icon-container { - position: relative; - cursor: pointer; -} - -.info-text { - opacity: 0; - visibility: hidden; - position: absolute; - top: 110%; - left: 50%; - transform: translateX(-50%) translateY(-90%); - background-color: $darker-background-color; - border: 1px solid $background-color; - padding: $spacing $spacing-small; - width: 200px; - text-align: center; - font-size: $font-size; - color: white; - border-radius: $border-radius-small; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.3); - font-weight: 400; - transition: opacity $transition ease, visibility $transition ease, - transform $transition ease, box-shadow $transition ease; - z-index: 10; -} - -.info-icon-container:hover .info-text { - opacity: 1; - visibility: visible; - top: 100%; - transform: translateX(-50%) translateY(-100%); - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); -} - .line { width: 100%; height: 1px; @@ -290,69 +193,6 @@ em { text-decoration: underline !important; } -.hovertext { - position: relative; -} - -.hovertext:before { - content: attr(data-hover); - visibility: hidden; - opacity: 0; - background-color: $dark-background-color; - color: $text-color; - text-align: center; - border-radius: $border-radius-small; - border: 1px solid $mid-background-color; - padding: $spacing-extra-small $spacing-small; - transition: opacity 100ms ease-in-out; - position: absolute; - z-index: 1; - left: 0; - top: 110%; - font-size: $font-size-small; - min-width: 100px; - box-shadow: 2px 3px 9px 0px rgba(0, 0, 0, 0.25); - -webkit-box-shadow: 2px 3px 9px 0px rgba(0, 0, 0, 0.25); - -moz-box-shadow: 2px 3px 9px 0px rgba(0, 0, 0, 0.25); -} - -.hovertext:hover:before { - opacity: 1; - visibility: visible; -} - -.skeleton-text { - background: linear-gradient( - 270deg, - $dark-background-color, - $darker-background-color - ); - background-size: 400% 400%; - border-radius: 2px; -} - -.skeleton-background { - background: linear-gradient( - 270deg, - $dark-background-color, - $darker-background-color - ); - background-size: 400% 400%; -} - -/*Search suggestions bolded from pipe*/ -.highlight-text { - font-weight: bold; -} - -@media only screen and (max-width: 1000px) { - /*innerHTML requires global styling (in tutorial-app-editor.html)*/ - .tutorial-text, - .tutorial-text * { - font-size: $font-size-small; - } -} - .dimmed { opacity: $dimmed; } @@ -381,26 +221,13 @@ em { cursor: not-allowed !important; } -.show-mobile { - display: none; +.bolded { + font-weight: 500; } @media only screen and (max-width: 1000px) { - .show-desktop { - display: none; - } - - .show-mobile { - display: block; + .site-wrapper { + padding: 16px; + padding-top: 32px; } } - -.more-menu-item-description a { - font-size: $font-size-small; -} - -.more-menu-item-description img { - max-width: 100%; - border-radius: $border-radius-small; - margin-bottom: $spacing-small; -}