mirror of
https://github.com/hanzoai/billing.git
synced 2026-10-30 18:46:54 +00:00
A focused input painted THREE affordances (brightened border + 3px halo +
2px outline). Root cause: two rules in @hanzo/design's @layer base (inlined
into @hanzo/ui's dist/styles.css) both compute to (0,1,0) because :where()
zeroes specificity, so the generic `:focus-visible{outline:2px}` overrode the
field rule's own `outline:none`. The one-place fix already shipped in
@hanzo/design 0.4.9 / @hanzo/ui 8.0.69+ — but @hanzo/ui inlines the base layer
at ITS build time, so the layer billing renders is set by its @hanzo/ui pin,
not its @hanzo/design pin. Billing sat at ui 8.0.47 (the double-ring layer).
Bump ui ^8.0.47->^8.0.72 + design ^0.5.1 + the gui/shell peers (caret already
permitted it; this raises the floor past the fix), and delete billing's own 16
focus overrides across 12 files — after the bump they double-up (and the coupon
input painted nothing without removing them). Verified in a browser: one 2px
ring at 2px offset on billing/console/app, light + dark, :focus-visible correct
(a mouse-clicked button doesn't ring). tsc clean, build green, gui-css-check
24/24, the --page workaround re-checked at 8.0.72.
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { getOrgByHost } from '@/lib/config'
|
|
|
|
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 { commerceUrl } = getOrgByHost(window.location.host)
|
|
const res = await fetch(`${commerceUrl}/v1/coupon/validate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code: trimmed }),
|
|
})
|
|
// The validate endpoint returns 200 with { valid, coupon, rewards } for
|
|
// known codes (valid:false for unknown/expired) and a non-2xx only on
|
|
// transport or auth errors.
|
|
const data = await res.json().catch(() => null)
|
|
if (res.ok && data?.valid) {
|
|
const reward = Array.isArray(data.rewards) && data.rewards.length > 0
|
|
? data.rewards[0]?.description
|
|
: undefined
|
|
setStatus('ok')
|
|
setMessage(reward ?? data.coupon?.name ?? `Code applied: ${trimmed}`)
|
|
} else if (res.ok) {
|
|
setStatus('error')
|
|
setMessage(data?.error ?? 'Invalid or expired coupon code.')
|
|
} else {
|
|
setStatus('error')
|
|
setMessage('Unable to validate coupon right now.')
|
|
}
|
|
} 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: 'color-mix(in oklab, var(--color-text) 4%, transparent)',
|
|
border: `1px solid ${status === 'error' ? 'rgba(239,68,68,0.5)' : status === 'ok' ? 'rgba(34,197,94,0.5)' : 'color-mix(in oklab, var(--color-text) 10%, transparent)'}`,
|
|
borderRadius: '8px',
|
|
padding: '10px 14px',
|
|
color: 'var(--color-text)',
|
|
fontSize: '14px',
|
|
letterSpacing: '0.05em',
|
|
}}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={apply}
|
|
disabled={status === 'loading' || !code.trim()}
|
|
style={{
|
|
background: status === 'loading' ? 'color-mix(in oklab, var(--color-text) 6%, transparent)' : 'color-mix(in oklab, var(--color-text) 9%, transparent)',
|
|
border: '1px solid color-mix(in oklab, var(--color-text) 12%, transparent)',
|
|
borderRadius: '8px',
|
|
padding: '10px 18px',
|
|
color: code.trim() ? 'var(--color-text)' : 'color-mix(in oklab, var(--color-text) 30%, transparent)',
|
|
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>
|
|
)
|
|
}
|