fix: checkout UI polish — brand colors, Pulse Select, logo, touched-only errors, no skeletons
This commit is contained in:
@@ -11,7 +11,7 @@ import { getSubscription } from '@/lib/api/billing'
|
|||||||
import { PLAN_PRICES, TRAFFIC_TIERS } from '@/lib/plans'
|
import { PLAN_PRICES, TRAFFIC_TIERS } from '@/lib/plans'
|
||||||
import PlanSummary from '@/components/checkout/PlanSummary'
|
import PlanSummary from '@/components/checkout/PlanSummary'
|
||||||
import PaymentForm from '@/components/checkout/PaymentForm'
|
import PaymentForm from '@/components/checkout/PaymentForm'
|
||||||
import pulseLogo from '@/public/pulse_logo_no_margins.png'
|
import pulseIcon from '@/public/pulse_icon_no_margins.png'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Validation helpers
|
// Validation helpers
|
||||||
@@ -174,15 +174,16 @@ function CheckoutContent() {
|
|||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
<Link href="/pricing" className="inline-block">
|
<Link href="/pricing" className="flex items-center gap-2 w-fit hover:opacity-80 transition-opacity">
|
||||||
<Image
|
<Image
|
||||||
src={pulseLogo}
|
src={pulseIcon}
|
||||||
alt="Pulse"
|
alt="Pulse"
|
||||||
width={90}
|
width={36}
|
||||||
height={28}
|
height={36}
|
||||||
unoptimized
|
unoptimized
|
||||||
className="opacity-80 hover:opacity-100 transition-opacity"
|
className="object-contain w-8 h-8"
|
||||||
/>
|
/>
|
||||||
|
<span className="text-xl font-bold text-white tracking-tight">Pulse</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import Script from 'next/script'
|
import Script from 'next/script'
|
||||||
import { Lock, ShieldCheck } from '@phosphor-icons/react'
|
import { Lock, ShieldCheck } from '@phosphor-icons/react'
|
||||||
|
import { Select } from '@ciphera-net/ui'
|
||||||
import { COUNTRY_OPTIONS } from '@/lib/countries'
|
import { COUNTRY_OPTIONS } from '@/lib/countries'
|
||||||
import { initMollie, getMollie, MOLLIE_FIELD_STYLES, type MollieComponent } from '@/lib/mollie'
|
import { initMollie, getMollie, MOLLIE_FIELD_STYLES, type MollieComponent } from '@/lib/mollie'
|
||||||
import { createEmbeddedCheckout } from '@/lib/api/billing'
|
import { createEmbeddedCheckout } from '@/lib/api/billing'
|
||||||
@@ -30,6 +31,7 @@ export default function PaymentForm({ plan, interval, limit }: PaymentFormProps)
|
|||||||
const [mollieError, setMollieError] = useState(false)
|
const [mollieError, setMollieError] = useState(false)
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
const [cardErrors, setCardErrors] = useState<Record<string, string>>({})
|
const [cardErrors, setCardErrors] = useState<Record<string, string>>({})
|
||||||
|
const [touchedFields, setTouchedFields] = useState<Set<string>>(new Set())
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const componentsRef = useRef<Record<string, MollieComponent | null>>({
|
const componentsRef = useRef<Record<string, MollieComponent | null>>({
|
||||||
@@ -71,6 +73,9 @@ export default function PaymentForm({ plan, interval, limit }: PaymentFormProps)
|
|||||||
component.mount(el)
|
component.mount(el)
|
||||||
component.addEventListener('change', (event: unknown) => {
|
component.addEventListener('change', (event: unknown) => {
|
||||||
const e = event as { error?: string; touched?: boolean }
|
const e = event as { error?: string; touched?: boolean }
|
||||||
|
if (e.touched) {
|
||||||
|
setTouchedFields((prev) => new Set(prev).add(type))
|
||||||
|
}
|
||||||
setCardErrors((prev) => {
|
setCardErrors((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
if (e.error) next[type] = e.error
|
if (e.error) next[type] = e.error
|
||||||
@@ -157,13 +162,8 @@ export default function PaymentForm({ plan, interval, limit }: PaymentFormProps)
|
|||||||
{/* Cardholder name */}
|
{/* Cardholder name */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Cardholder name</label>
|
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Cardholder name</label>
|
||||||
<div className="relative">
|
|
||||||
<div id="mollie-card-holder" className={mollieFieldClass} />
|
<div id="mollie-card-holder" className={mollieFieldClass} />
|
||||||
{!mollieReady && (
|
{touchedFields.has('cardHolder') && cardErrors.cardHolder && (
|
||||||
<div className="absolute inset-0 animate-pulse bg-neutral-700/30 rounded-lg" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{cardErrors.cardHolder && (
|
|
||||||
<p className="mt-1 text-xs text-red-400">{cardErrors.cardHolder}</p>
|
<p className="mt-1 text-xs text-red-400">{cardErrors.cardHolder}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -171,13 +171,8 @@ export default function PaymentForm({ plan, interval, limit }: PaymentFormProps)
|
|||||||
{/* Card number */}
|
{/* Card number */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Card number</label>
|
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Card number</label>
|
||||||
<div className="relative">
|
|
||||||
<div id="mollie-card-number" className={mollieFieldClass} />
|
<div id="mollie-card-number" className={mollieFieldClass} />
|
||||||
{!mollieReady && (
|
{touchedFields.has('cardNumber') && cardErrors.cardNumber && (
|
||||||
<div className="absolute inset-0 animate-pulse bg-neutral-700/30 rounded-lg" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{cardErrors.cardNumber && (
|
|
||||||
<p className="mt-1 text-xs text-red-400">{cardErrors.cardNumber}</p>
|
<p className="mt-1 text-xs text-red-400">{cardErrors.cardNumber}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -186,25 +181,15 @@ export default function PaymentForm({ plan, interval, limit }: PaymentFormProps)
|
|||||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Expiry date</label>
|
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Expiry date</label>
|
||||||
<div className="relative">
|
|
||||||
<div id="mollie-card-expiry" className={mollieFieldClass} />
|
<div id="mollie-card-expiry" className={mollieFieldClass} />
|
||||||
{!mollieReady && (
|
{touchedFields.has('expiryDate') && cardErrors.expiryDate && (
|
||||||
<div className="absolute inset-0 animate-pulse bg-neutral-700/30 rounded-lg" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{cardErrors.expiryDate && (
|
|
||||||
<p className="mt-1 text-xs text-red-400">{cardErrors.expiryDate}</p>
|
<p className="mt-1 text-xs text-red-400">{cardErrors.expiryDate}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">CVC</label>
|
<label className="block text-sm font-medium text-neutral-300 mb-1.5">CVC</label>
|
||||||
<div className="relative">
|
|
||||||
<div id="mollie-card-cvc" className={mollieFieldClass} />
|
<div id="mollie-card-cvc" className={mollieFieldClass} />
|
||||||
{!mollieReady && (
|
{touchedFields.has('verificationCode') && cardErrors.verificationCode && (
|
||||||
<div className="absolute inset-0 animate-pulse bg-neutral-700/30 rounded-lg" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{cardErrors.verificationCode && (
|
|
||||||
<p className="mt-1 text-xs text-red-400">{cardErrors.verificationCode}</p>
|
<p className="mt-1 text-xs text-red-400">{cardErrors.verificationCode}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,18 +198,12 @@ export default function PaymentForm({ plan, interval, limit }: PaymentFormProps)
|
|||||||
{/* Country */}
|
{/* Country */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Country</label>
|
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Country</label>
|
||||||
<select
|
<Select
|
||||||
value={country}
|
value={country}
|
||||||
onChange={(e) => setCountry(e.target.value)}
|
onChange={setCountry}
|
||||||
className={`${inputClass} appearance-none`}
|
variant="input"
|
||||||
>
|
options={[{ value: '', label: 'Select country' }, ...COUNTRY_OPTIONS.map((c) => ({ value: c.value, label: c.label }))]}
|
||||||
<option value="">Select country</option>
|
/>
|
||||||
{COUNTRY_OPTIONS.map((c) => (
|
|
||||||
<option key={c.value} value={c.value}>
|
|
||||||
{c.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* VAT ID */}
|
{/* VAT ID */}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function PlanSummary({ plan, interval, limit }: PlanSummaryProps)
|
|||||||
{/* Plan header */}
|
{/* Plan header */}
|
||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<h2 className="text-xl font-semibold text-white capitalize">{plan}</h2>
|
<h2 className="text-xl font-semibold text-white capitalize">{plan}</h2>
|
||||||
<span className="rounded-full bg-orange-500/15 px-3 py-0.5 text-xs font-medium text-orange-400">
|
<span className="rounded-full bg-brand-orange/15 px-3 py-0.5 text-xs font-medium text-brand-orange">
|
||||||
30-day trial
|
30-day trial
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +75,7 @@ export default function PlanSummary({ plan, interval, limit }: PlanSummaryProps)
|
|||||||
<span className="text-sm text-neutral-400">
|
<span className="text-sm text-neutral-400">
|
||||||
€{displayPrice.toFixed(2)} billed yearly
|
€{displayPrice.toFixed(2)} billed yearly
|
||||||
</span>
|
</span>
|
||||||
<span className="rounded-full bg-orange-500/15 px-2.5 py-0.5 text-xs font-medium text-orange-400">
|
<span className="rounded-full bg-brand-orange/15 px-2.5 py-0.5 text-xs font-medium text-brand-orange">
|
||||||
Save 1 month
|
Save 1 month
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +115,7 @@ export default function PlanSummary({ plan, interval, limit }: PlanSummaryProps)
|
|||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{features.map((feature) => (
|
{features.map((feature) => (
|
||||||
<li key={feature} className="flex items-center gap-3 text-sm text-neutral-300">
|
<li key={feature} className="flex items-center gap-3 text-sm text-neutral-300">
|
||||||
<Check weight="bold" className="h-4 w-4 shrink-0 text-orange-400" />
|
<Check weight="bold" className="h-4 w-4 shrink-0 text-brand-orange" />
|
||||||
{feature}
|
{feature}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user