diff --git a/apps/web/src/app/data/services/account.service.ts b/apps/web/src/app/data/services/account.service.ts index b0a5929..4a327fe 100644 --- a/apps/web/src/app/data/services/account.service.ts +++ b/apps/web/src/app/data/services/account.service.ts @@ -1,26 +1,11 @@ import { Injectable, signal, WritableSignal, inject } from '@angular/core'; import { ApiService } from '../../core/services/api.service'; import { Account, LoginLink } from '@zoneless/shared-types'; -import { CreateAccountInput } from '@zoneless/shared-schemas'; - -/** - * Input type for updating an account. - * All fields are optional - only provided fields will be updated. - * Protected fields (id, object, created, payouts_enabled, details_submitted, tos_acceptance) - * cannot be updated directly. - */ -export type AccountUpdateInput = Partial< - Omit< - Account, - | 'id' - | 'object' - | 'created' - | 'payouts_enabled' - | 'details_submitted' - | 'tos_acceptance' - | 'individual' - > ->; +import { + CreateAccountInput, + UpdateAccountInput, +} from '@zoneless/shared-schemas'; +import { SettingsCardRow } from '../../shared'; @Injectable({ providedIn: 'root', @@ -59,7 +44,7 @@ export class AccountService { async UpdateAccount( accountId: string, - data: AccountUpdateInput + data: UpdateAccountInput ): Promise { this.loading.set(true); try { @@ -162,4 +147,38 @@ export class AccountService { return account.email ?? individual?.email ?? account.id; } + + /** + * Display title for the Business details settings card. + */ + GetBusinessDetailsTitle(account: Account | null): string { + if (!account) return 'Business details'; + return ( + account.business_profile?.name?.trim() || + account.settings?.dashboard?.display_name?.trim() || + 'Business details' + ); + } + + GetBusinessDetailsCardRows(account: Account | null): SettingsCardRow[] { + if (!account) return []; + + return [ + { + label: 'Logo', + value: account.settings?.branding?.logo || '—', + type: 'text', + }, + { + label: 'Terms of Service', + value: account.settings?.terms_url || '—', + type: 'text', + }, + { + label: 'Privacy Policy', + value: account.settings?.privacy_url || '—', + type: 'text', + }, + ]; + } } diff --git a/apps/web/src/app/features/account/settings/settings.component.html b/apps/web/src/app/features/account/settings/settings.component.html index ed84ff9..9fd87b7 100644 --- a/apps/web/src/app/features/account/settings/settings.component.html +++ b/apps/web/src/app/features/account/settings/settings.component.html @@ -2,7 +2,21 @@

Settings

- + +@if (authService.isPlatform()) { +
+
Business details
+ +
+} + + +@if (!authService.isPlatform()) {
Personal details
Settings (editClicked)="OnEditPersonClick()" >
+} @if (externalWalletService.wallet()) { @@ -37,7 +52,29 @@

Settings

+ +@if (authService.isPlatform()) { + + + +} + +@if (!authService.isPlatform()) { Settings [showErrors]="editPersonShowErrors()" > +} = signal(false); walletFormValid: WritableSignal = signal(false); + // Edit business details panel state + editBusinessPanelOpen: WritableSignal = signal(false); + editBusinessLoading: WritableSignal = signal(false); + editBusinessShowErrors: WritableSignal = signal(false); + ngOnInit(): void { this.metaService.SetMetaTitle('Settings'); } + // Edit Business Panel + OnEditBusinessClick(): void { + this.editBusinessShowErrors.set(false); + this.editBusinessPanelOpen.set(true); + } + + OnEditBusinessPanelClosed(): void { + this.editBusinessPanelOpen.set(false); + this.editBusinessShowErrors.set(false); + } + + async OnEditBusinessSubmit(): Promise { + if (!this.editBusinessForm) return; + + this.editBusinessShowErrors.set(true); + + if (!this.editBusinessForm.ValidateAll()) { + return; + } + + const account = this.GetAccount(); + if (!account) return; + + this.editBusinessLoading.set(true); + + try { + const updateData = this.editBusinessForm.GetUpdateData(); + await this.accountService.UpdateAccount(account.id, updateData); + this.configService.ClearConfig(); + await this.configService.LoadConfig(); + this.editBusinessPanelOpen.set(false); + this.editBusinessShowErrors.set(false); + } catch (error) { + console.error('Failed to update business details:', error); + } finally { + this.editBusinessLoading.set(false); + } + } + // Edit Person Panel OnEditPersonClick(): void { this.editPersonShowErrors.set(false); diff --git a/apps/web/src/app/features/setup/setup.component.html b/apps/web/src/app/features/setup/setup.component.html index f834849..33228b3 100644 --- a/apps/web/src/app/features/setup/setup.component.html +++ b/apps/web/src/app/features/setup/setup.component.html @@ -47,29 +47,29 @@

This instance is operator-managed

/>

Welcome to Zoneless

- The open-source Stripe Connect alternative for USDC payouts. + The open-source Stripe, built on stablecoins.

- Instant payouts - Instant USDC payouts to your sellers + Instant settlement + Payments settle in seconds, 24/7
- Global payouts - Payouts to anywhere in the world + Same API & dashboard + Stripe-compatible — migrate in an afternoon
Near-zero fees - No platform fees — just minimal Solana gas + Fractions of a cent per payment, not a percentage cut
@@ -79,8 +79,8 @@

Welcome to Zoneless

alt="" />
- No lock-in - Fully open-source and free to use + Self-custody + Your keys, your money — fully open-source
@@ -90,8 +90,8 @@

Welcome to Zoneless

- This setup will only take a minute. You'll need to configure your - platform details and Solana wallet. + This setup will only take a minute. You'll configure your business + details and Solana wallet.

} @@ -101,73 +101,18 @@

Welcome to Zoneless

-

Platform Details

+

Business Details

- Configure your platform's name and branding. + Configure your business name and branding. These appear on Checkout, + Payment Links, and your dashboard.

-
- - - This will be shown to your users during onboarding. -
- -
- - - A square image works best. If not provided, initials will be - shown. -
- -
- - - Your platform's Terms of Service page. Shown to sellers during - onboarding. -
- -
- - - Your platform's Privacy Policy page. Shown to sellers during - onboarding. -
+ @if(error()) {
{{ error() }}
@@ -186,9 +131,9 @@

Platform Details

Solana Wallet

- Configure your platform's Solana wallet for receiving USDC deposits and - sending payouts. Your wallet's secret key is generated locally and never - sent to Zoneless. + Configure the Solana wallet that holds your USDC balance. Your secret + key is generated locally and never sent to Zoneless — self-custody by + default.

@@ -274,7 +219,7 @@

Solana Wallet

{{ generatedPublicKey() }}
- Send USDC to this address to top up your platform balance. + Send USDC to this address to fund your balance.
@@ -293,7 +238,7 @@

Solana Wallet

{{ generatedSecretKey() }} - Store this securely on your server. You'll use it to sign payout + Store this securely on your server. You'll use it to sign transactions. This key is never sent to Zoneless. @@ -312,7 +257,7 @@

Solana Wallet

/> Enter your Solana wallet address. Keep your secret key safe on your - own server - you'll use it to sign payout transactions. + own server — you'll use it to sign transactions. @@ -344,7 +289,7 @@

Solana Wallet

/>

Setup Complete!

- Your platform is configured and ready to use. Save your API key below - + Your payments stack is ready. Save your API key below — it won't be shown again.

@@ -389,7 +334,7 @@

Setup Complete!

{{ setupResult()!.solana_public_key }} - Send USDC to this address to top up your platform balance. + Send USDC to this address to fund your balance. @@ -397,7 +342,7 @@

Setup Complete!

Next Steps

    -
  1. Save your API key - you'll need it to make API requests
  2. +
  3. Save your API key — you'll need it to make API requests
  4. @if(walletOption() === 'generate') {
  5. Save your secret key securely on your server (shown in previous @@ -405,7 +350,10 @@

    Next Steps

  6. }
  7. Fund your Solana wallet with SOL (for fees) and USDC
  8. -
  9. Integrate the Zoneless API into your application
  10. +
  11. + Point your Stripe integration at this instance, or explore the + dashboard +
diff --git a/apps/web/src/app/features/setup/setup.component.ts b/apps/web/src/app/features/setup/setup.component.ts index dbb1f7a..ff7b2dd 100644 --- a/apps/web/src/app/features/setup/setup.component.ts +++ b/apps/web/src/app/features/setup/setup.component.ts @@ -5,13 +5,19 @@ import { inject, signal, WritableSignal, + ViewChild, } from '@angular/core'; import { Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { MetaService, StorageService } from '../../core'; import { SetupService } from '../../data'; -import { PageLoaderComponent, LoaderComponent } from '../../shared'; +import { + BusinessProfileFormComponent, + BusinessProfileSetupData, + PageLoaderComponent, + LoaderComponent, +} from '../../shared'; import { SetupResponse } from '@zoneless/shared-types'; enum SetupStep { @@ -26,10 +32,18 @@ enum SetupStep { templateUrl: './setup.component.html', styleUrls: ['./setup.component.scss'], standalone: true, - imports: [FormsModule, PageLoaderComponent, LoaderComponent], + imports: [ + FormsModule, + PageLoaderComponent, + LoaderComponent, + BusinessProfileFormComponent, + ], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SetupComponent implements OnInit { + @ViewChild('platformDetailsForm') + platformDetailsForm!: BusinessProfileFormComponent; + private readonly meta = inject(MetaService); private readonly router = inject(Router); private readonly storage = inject(StorageService); @@ -42,12 +56,9 @@ export class SetupComponent implements OnInit { submitting: WritableSignal = signal(false); error: WritableSignal = signal(''); operatorMode: WritableSignal = signal(false); - - // Form data - platformName: WritableSignal = signal(''); - platformLogoUrl: WritableSignal = signal(''); - termsUrl: WritableSignal = signal(''); - privacyUrl: WritableSignal = signal(''); + platformShowErrors: WritableSignal = signal(false); + platformDetails: WritableSignal = + signal(null); // Wallet options walletOption: WritableSignal<'generate' | 'import'> = signal('generate'); @@ -124,10 +135,12 @@ export class SetupComponent implements OnInit { return true; case SetupStep.PLATFORM: - if (!this.platformName().trim()) { - this.error.set('Platform name is required'); + this.platformShowErrors.set(true); + if (!this.platformDetailsForm?.ValidateAll()) { return false; } + this.platformDetails.set(this.platformDetailsForm.GetSetupData()); + this.platformShowErrors.set(false); return true; case SetupStep.WALLET: @@ -199,11 +212,14 @@ export class SetupComponent implements OnInit { ? this.generatedPublicKey() : this.importPublicKey().trim(); + const platformDetails = this.platformDetails(); + if (!platformDetails) { + this.error.set('Platform details are missing. Please go back.'); + return; + } + const response = await this.setupService.CompleteSetup({ - platform_name: this.platformName().trim(), - platform_logo_url: this.platformLogoUrl().trim() || undefined, - terms_url: this.termsUrl().trim() || undefined, - privacy_url: this.privacyUrl().trim() || undefined, + ...platformDetails, solana_public_key: publicKey, }); diff --git a/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.html b/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.html new file mode 100644 index 0000000..ff904e2 --- /dev/null +++ b/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.html @@ -0,0 +1,92 @@ +
+
+
Business name
+ @if (mode === 'setup') { +

+ This will be shown on Checkout, Payment Links, and your dashboard. +

+ } + + @if (showErrors && businessNameError()) { +
{{ businessNameError() }}
+ } +
+ +
+
+ Logo URL Optional +
+ @if (mode === 'setup') { +

+ A square image works best. If not provided, initials will be shown. +

+ } + + @if (showErrors && logoUrlError()) { +
{{ logoUrlError() }}
+ } +
+ +
+
+ Terms of Service URL Optional +
+ @if (mode === 'setup') { +

+ Your Terms of Service page. Linked from Checkout and onboarding. +

+ } + + @if (showErrors && termsUrlError()) { +
{{ termsUrlError() }}
+ } +
+ +
+
+ Privacy Policy URL Optional +
+ @if (mode === 'setup') { +

+ Your Privacy Policy page. Linked from Checkout and onboarding. +

+ } + + @if (showErrors && privacyUrlError()) { +
{{ privacyUrlError() }}
+ } +
+
diff --git a/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.scss b/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.scss new file mode 100644 index 0000000..9d276cd --- /dev/null +++ b/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.scss @@ -0,0 +1,18 @@ +@use '../../../styles/base.scss' as *; +@use '../../../styles/forms.scss' as *; + +.business-profile-form { + display: flex; + flex-direction: column; + gap: $spacing; +} + +.field-optional { + font-size: $font-size-small; + font-weight: normal; + color: $text-color; + opacity: $dimmed-extra; + background-color: $darker-background-color; + padding: 2px $spacing-small; + border-radius: $border-radius-small; +} diff --git a/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.ts b/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.ts new file mode 100644 index 0000000..12626f9 --- /dev/null +++ b/apps/web/src/app/shared/forms/business-profile-form/business-profile-form.component.ts @@ -0,0 +1,233 @@ +import { + Component, + Input, + Output, + EventEmitter, + OnInit, + OnChanges, + SimpleChanges, + signal, + WritableSignal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { Account } from '@zoneless/shared-types'; +import { UpdateAccountInput } from '@zoneless/shared-schemas'; + +export type BusinessProfileFormMode = 'setup' | 'edit'; + +export interface BusinessProfileFormData { + businessName: string; + logoUrl: string; + termsUrl: string; + privacyUrl: string; +} + +export interface BusinessProfileSetupData { + platform_name: string; + platform_logo_url?: string; + terms_url?: string; + privacy_url?: string; +} + +@Component({ + selector: 'app-business-profile-form', + standalone: true, + imports: [FormsModule], + templateUrl: './business-profile-form.component.html', + styleUrls: ['./business-profile-form.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BusinessProfileFormComponent implements OnInit, OnChanges { + @Input() mode: BusinessProfileFormMode = 'edit'; + @Input() account: Account | null = null; + @Input() initialData: BusinessProfileSetupData | null = null; + @Input() showErrors = false; + @Input() isOpen = false; + + @Output() formChange = new EventEmitter(); + @Output() validationChange = new EventEmitter(); + + businessName: WritableSignal = signal(''); + businessNameError: WritableSignal = signal(''); + + logoUrl: WritableSignal = signal(''); + logoUrlError: WritableSignal = signal(''); + + termsUrl: WritableSignal = signal(''); + termsUrlError: WritableSignal = signal(''); + + privacyUrl: WritableSignal = signal(''); + privacyUrlError: WritableSignal = signal(''); + + ngOnInit(): void { + this.InitializeForm(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['isOpen'] && this.isOpen) { + this.InitializeForm(); + } + if (changes['account'] && this.mode === 'edit') { + this.InitializeForm(); + } + } + + InitializeForm(): void { + if (this.mode === 'edit' && this.account) { + const name = + this.account.business_profile?.name?.trim() || + this.account.settings?.dashboard?.display_name?.trim() || + ''; + this.businessName.set(name); + this.logoUrl.set(this.account.settings?.branding?.logo || ''); + this.termsUrl.set(this.account.settings?.terms_url || ''); + this.privacyUrl.set(this.account.settings?.privacy_url || ''); + } else if (this.mode === 'setup' && this.initialData) { + this.businessName.set(this.initialData.platform_name || ''); + this.logoUrl.set(this.initialData.platform_logo_url || ''); + this.termsUrl.set(this.initialData.terms_url || ''); + this.privacyUrl.set(this.initialData.privacy_url || ''); + } + + this.businessNameError.set(''); + this.logoUrlError.set(''); + this.termsUrlError.set(''); + this.privacyUrlError.set(''); + + this.EmitFormChange(); + } + + OnBusinessNameChange(value: string): void { + this.businessName.set(value); + this.ValidateBusinessName(); + this.EmitFormChange(); + } + + OnLogoUrlChange(value: string): void { + this.logoUrl.set(value); + this.ValidateOptionalUrl(value, this.logoUrlError); + this.EmitFormChange(); + } + + OnTermsUrlChange(value: string): void { + this.termsUrl.set(value); + this.ValidateOptionalUrl(value, this.termsUrlError); + this.EmitFormChange(); + } + + OnPrivacyUrlChange(value: string): void { + this.privacyUrl.set(value); + this.ValidateOptionalUrl(value, this.privacyUrlError); + this.EmitFormChange(); + } + + ValidateBusinessName(): void { + const name = this.businessName().trim(); + if (!name) { + this.businessNameError.set('Business name is required'); + return; + } + this.businessNameError.set(''); + } + + ValidateOptionalUrl( + value: string, + errorSignal: WritableSignal + ): void { + const trimmed = value.trim(); + if (!trimmed) { + errorSignal.set(''); + return; + } + if (!this.IsValidUrl(trimmed)) { + errorSignal.set('Please enter a valid URL'); + return; + } + errorSignal.set(''); + } + + ValidateAll(): boolean { + this.ValidateBusinessName(); + this.ValidateOptionalUrl(this.logoUrl(), this.logoUrlError); + this.ValidateOptionalUrl(this.termsUrl(), this.termsUrlError); + this.ValidateOptionalUrl(this.privacyUrl(), this.privacyUrlError); + + return ( + !this.businessNameError() && + !this.logoUrlError() && + !this.termsUrlError() && + !this.privacyUrlError() + ); + } + + IsValid(): boolean { + return ( + !!this.businessName().trim() && + !this.businessNameError() && + !this.logoUrlError() && + !this.termsUrlError() && + !this.privacyUrlError() + ); + } + + GetFormData(): BusinessProfileFormData { + return { + businessName: this.businessName(), + logoUrl: this.logoUrl(), + termsUrl: this.termsUrl(), + privacyUrl: this.privacyUrl(), + }; + } + + /** + * Account update payload for Settings edit mode. + * Empty optional URLs are sent as null so they clear existing values. + */ + GetUpdateData(): UpdateAccountInput { + const name = this.businessName().trim(); + return { + business_profile: { name }, + settings: { + branding: { logo: this.logoUrl().trim() || null }, + dashboard: { display_name: name }, + terms_url: this.termsUrl().trim() || null, + privacy_url: this.privacyUrl().trim() || null, + }, + }; + } + + /** + * Setup request fields for the Platform Details step. + */ + GetSetupData(): BusinessProfileSetupData { + const data: BusinessProfileSetupData = { + platform_name: this.businessName().trim(), + }; + + const logo = this.logoUrl().trim(); + if (logo) data.platform_logo_url = logo; + + const terms = this.termsUrl().trim(); + if (terms) data.terms_url = terms; + + const privacy = this.privacyUrl().trim(); + if (privacy) data.privacy_url = privacy; + + return data; + } + + private IsValidUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } + } + + private EmitFormChange(): void { + this.formChange.emit(this.GetFormData()); + this.validationChange.emit(this.IsValid()); + } +} diff --git a/apps/web/src/app/shared/forms/index.ts b/apps/web/src/app/shared/forms/index.ts index 9b5c38c..1d308cc 100644 --- a/apps/web/src/app/shared/forms/index.ts +++ b/apps/web/src/app/shared/forms/index.ts @@ -1,2 +1,3 @@ export * from './person-form/person-form.component'; export * from './external-wallet-form/external-wallet-form.component'; +export * from './business-profile-form/business-profile-form.component'; diff --git a/apps/web/src/app/styles/forms.scss b/apps/web/src/app/styles/forms.scss index 2c0fd31..f99b13b 100644 --- a/apps/web/src/app/styles/forms.scss +++ b/apps/web/src/app/styles/forms.scss @@ -149,6 +149,7 @@ input[type='date'], input[type='number'], input[type='email'], input[type='password'], +input[type='url'], input[type='time'], input[type='datetime-local'], input[type='tel'] {