mirror of
https://github.com/hanzoai/billing.git
synced 2026-10-30 18:46:54 +00:00
- app/commerce/page.tsx: server-rendered landing page; fetches plans from pricing.hanzo.ai/v1/pricing/subscriptions (ISR 1h), falls back to hardcoded plans; renders hero, stats row, plan cards, feature grid, referral section, and footer - app/commerce/components/plan-card.tsx: plan card with monthly/annual pricing, popular badge, feature list, idle-resale payout callout, CTA linking to billing.hanzo.ai or cal.com/hanzo for Enterprise - app/commerce/components/billing-toggle.tsx: client toggle for monthly/annual interval; renders PlanCard grid - app/commerce/components/coupon-input.tsx: client coupon field with optimistic validation against commerce API - app/commerce/components/referral-section.tsx: affiliate sign-up form (20% recurring commission copy + email capture) - k8s/deployment.yaml: add commerce.hanzo.ai host to ingress TLS + rules pointing to existing billing service
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
|
|
export function CouponInput() {
|
|
const [code, setCode] = useState('')
|
|
const [status, setStatus] = useState<'idle' | 'loading' | 'ok' | 'error'>('idle')
|
|
const [message, setMessage] = useState('')
|
|
|
|
const apply = async () => {
|
|
const trimmed = code.trim().toUpperCase()
|
|
if (!trimmed) return
|
|
setStatus('loading')
|
|
setMessage('')
|
|
try {
|
|
const res = await fetch(
|
|
`${process.env.NEXT_PUBLIC_COMMERCE_API ?? 'https://api.hanzo.ai/api/v1'}/coupons/validate`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code: trimmed }),
|
|
},
|
|
)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setStatus('ok')
|
|
setMessage(data.description ?? `Code applied: ${trimmed}`)
|
|
} else {
|
|
setStatus('error')
|
|
setMessage('Invalid or expired coupon code.')
|
|
}
|
|
} catch {
|
|
setStatus('error')
|
|
setMessage('Unable to validate coupon right now.')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px', marginBottom: '48px' }}>
|
|
<div style={{ display: 'flex', gap: '8px', width: '100%', maxWidth: '380px' }}>
|
|
<input
|
|
type="text"
|
|
placeholder="Coupon or promo code"
|
|
value={code}
|
|
onChange={(e) => { setCode(e.target.value); setStatus('idle'); setMessage('') }}
|
|
onKeyDown={(e) => e.key === 'Enter' && apply()}
|
|
style={{
|
|
flex: 1,
|
|
background: 'rgba(255,255,255,0.04)',
|
|
border: `1px solid ${status === 'error' ? 'rgba(239,68,68,0.5)' : status === 'ok' ? 'rgba(34,197,94,0.5)' : 'rgba(255,255,255,0.1)'}`,
|
|
borderRadius: '8px',
|
|
padding: '10px 14px',
|
|
color: '#fafafa',
|
|
fontSize: '14px',
|
|
outline: 'none',
|
|
letterSpacing: '0.05em',
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={apply}
|
|
disabled={status === 'loading' || !code.trim()}
|
|
style={{
|
|
background: status === 'loading' ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.09)',
|
|
border: '1px solid rgba(255,255,255,0.12)',
|
|
borderRadius: '8px',
|
|
padding: '10px 18px',
|
|
color: code.trim() ? '#fafafa' : 'rgba(255,255,255,0.3)',
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
cursor: code.trim() && status !== 'loading' ? 'pointer' : 'not-allowed',
|
|
transition: 'background 0.15s',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
>
|
|
{status === 'loading' ? 'Checking…' : 'Apply'}
|
|
</button>
|
|
</div>
|
|
{message && (
|
|
<p style={{
|
|
fontSize: '13px',
|
|
color: status === 'ok' ? '#4ade80' : '#f87171',
|
|
margin: 0,
|
|
}}>
|
|
{message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|