Skip to content

Commit f0b0459

Browse files
committed
feat: @hanzo/shop payment popup + Square billing components
- @hanzo/shop: new embeddable 3-tab topup widget (card/crypto/bank) - HanzoTopup dialog, TopupButton trigger - CardInput (Square Web Payments SDK), CryptoInput (MPC chains), BankInput (ACH) - hooks: useSquareCard, useTopup - @hanzo/commerce client: add getWalletAddress, addBankAccount, addCryptoWallet, topup - @hanzo/ui billing: comprehensive billing dashboard components - OverviewDashboard, PaymentManager (Square), CreditsPanel, UsagePanel - InvoicesPayments, TransactionsPanel, SquareCardForm - CostExplorer, SpendAlerts, AccountMembers, AccountSwitcher - BillingSettings, SupportTiersPanel, PromotionsPanel - GuidedSetup, AnimatedCard, StatusBar
1 parent 95e090b commit f0b0459

38 files changed

+6131
-400
lines changed

pkg/commerce/client.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,17 @@ export type PaymentMethod = {
270270
expMonth: number
271271
expYear: number
272272
}
273+
bank_account?: {
274+
bankName?: string
275+
last4?: string
276+
accountType?: 'checking' | 'savings'
277+
routingNumber?: string
278+
}
279+
crypto?: {
280+
chain: string
281+
address: string
282+
label?: string
283+
}
273284
providerRef?: string
274285
providerType?: string
275286
createdAt?: string
@@ -687,6 +698,73 @@ export class Commerce {
687698
method: 'POST', body: params, token,
688699
})
689700
}
701+
702+
// -----------------------------------------------------------------------
703+
// Crypto wallet deposit address
704+
// -----------------------------------------------------------------------
705+
706+
/**
707+
* Get or create a deposit address for a given chain.
708+
* Used for crypto top-up — user sends funds to this address.
709+
*/
710+
async getWalletAddress(params: { chain: string; userId: string }, token?: string): Promise<{ address: string; chain: string; qrCode?: string }> {
711+
return this.request<{ address: string; chain: string; qrCode?: string }>('/api/wallet/account', {
712+
method: 'POST',
713+
body: { name: `${params.userId}-${params.chain}`, blockchainType: params.chain },
714+
token,
715+
})
716+
}
717+
718+
// -----------------------------------------------------------------------
719+
// Bank account (ACH) and crypto payment methods
720+
// -----------------------------------------------------------------------
721+
722+
/**
723+
* Add a bank account payment method (ACH).
724+
* plaidToken from Plaid Link or Square bank OAuth.
725+
*/
726+
async addBankAccount(
727+
params: {
728+
customerId: string
729+
plaidToken?: string // Plaid public token
730+
bankName?: string
731+
accountType?: 'checking' | 'savings'
732+
},
733+
token?: string,
734+
): Promise<PaymentMethod> {
735+
return this.request<PaymentMethod>('/v1/billing/payment-methods', {
736+
method: 'POST',
737+
body: { customerId: params.customerId, type: 'bank_account', plaidToken: params.plaidToken, bankName: params.bankName, accountType: params.accountType },
738+
token,
739+
})
740+
}
741+
742+
async addCryptoWallet(
743+
params: { customerId: string; chain: string; address: string; label?: string },
744+
token?: string,
745+
): Promise<PaymentMethod> {
746+
return this.request<PaymentMethod>('/v1/billing/payment-methods', {
747+
method: 'POST',
748+
body: { customerId: params.customerId, type: 'crypto', chain: params.chain, address: params.address, label: params.label },
749+
token,
750+
})
751+
}
752+
753+
// -----------------------------------------------------------------------
754+
// Top-up
755+
// -----------------------------------------------------------------------
756+
757+
/**
758+
* Convenience: charge a saved payment method and deposit credits.
759+
* amount is in cents.
760+
*/
761+
async topup(params: { userId: string; paymentMethodId: string; amountCents: number; currency?: string }, token?: string): Promise<Transaction> {
762+
return this.request<Transaction>('/v1/billing/topup', {
763+
method: 'POST',
764+
body: params,
765+
token,
766+
})
767+
}
690768
}
691769

692770
// ---------------------------------------------------------------------------

pkg/shop/components/BankInput.tsx

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
'use client'
2+
3+
/**
4+
* BankInput — ACH / wire transfer instructions.
5+
*
6+
* Shows Bank of America wire details and routing numbers.
7+
* No live API calls needed — static info with clipboard copy.
8+
*/
9+
10+
import React, { useState } from 'react'
11+
12+
interface BankField {
13+
label: string
14+
value: string
15+
}
16+
17+
const WIRE_FIELDS: BankField[] = [
18+
{ label: 'Bank', value: 'Bank of America' },
19+
{ label: 'Beneficiary', value: 'Hanzo AI, Inc.' },
20+
{ label: 'Account Number', value: '325070760' },
21+
{ label: 'ACH Routing', value: '113000023' },
22+
{ label: 'Wire Routing', value: '026009593' },
23+
{ label: 'SWIFT / BIC', value: 'BOFAUS3N' },
24+
]
25+
26+
const MEMO_HINT = 'Include your User ID as the payment memo / reference'
27+
28+
export interface BankInputProps {
29+
userId: string
30+
amountCents: number
31+
currency: string
32+
}
33+
34+
export function BankInput({ userId, amountCents, currency }: BankInputProps) {
35+
const [copiedField, setCopiedField] = useState<string | null>(null)
36+
37+
const formattedAmount = new Intl.NumberFormat('en-US', {
38+
style: 'currency',
39+
currency: currency.toUpperCase(),
40+
}).format(amountCents / 100)
41+
42+
const copy = async (label: string, value: string) => {
43+
await navigator.clipboard.writeText(value)
44+
setCopiedField(label)
45+
setTimeout(() => setCopiedField(null), 2000)
46+
}
47+
48+
return (
49+
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
50+
<p style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', margin: 0 }}>
51+
Wire or ACH transfer {formattedAmount} to:
52+
</p>
53+
54+
<div style={{
55+
backgroundColor: 'rgba(255,255,255,0.04)',
56+
border: '1px solid rgba(255,255,255,0.1)',
57+
borderRadius: '8px',
58+
overflow: 'hidden',
59+
}}>
60+
{WIRE_FIELDS.map((field, i) => (
61+
<div
62+
key={field.label}
63+
style={{
64+
display: 'flex',
65+
justifyContent: 'space-between',
66+
alignItems: 'center',
67+
padding: '10px 14px',
68+
borderBottom: i < WIRE_FIELDS.length - 1 ? '1px solid rgba(255,255,255,0.06)' : 'none',
69+
}}
70+
>
71+
<div>
72+
<span style={{ fontSize: '11px', color: 'rgba(255,255,255,0.35)', display: 'block' }}>
73+
{field.label}
74+
</span>
75+
<span style={{ fontSize: '13px', color: '#fafafa', fontFamily: 'ui-monospace, monospace' }}>
76+
{field.value}
77+
</span>
78+
</div>
79+
<button
80+
type="button"
81+
onClick={() => void copy(field.label, field.value)}
82+
style={{
83+
padding: '4px 8px',
84+
borderRadius: '4px',
85+
border: '1px solid rgba(255,255,255,0.12)',
86+
backgroundColor: 'transparent',
87+
color: copiedField === field.label ? '#4ade80' : 'rgba(255,255,255,0.4)',
88+
fontSize: '11px',
89+
cursor: 'pointer',
90+
flexShrink: 0,
91+
marginLeft: '12px',
92+
}}
93+
>
94+
{copiedField === field.label ? 'Copied' : 'Copy'}
95+
</button>
96+
</div>
97+
))}
98+
99+
{/* User ID memo row */}
100+
<div style={{
101+
padding: '10px 14px',
102+
backgroundColor: 'rgba(251,191,36,0.06)',
103+
borderTop: '1px solid rgba(255,255,255,0.06)',
104+
}}>
105+
<span style={{ fontSize: '11px', color: '#fbbf24', display: 'block' }}>
106+
Payment Memo / Reference
107+
</span>
108+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
109+
<span style={{ fontSize: '13px', color: '#fafafa', fontFamily: 'ui-monospace, monospace' }}>
110+
{userId}
111+
</span>
112+
<button
113+
type="button"
114+
onClick={() => void copy('memo', userId)}
115+
style={{
116+
padding: '4px 8px',
117+
borderRadius: '4px',
118+
border: '1px solid rgba(251,191,36,0.3)',
119+
backgroundColor: 'transparent',
120+
color: copiedField === 'memo' ? '#4ade80' : '#fbbf24',
121+
fontSize: '11px',
122+
cursor: 'pointer',
123+
flexShrink: 0,
124+
marginLeft: '12px',
125+
}}
126+
>
127+
{copiedField === 'memo' ? 'Copied' : 'Copy'}
128+
</button>
129+
</div>
130+
</div>
131+
</div>
132+
133+
<p style={{ fontSize: '11px', color: 'rgba(255,255,255,0.3)', margin: 0 }}>
134+
{MEMO_HINT}. ACH deposits typically clear in 1&ndash;3 business days. Wire transfers clear same day.
135+
Contact <a href="mailto:billing@hanzo.ai" style={{ color: 'rgba(255,255,255,0.5)', textDecoration: 'underline' }}>billing@hanzo.ai</a> after sending.
136+
</p>
137+
</div>
138+
)
139+
}

pkg/shop/components/CardInput.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
'use client'
2+
3+
/**
4+
* CardInput — Square Web Payments SDK card element with topup submit button.
5+
*/
6+
7+
import React, { useState } from 'react'
8+
import { useSquareCard } from '../hooks/useSquareCard'
9+
10+
const CONTAINER_ID = 'hanzo-sq-card-container'
11+
12+
export interface CardInputProps {
13+
squareAppId: string
14+
squareLocationId: string
15+
squareEnv?: 'sandbox' | 'production'
16+
amountCents: number
17+
currency: string
18+
disabled?: boolean
19+
onToken: (sourceId: string) => Promise<void>
20+
}
21+
22+
function formatAmount(cents: number, currency: string): string {
23+
return new Intl.NumberFormat('en-US', {
24+
style: 'currency',
25+
currency: currency.toUpperCase(),
26+
}).format(cents / 100)
27+
}
28+
29+
export function CardInput({
30+
squareAppId,
31+
squareLocationId,
32+
squareEnv = 'sandbox',
33+
amountCents,
34+
currency,
35+
disabled,
36+
onToken,
37+
}: CardInputProps) {
38+
const { ready, loading, error: initError, tokenize } = useSquareCard({
39+
appId: squareAppId,
40+
locationId: squareLocationId,
41+
env: squareEnv,
42+
containerId: CONTAINER_ID,
43+
})
44+
45+
const [submitting, setSubmitting] = useState(false)
46+
const [submitError, setSubmitError] = useState<string | null>(null)
47+
48+
const handlePay = async () => {
49+
if (!ready || submitting || disabled) return
50+
setSubmitting(true)
51+
setSubmitError(null)
52+
try {
53+
const sourceId = await tokenize()
54+
await onToken(sourceId)
55+
} catch (err) {
56+
setSubmitError(err instanceof Error ? err.message : 'Payment failed')
57+
} finally {
58+
setSubmitting(false)
59+
}
60+
}
61+
62+
const error = initError ?? submitError
63+
64+
return (
65+
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
66+
{/* Square card iframe container */}
67+
<div
68+
id={CONTAINER_ID}
69+
style={{
70+
minHeight: '120px',
71+
borderRadius: '8px',
72+
border: '1px solid rgba(255,255,255,0.1)',
73+
backgroundColor: 'rgba(255,255,255,0.04)',
74+
padding: '12px',
75+
}}
76+
>
77+
{loading && (
78+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '96px' }}>
79+
<Spinner />
80+
</div>
81+
)}
82+
</div>
83+
84+
{error && (
85+
<p style={{ fontSize: '13px', color: '#f87171', margin: 0 }}>{error}</p>
86+
)}
87+
88+
<button
89+
type="button"
90+
disabled={!ready || submitting || !!disabled}
91+
onClick={() => void handlePay()}
92+
style={btnStyle(!ready || submitting || !!disabled)}
93+
>
94+
{submitting
95+
? 'Processing\u2026'
96+
: `Pay ${formatAmount(amountCents, currency)}`}
97+
</button>
98+
99+
<SecureBadge label="Square" />
100+
</div>
101+
)
102+
}
103+
104+
// ---------------------------------------------------------------------------
105+
// Shared primitives
106+
// ---------------------------------------------------------------------------
107+
108+
export function Spinner() {
109+
return (
110+
<>
111+
<div style={{
112+
width: '20px', height: '20px',
113+
border: '2px solid rgba(255,255,255,0.15)',
114+
borderTopColor: 'rgba(255,255,255,0.7)',
115+
borderRadius: '50%',
116+
animation: 'hanzo-spin 0.8s linear infinite',
117+
}} />
118+
<style>{`@keyframes hanzo-spin { to { transform: rotate(360deg); } }`}</style>
119+
</>
120+
)
121+
}
122+
123+
export function SecureBadge({ label }: { label: string }) {
124+
return (
125+
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', color: 'rgba(255,255,255,0.35)' }}>
126+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
127+
<rect x="3" y="11" width="18" height="11" rx="2" />
128+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
129+
</svg>
130+
Secured by {label} &mdash; PCI DSS compliant tokenization
131+
</div>
132+
)
133+
}
134+
135+
export function btnStyle(disabled: boolean): React.CSSProperties {
136+
return {
137+
width: '100%',
138+
borderRadius: '8px',
139+
backgroundColor: disabled ? 'rgba(250,250,250,0.25)' : 'rgb(250,250,250)',
140+
color: disabled ? 'rgba(9,9,11,0.4)' : 'rgb(9,9,11)',
141+
padding: '10px 16px',
142+
fontSize: '14px',
143+
fontWeight: '500',
144+
cursor: disabled ? 'not-allowed' : 'pointer',
145+
border: 'none',
146+
transition: 'opacity 0.15s',
147+
}
148+
}

0 commit comments

Comments
 (0)