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
1 change: 1 addition & 0 deletions apps/web/src/app/data/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
56 changes: 56 additions & 0 deletions apps/web/src/app/data/services/price.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> = signal(false);

async CreatePrice(data: CreatePriceInput): Promise<Price> {
this.loading.set(true);
try {
const price = await this.api.Call<Price>('POST', `prices`, data);
return price;
} finally {
this.loading.set(false);
}
}

async UpdatePrice(priceId: string, data: UpdatePriceInput): Promise<Price> {
this.loading.set(true);
try {
const price = await this.api.Call<Price>(
'POST',
`prices/${priceId}`,
data
);
return price;
} finally {
this.loading.set(false);
}
}

async DeletePrice(priceId: string): Promise<void> {
this.loading.set(true);
try {
await this.api.Call<void>('DELETE', `prices/${priceId}`);
} finally {
this.loading.set(false);
}
}

async GetPrice(priceId: string): Promise<Price> {
this.loading.set(true);
try {
const price = await this.api.Call<Price>('GET', `prices/${priceId}`);
return price;
} finally {
this.loading.set(false);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<!-- Create Price Slide Panel -->
<app-slide-panel
[isOpen]="actions.panelOpen()"
[title]="GetPanelTitle()"
[loading]="actions.panelLoading()"
submitLabel="Save"
[submitDisabled]="false"
(closed)="actions.ClosePanel()"
(submitted)="OnSubmit()"
>
<app-price-form
#priceForm
[mode]="actions.panelMode()"
[price]="actions.priceToEdit()"
[isOpen]="actions.panelOpen()"
[showErrors]="actions.panelShowErrors()"
(validationChange)="OnValidationChange($event)"
></app-price-form>
</app-slide-panel>

<app-confirm-dialog
[isOpen]="actions.archiveDialogOpen()"
title="Archive price"
description="Archiving will hide this price from new purchases. Are you sure you want to archive this price?"
confirmLabel="Archive price"
[loading]="actions.archiving()"
(confirmed)="actions.ConfirmArchive()"
(cancelled)="actions.archiveDialogOpen.set(false)"
></app-confirm-dialog>

<app-confirm-dialog
[isOpen]="actions.unarchiveDialogOpen()"
title="Unarchive price"
description="Unarchiving will make this price available for new purchases. Are you sure you want to unarchive this price?"
confirmLabel="Unarchive price"
[loading]="actions.unarchiving()"
(confirmed)="actions.ConfirmUnarchive()"
(cancelled)="actions.unarchiveDialogOpen.set(false)"
></app-confirm-dialog>

<app-confirm-dialog
[isOpen]="actions.deleteDialogOpen()"
title="Delete price"
description="Deleting this price will permanently remove it from the price catalogue. Are you sure you want to delete this price?"
confirmLabel="Delete price"
[loading]="actions.deleting()"
[destructive]="true"
(confirmed)="actions.ConfirmDelete()"
(cancelled)="actions.deleteDialogOpen.set(false)"
></app-confirm-dialog>
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<div class="field">
<div class="field-value">
<div class="field-pill">
<div
class="field-pill-option"
role="button"
tabindex="0"
[class.field-pill-option-selected]="selectedPricing() === 'recurring'"
(click)="ChangePricing('recurring')"
(keydown.enter)="ChangePricing('recurring')"
(keydown.space)="ChangePricing('recurring')"
[class.dimmed-disabled]="mode === 'edit'"
>
<span>Recurring</span>
</div>
<div
class="field-pill-option"
role="button"
tabindex="0"
[class.field-pill-option-selected]="selectedPricing() === 'one-time'"
(click)="ChangePricing('one-time')"
(keydown.enter)="ChangePricing('one-time')"
(keydown.space)="ChangePricing('one-time')"
[class.dimmed-disabled]="mode === 'edit'"
>
<span>One-time</span>
</div>
</div>
</div>
</div>

<div class="field pricing-model-selector">
<div class="field-title">Choose your pricing model</div>
<div class="field-value">
<select
[disabled]="mode === 'edit'"
[class.dimmed-disabled]="mode === 'edit'"
>
<option value="">Flat rate</option>
</select>
</div>
<div class="field-help">A single, fixed price.</div>
</div>

<h3>Price</h3>

<div class="field">
<div class="field-title">Amount (required)</div>
<div class="field-value">
<div class="dollar-input">
<span [class.dimmed-disabled]="mode === 'edit'">USDC$</span>
<input
[ngModel]="unitAmount()"
(ngModelChange)="OnUnitAmountChange($event)"
type="number"
[class.field-error]="showErrors && unitAmountError()"
[disabled]="mode === 'edit'"
[class.dimmed-disabled]="mode === 'edit'"
/>
</div>
</div>
@if (showErrors && unitAmountError()) {
<div class="error">{{ unitAmountError() }}</div>
}
</div>

<div class="line"></div>

@if(selectedPricing() === 'recurring') {
<h3>Billing period</h3>
<div class="field">
<div class="field-value">
<select
[ngModel]="interval()"
(ngModelChange)="OnIntervalChange($event)"
[disabled]="mode === 'edit'"
[class.dimmed-disabled]="mode === 'edit'"
>
<option value="day">Daily</option>
<option value="week">Weekly</option>
<option value="month">Monthly</option>
<option value="year">Yearly</option>
</select>
</div>
</div>
<div class="line"></div>
}

<h3>Advanced</h3>

<div class="field">
<div class="field-title">Price description</div>
<p class="field-help">Use to organise your prices. Not shown to customers.</p>
<div class="field-value">
<input
[ngModel]="nickname()"
(ngModelChange)="OnNicknameChange($event)"
type="text"
[maxlength]="NICKNAME_MAX_LENGTH"
/>
</div>
</div>

<div class="field">
<div class="field-title">Lookup key</div>
<p class="field-help">
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.
</p>
<div class="field-value">
<input
[ngModel]="lookupKey()"
(ngModelChange)="OnLookupKeyChange($event)"
type="text"
[maxlength]="LOOKUP_KEY_MAX_LENGTH"
/>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading