Files
billing/app/commerce/components/coupon-input.tsx
zooqueen 8a0d8db2a9 billing: one focus glow — adopt the fixed @hanzo/ui base layer, drop redundant local focus overrides (#131)
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.
2026-08-13 07:07:04 -07:00

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>
)
}