1 GitHub star = 1 new component.

Star the repo
E-commerce ecommerceshopcheckoutformpayment

Checkout Form

One-page checkout — contact, delivery, shipping and payment — with autocomplete hints, validation and an animated confirmation.

Live Preview

Contact
Delivery
Shipping method
Payment

All transactions are secure and encrypted.

Demo checkout — no data leaves your browser. Try code ASTRO10.

Usage

Copy and paste this code into your Astro page:

astro
---
import { CheckoutForm } from 'astro-component-kit/ecommerce';
---

<div class="checkout-layout">
  <CheckoutForm continueHref="/" />
  <OrderSummary />
</div>
--- import { CheckoutForm } from 'astro-component-kit/ecommerce'; --- <div class="checkout-layout"> <CheckoutForm continueHref="/" /> <OrderSummary /> </div>

Manual Installation

If you are not using the npm package, create a new file and paste the following code: src/components/lib/CheckoutForm.astro:

astro
---
/**
 * CheckoutForm — One-page checkout: contact, delivery address, shipping method and payment,
 * built from AnimatedInput, ModernSelect and CustomRadioGroup. Shows an order confirmation
 * (AnimatedCheckmark) on submit and clears the cart.
 *
 * Pair it with OrderSummary: the shipping radios drive its totals and the pay button shows
 * the live total. Replace the demo submit handler with your payment provider.
 *
 * @param {string} id - Form id. Default 'checkout'.
 * @param {ShippingRate[]} shippingRates - Shipping options (must match OrderSummary).
 * @param {number} freeShippingThreshold - Shows "Free" on the first rate above this subtotal. Default 75.
 * @param {Array<{label: string, value: string}>} countries - Country select options.
 * @param {string} cartKey - Cart storage key. Default 'ack-cart'.
 * @param {string} currency - ISO currency. Default 'USD'.
 * @param {string} locale - Number format locale. Default 'en-US'.
 * @param {string} continueHref - "Continue shopping" link on the confirmation. Default '#'.
 * @param {boolean} demo - Simulates payment and never sends data anywhere. Default true.
 * @param {string} variant - Section style (cards|plain). Default 'cards'.
 * @param {string} className - Extra class.
 *
 * Accessibility: grouped fieldsets with legends, autocomplete hints on every field, native
 * validation with focus moved to the first invalid field, confirmation heading receives focus.
 * Emits `ack:order-placed` with `{ orderId, total }`.
 */
import AnimatedInput from '../forms/AnimatedInput.astro';
import ModernSelect from '../forms/ModernSelect.astro';
import CustomRadioGroup from '../forms/CustomRadioGroup.astro';
import AnimatedCheckmark from '../feedback/AnimatedCheckmark.astro';
import { formatMoney } from './cart-store';
import type { ShippingRate } from './types';

interface Props {
  id?: string;
  shippingRates?: ShippingRate[];
  freeShippingThreshold?: number;
  countries?: Array<{ label: string; value: string }>;
  cartKey?: string;
  currency?: string;
  locale?: string;
  continueHref?: string;
  demo?: boolean;
  variant?: 'cards' | 'plain';
  className?: string;
}

const {
  id = 'checkout',
  shippingRates = [
    { id: 'standard', label: 'Standard', eta: '3–5 business days', price: 6 },
    { id: 'express', label: 'Express', eta: '1–2 business days', price: 14 },
  ],
  freeShippingThreshold = 75,
  countries = [
    { label: 'United States', value: 'US' },
    { label: 'Canada', value: 'CA' },
    { label: 'United Kingdom', value: 'GB' },
    { label: 'Spain', value: 'ES' },
    { label: 'Germany', value: 'DE' },
    { label: 'France', value: 'FR' },
  ],
  cartKey = 'ack-cart',
  currency = 'USD',
  locale = 'en-US',
  continueHref = '#',
  demo = true,
  variant = 'cards',
  className = '',
} = Astro.props;

const money = { currency, locale };
---

<div class:list={['checkout', `checkout--${variant}`, className]} data-checkout data-key={cartKey} data-demo={demo ? '' : undefined} data-free={freeShippingThreshold}>
  <form class="checkout__form" id={id} novalidate data-checkout-form>
    <fieldset class="checkout__section">
      <legend><span class="checkout__num" aria-hidden="true">1</span> Contact</legend>
      <AnimatedInput label="Email" id={`${id}-email`} name="email" type="email" required autocomplete="email" />
      <label class="checkout__check">
        <input type="checkbox" name="newsletter" checked />
        <span class="checkout__box" aria-hidden="true"></span>
        Email me with news and offers
      </label>
    </fieldset>

    <fieldset class="checkout__section">
      <legend><span class="checkout__num" aria-hidden="true">2</span> Delivery</legend>
      <ModernSelect label="Country / region" id={`${id}-country`} name="country" options={countries} required />
      <div class="checkout__row">
        <AnimatedInput label="First name" id={`${id}-first`} name="firstName" required autocomplete="given-name" />
        <AnimatedInput label="Last name" id={`${id}-last`} name="lastName" required autocomplete="family-name" />
      </div>
      <AnimatedInput label="Address" id={`${id}-address`} name="address" required autocomplete="address-line1" />
      <AnimatedInput label="Apartment, suite (optional)" id={`${id}-address2`} name="address2" autocomplete="address-line2" />
      <div class="checkout__row checkout__row--3">
        <AnimatedInput label="City" id={`${id}-city`} name="city" required autocomplete="address-level2" />
        <AnimatedInput label="State" id={`${id}-state`} name="state" autocomplete="address-level1" />
        <AnimatedInput label="ZIP / Postal" id={`${id}-zip`} name="zip" required autocomplete="postal-code" />
      </div>
      <AnimatedInput label="Phone (for delivery updates)" id={`${id}-phone`} name="phone" type="tel" autocomplete="tel" />
    </fieldset>

    <fieldset class="checkout__section">
      <legend><span class="checkout__num" aria-hidden="true">3</span> Shipping method</legend>
      <div class="checkout__rates">
        {shippingRates.map((r, i) => (
          <label class="checkout__rate">
            <input type="radio" name="shipping" value={r.id} checked={i === 0} data-price={r.price} data-first={i === 0 ? '' : undefined} />
            <span class="checkout__rate-dot" aria-hidden="true"></span>
            <span class="checkout__rate-text">
              <strong>{r.label}</strong>
              <span>{r.eta}</span>
            </span>
            <span class="checkout__rate-price" data-rate-price>{r.price === 0 ? 'Free' : formatMoney(r.price, money)}</span>
          </label>
        ))}
      </div>
    </fieldset>

    <fieldset class="checkout__section">
      <legend><span class="checkout__num" aria-hidden="true">4</span> Payment</legend>
      <p class="checkout__secure">
        <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
        All transactions are secure and encrypted.
      </p>
      <CustomRadioGroup
        name="payment"
        horizontal
        options={[
          { label: 'Card', value: 'card', checked: true },
          { label: 'PayPal', value: 'paypal' },
          { label: 'Apple Pay', value: 'applepay' },
        ]}
      />
      <div class="checkout__card" data-card-fields>
        <div class="checkout__field">
          <label for={`${id}-cc`}>Card number</label>
          <input id={`${id}-cc`} name="cc" inputmode="numeric" autocomplete="cc-number" placeholder="1234 1234 1234 1234" required pattern="[0-9 ]{15,23}" maxlength="23" data-cc />
        </div>
        <div class="checkout__row">
          <div class="checkout__field">
            <label for={`${id}-exp`}>Expiry (MM/YY)</label>
            <input id={`${id}-exp`} name="exp" inputmode="numeric" autocomplete="cc-exp" placeholder="MM/YY" required pattern="(0[1-9]|1[0-2])\/[0-9]{2}" maxlength="5" data-exp />
          </div>
          <div class="checkout__field">
            <label for={`${id}-cvc`}>Security code</label>
            <input id={`${id}-cvc`} name="cvc" inputmode="numeric" autocomplete="cc-csc" placeholder="CVC" required pattern="[0-9]{3,4}" maxlength="4" />
          </div>
        </div>
        <div class="checkout__field">
          <label for={`${id}-ccname`}>Name on card</label>
          <input id={`${id}-ccname`} name="ccName" autocomplete="cc-name" required />
        </div>
      </div>
      <p class="checkout__alt" data-alt-pay hidden>You will be redirected to complete the payment securely.</p>
    </fieldset>

    <p class="checkout__error" role="alert" data-error hidden></p>

    <button class="checkout__pay" type="submit" data-pay>
      <span class="checkout__spinner" aria-hidden="true"></span>
      <span data-pay-label>Pay now</span>
    </button>
    {demo && <p class="checkout__demo">Demo checkout — no data leaves your browser. Try code <strong>ASTRO10</strong>.</p>}
  </form>

  <section class="checkout__done" data-done hidden aria-labelledby={`${id}-done-title`}>
    <AnimatedCheckmark size={72} color="var(--c-success, #34d399)" />
    <h2 id={`${id}-done-title`} tabindex="-1">Thank you for your order!</h2>
    <p>Order <strong data-order-id>#—</strong> is confirmed. A receipt is on its way to <strong data-order-email>your inbox</strong>.</p>
    <p class="checkout__done-total">Total paid: <strong data-order-total>—</strong></p>
    <a class="checkout__pay checkout__pay--link" href={continueHref}>Continue shopping</a>
  </section>
</div>

<script>
  import { createCart } from './cart-store';

  document.querySelectorAll<HTMLElement>('[data-checkout]:not([data-bound])').forEach((root) => {
    root.dataset.bound = '';
    const form = root.querySelector<HTMLFormElement>('[data-checkout-form]')!;
    const done = root.querySelector<HTMLElement>('[data-done]')!;
    const pay = root.querySelector<HTMLButtonElement>('[data-pay]')!;
    const payLabel = root.querySelector<HTMLElement>('[data-pay-label]')!;
    const error = root.querySelector<HTMLElement>('[data-error]')!;
    const cardFields = root.querySelector<HTMLElement>('[data-card-fields]')!;
    const altPay = root.querySelector<HTMLElement>('[data-alt-pay]')!;
    const firstRatePrice = root.querySelector<HTMLElement>('[data-first] ~ [data-rate-price]');
    const cart = createCart(root.dataset.key);
    let total = { formatted: '', total: 0, count: 0 };
    const firstRateText = firstRatePrice?.textContent || '';

    document.addEventListener('ack:totals', (e) => {
      const d = (e as CustomEvent).detail;
      total = d;
      payLabel.textContent = d.count ? `Pay ${d.formatted}` : 'Pay now';
      if (firstRatePrice) firstRatePrice.textContent = d.freeStandard || (d.subtotal - d.discount) >= Number(root.dataset.free || 0) && Number(root.dataset.free) > 0 ? 'Free' : firstRateText;
    });
    document.dispatchEvent(new CustomEvent('ack:totals-request'));

    // Payment method toggles the card fields (and their `required`).
    const setMethod = () => {
      const method = (form.elements.namedItem('payment') as RadioNodeList)?.value || 'card';
      const isCard = method === 'card';
      cardFields.hidden = !isCard;
      altPay.hidden = isCard;
      cardFields.querySelectorAll('input').forEach((i) => { i.required = isCard; });
    };
    form.addEventListener('change', (e) => {
      if ((e.target as HTMLInputElement).name === 'payment') setMethod();
    });
    setMethod();

    // Light input masks
    form.querySelector<HTMLInputElement>('[data-cc]')?.addEventListener('input', (e) => {
      const el = e.target as HTMLInputElement;
      el.value = el.value.replace(/\D/g, '').slice(0, 19).replace(/(\d{4})(?=\d)/g, '$1 ');
    });
    form.querySelector<HTMLInputElement>('[data-exp]')?.addEventListener('input', (e) => {
      const el = e.target as HTMLInputElement;
      const digits = el.value.replace(/\D/g, '').slice(0, 4);
      el.value = digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits;
    });

    form.addEventListener('submit', (e) => {
      e.preventDefault();
      error.hidden = true;
      form.querySelectorAll('[aria-invalid]').forEach((n) => n.removeAttribute('aria-invalid'));
      if (!cart.items().length) {
        error.textContent = 'Your cart is empty — add a product before checking out.';
        error.hidden = false;
        return;
      }
      if (!form.checkValidity()) {
        const invalid = Array.from(form.querySelectorAll<HTMLInputElement>('input, select')).filter((n) => !n.checkValidity());
        invalid.forEach((n) => n.setAttribute('aria-invalid', 'true'));
        error.textContent = `Please check ${invalid.length === 1 ? '1 field' : `${invalid.length} fields`} highlighted in red.`;
        error.hidden = false;
        invalid[0]?.focus();
        return;
      }
      pay.disabled = true;
      pay.classList.add('is-loading');
      payLabel.textContent = 'Processing…';

      const finish = () => {
        const orderId = `#${Math.floor(100000 + Math.random() * 900000)}`;
        const email = (form.elements.namedItem('email') as HTMLInputElement).value;
        root.querySelector('[data-order-id]')!.textContent = orderId;
        root.querySelector('[data-order-email]')!.textContent = email;
        root.querySelector('[data-order-total]')!.textContent = total.formatted;
        form.hidden = true;
        done.hidden = false;
        cart.clear();
        done.querySelector<HTMLElement>('h2')?.focus();
        document.dispatchEvent(new CustomEvent('ack:order-placed', { detail: { orderId, total: total.total } }));
        window.scrollTo({ top: 0, behavior: 'smooth' });
      };
      // Demo mode: simulate the payment round-trip. Replace with your provider's SDK.
      window.setTimeout(finish, root.hasAttribute('data-demo') ? 1100 : 0);
    });
  });
</script>

<style>
  .checkout { container-type: inline-size; min-width: 0; }
  .checkout__form { display: grid; gap: var(--sp-5, 1.25rem); }
  .checkout__form[hidden], .checkout__done[hidden] { display: none; }

  .checkout__section {
    display: grid;
    gap: var(--sp-3, 0.75rem);
    min-width: 0;
    margin: 0;
    padding: 0;
    border: 0;
  }
  .checkout--cards .checkout__section {
    padding: var(--sp-6, 1.5rem);
    border-radius: var(--r-lg, 20px);
    border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05));
    background: var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02));
  }
  .checkout__section legend {
    display: flex;
    align-items: center;
    gap: var(--sp-3, 0.75rem);
    float: left;
    width: 100%;
    margin-bottom: var(--sp-2, 0.5rem);
    font-size: 1.05rem;
    font-weight: 800;
    color: var(--c-text-1, #fff);
  }
  .checkout__section legend + * { clear: both; }
  .checkout__num {
    display: grid;
    place-items: center;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    background: rgba(212, 160, 23, 0.14);
    color: var(--c-primary-light, #d4a017);
    font-size: 0.82rem;
  }

  .checkout__row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3, 0.75rem); }
  .checkout__row--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }

  /* Reused form components, tuned to the store palette */
  .checkout :global(.anim-input:focus),
  .checkout :global(.custom-select:focus) { border-color: var(--c-primary-light, #d4a017); box-shadow: 0 0 0 3px rgba(212, 160, 23, 0.14); background: rgba(212, 160, 23, 0.04); }
  .checkout :global(.anim-input:focus ~ .anim-input-label),
  .checkout :global(.anim-input:not(:placeholder-shown) ~ .anim-input-label) { color: var(--c-primary-light, #d4a017); }
  .checkout :global(.anim-input-label),
  .checkout :global(.select-label) { color: var(--c-text-2, #a3a3a3); }
  .checkout :global([aria-invalid='true']) { border-color: var(--c-danger, #f87171) !important; box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.14) !important; }
  .checkout :global(.radio-circle::after) { background: var(--c-primary-light, #d4a017); }
  .checkout :global(.radio-item input:checked + .radio-circle) { border-color: var(--c-primary-light, #d4a017); box-shadow: 0 0 10px rgba(212, 160, 23, 0.25); }
  .checkout :global(.radio-group-legend) { display: none; }

  .checkout__check { position: relative; display: flex; align-items: center; gap: var(--sp-3, 0.75rem); min-height: 40px; font-size: 0.88rem; cursor: pointer; }
  .checkout__check input { position: absolute; opacity: 0; width: 1px; height: 1px; }
  .checkout__box { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid rgb(var(--ink-rgb, 255 255 255) / 0.25); flex-shrink: 0; transition: background 0.15s ease; }
  .checkout__check input:checked + .checkout__box { background: var(--c-primary-light, #d4a017); border-color: var(--c-primary-light, #d4a017); box-shadow: inset 0 0 0 3px var(--c-bg-1, #0a0a0a); }
  .checkout__check input:focus-visible + .checkout__box { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }

  .checkout__rates { display: grid; gap: var(--sp-2, 0.5rem); }
  .checkout__rate {
    position: relative;
    display: flex;
    align-items: center;
    gap: var(--sp-3, 0.75rem);
    min-height: 64px;
    padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem);
    border-radius: var(--r-md, 12px);
    border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.1);
    cursor: pointer;
    transition: border-color 0.15s ease, background 0.15s ease;
  }
  .checkout__rate:hover { border-color: rgb(var(--ink-rgb, 255 255 255) / 0.22); }
  .checkout__rate:has(input:checked) { border-color: var(--c-primary-light, #d4a017); background: rgba(212, 160, 23, 0.06); }
  .checkout__rate input { position: absolute; opacity: 0; width: 1px; height: 1px; }
  .checkout__rate-dot { width: 20px; height: 20px; border-radius: 50%; border: 2px solid rgb(var(--ink-rgb, 255 255 255) / 0.25); flex-shrink: 0; }
  .checkout__rate input:checked + .checkout__rate-dot { border-color: var(--c-primary-light, #d4a017); box-shadow: inset 0 0 0 4px var(--c-bg-1, #0a0a0a); background: var(--c-primary-light, #d4a017); }
  .checkout__rate input:focus-visible + .checkout__rate-dot { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; }
  .checkout__rate-text { flex: 1; display: grid; gap: 1px; }
  .checkout__rate-text strong { color: var(--c-text-1, #fff); font-size: 0.92rem; }
  .checkout__rate-text span { font-size: 0.8rem; }
  .checkout__rate-price { font-weight: 800; color: var(--c-text-1, #fff); }

  .checkout__secure { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 0.82rem; color: var(--c-text-2, #a3a3a3); }
  .checkout__card { display: grid; gap: var(--sp-3, 0.75rem); padding-top: var(--sp-2, 0.5rem); }
  .checkout__card[hidden], .checkout__alt[hidden] { display: none; }
  .checkout__field { display: grid; gap: var(--sp-2, 0.5rem); }
  .checkout__field label { font-size: 0.82rem; font-weight: 600; color: var(--c-text-2, #a3a3a3); }
  .checkout__field input {
    width: 100%;
    min-height: 50px;
    padding: 0 var(--sp-4, 1rem);
    border-radius: var(--r-md, 12px);
    border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.1));
    background: rgb(var(--ink-rgb, 255 255 255) / 0.04);
    color: var(--c-text-1, #fff);
    font: inherit;
    font-size: 0.95rem;
    letter-spacing: 0.02em;
  }
  .checkout__field input::placeholder { color: var(--c-text-3, #737373); }
  .checkout__field input:focus { outline: none; border-color: var(--c-primary-light, #d4a017); box-shadow: 0 0 0 3px rgba(212, 160, 23, 0.14); }
  .checkout__alt { margin: 0; padding: var(--sp-4, 1rem); border-radius: var(--r-md, 12px); background: rgb(var(--ink-rgb, 255 255 255) / 0.03); font-size: 0.88rem; }

  .checkout__error { margin: 0; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); border-radius: var(--r-md, 12px); background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); color: var(--c-danger, #fca5a5); font-size: 0.88rem; }
  .checkout__error[hidden] { display: none; }

  .checkout__pay {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--sp-2, 0.5rem);
    width: 100%;
    min-height: 56px;
    border: 0;
    border-radius: var(--r-full, 9999px);
    background: var(--c-primary-light, #d4a017);
    color: var(--c-on-primary, #050505);
    font: inherit;
    font-size: 1rem;
    font-weight: 800;
    text-decoration: none;
    cursor: pointer;
    transition: filter 0.2s ease, transform 0.2s ease;
  }
  .checkout__pay:hover { filter: brightness(1.08); color: var(--c-on-primary, #050505); }
  .checkout__pay:disabled { cursor: progress; filter: saturate(0.6); }
  .checkout__pay:focus-visible { outline: 2px solid var(--c-text-1, #fff); outline-offset: 3px; }
  .checkout__spinner { display: none; width: 18px; height: 18px; border-radius: 50%; border: 2.5px solid rgba(0, 0, 0, 0.25); border-top-color: var(--c-on-primary, #050505); animation: co-spin 0.7s linear infinite; }
  .checkout__pay.is-loading .checkout__spinner { display: block; }
  @keyframes co-spin { to { transform: rotate(360deg); } }
  .checkout__demo { margin: 0; text-align: center; font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); }

  .checkout__done {
    display: grid;
    justify-items: center;
    gap: var(--sp-3, 0.75rem);
    padding: var(--sp-12, 3rem) var(--sp-6, 1.5rem);
    border-radius: var(--r-xl, 28px);
    border: 1px solid rgba(52, 211, 153, 0.25);
    background: radial-gradient(circle at 50% 0%, rgba(52, 211, 153, 0.1), transparent 60%), var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02));
    text-align: center;
  }
  .checkout__done h2 { margin: 0; font-size: 1.8rem; font-weight: 900; letter-spacing: -0.03em; color: var(--c-text-1, #fff); outline: none; }
  .checkout__done p { margin: 0; max-width: 32rem; }
  .checkout__done strong { color: var(--c-text-1, #fff); }
  .checkout__pay--link { width: auto; padding: 0 var(--sp-8, 2rem); margin-top: var(--sp-4, 1rem); }

  @container (max-width: 560px) {
    .checkout--cards .checkout__section { padding: var(--sp-4, 1rem); }
    .checkout__row, .checkout__row--3 { grid-template-columns: 1fr; }
  }
  @media (prefers-reduced-motion: reduce) { .checkout__spinner { animation-duration: 2s; } }
</style>
--- /** * CheckoutForm — One-page checkout: contact, delivery address, shipping method and payment, * built from AnimatedInput, ModernSelect and CustomRadioGroup. Shows an order confirmation * (AnimatedCheckmark) on submit and clears the cart. * * Pair it with OrderSummary: the shipping radios drive its totals and the pay button shows * the live total. Replace the demo submit handler with your payment provider. * * @param {string} id - Form id. Default 'checkout'. * @param {ShippingRate[]} shippingRates - Shipping options (must match OrderSummary). * @param {number} freeShippingThreshold - Shows "Free" on the first rate above this subtotal. Default 75. * @param {Array<{label: string, value: string}>} countries - Country select options. * @param {string} cartKey - Cart storage key. Default 'ack-cart'. * @param {string} currency - ISO currency. Default 'USD'. * @param {string} locale - Number format locale. Default 'en-US'. * @param {string} continueHref - "Continue shopping" link on the confirmation. Default '#'. * @param {boolean} demo - Simulates payment and never sends data anywhere. Default true. * @param {string} variant - Section style (cards|plain). Default 'cards'. * @param {string} className - Extra class. * * Accessibility: grouped fieldsets with legends, autocomplete hints on every field, native * validation with focus moved to the first invalid field, confirmation heading receives focus. * Emits `ack:order-placed` with `{ orderId, total }`. */ import AnimatedInput from '../forms/AnimatedInput.astro'; import ModernSelect from '../forms/ModernSelect.astro'; import CustomRadioGroup from '../forms/CustomRadioGroup.astro'; import AnimatedCheckmark from '../feedback/AnimatedCheckmark.astro'; import { formatMoney } from './cart-store'; import type { ShippingRate } from './types'; interface Props { id?: string; shippingRates?: ShippingRate[]; freeShippingThreshold?: number; countries?: Array<{ label: string; value: string }>; cartKey?: string; currency?: string; locale?: string; continueHref?: string; demo?: boolean; variant?: 'cards' | 'plain'; className?: string; } const { id = 'checkout', shippingRates = [ { id: 'standard', label: 'Standard', eta: '3–5 business days', price: 6 }, { id: 'express', label: 'Express', eta: '1–2 business days', price: 14 }, ], freeShippingThreshold = 75, countries = [ { label: 'United States', value: 'US' }, { label: 'Canada', value: 'CA' }, { label: 'United Kingdom', value: 'GB' }, { label: 'Spain', value: 'ES' }, { label: 'Germany', value: 'DE' }, { label: 'France', value: 'FR' }, ], cartKey = 'ack-cart', currency = 'USD', locale = 'en-US', continueHref = '#', demo = true, variant = 'cards', className = '', } = Astro.props; const money = { currency, locale }; --- <div class:list={['checkout', `checkout--${variant}`, className]} data-checkout data-key={cartKey} data-demo={demo ? '' : undefined} data-free={freeShippingThreshold}> <form class="checkout__form" id={id} novalidate data-checkout-form> <fieldset class="checkout__section"> <legend><span class="checkout__num" aria-hidden="true">1</span> Contact</legend> <AnimatedInput label="Email" id={`${id}-email`} name="email" type="email" required autocomplete="email" /> <label class="checkout__check"> <input type="checkbox" name="newsletter" checked /> <span class="checkout__box" aria-hidden="true"></span> Email me with news and offers </label> </fieldset> <fieldset class="checkout__section"> <legend><span class="checkout__num" aria-hidden="true">2</span> Delivery</legend> <ModernSelect label="Country / region" id={`${id}-country`} name="country" options={countries} required /> <div class="checkout__row"> <AnimatedInput label="First name" id={`${id}-first`} name="firstName" required autocomplete="given-name" /> <AnimatedInput label="Last name" id={`${id}-last`} name="lastName" required autocomplete="family-name" /> </div> <AnimatedInput label="Address" id={`${id}-address`} name="address" required autocomplete="address-line1" /> <AnimatedInput label="Apartment, suite (optional)" id={`${id}-address2`} name="address2" autocomplete="address-line2" /> <div class="checkout__row checkout__row--3"> <AnimatedInput label="City" id={`${id}-city`} name="city" required autocomplete="address-level2" /> <AnimatedInput label="State" id={`${id}-state`} name="state" autocomplete="address-level1" /> <AnimatedInput label="ZIP / Postal" id={`${id}-zip`} name="zip" required autocomplete="postal-code" /> </div> <AnimatedInput label="Phone (for delivery updates)" id={`${id}-phone`} name="phone" type="tel" autocomplete="tel" /> </fieldset> <fieldset class="checkout__section"> <legend><span class="checkout__num" aria-hidden="true">3</span> Shipping method</legend> <div class="checkout__rates"> {shippingRates.map((r, i) => ( <label class="checkout__rate"> <input type="radio" name="shipping" value={r.id} checked={i === 0} data-price={r.price} data-first={i === 0 ? '' : undefined} /> <span class="checkout__rate-dot" aria-hidden="true"></span> <span class="checkout__rate-text"> <strong>{r.label}</strong> <span>{r.eta}</span> </span> <span class="checkout__rate-price" data-rate-price>{r.price === 0 ? 'Free' : formatMoney(r.price, money)}</span> </label> ))} </div> </fieldset> <fieldset class="checkout__section"> <legend><span class="checkout__num" aria-hidden="true">4</span> Payment</legend> <p class="checkout__secure"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg> All transactions are secure and encrypted. </p> <CustomRadioGroup name="payment" horizontal options={[ { label: 'Card', value: 'card', checked: true }, { label: 'PayPal', value: 'paypal' }, { label: 'Apple Pay', value: 'applepay' }, ]} /> <div class="checkout__card" data-card-fields> <div class="checkout__field"> <label for={`${id}-cc`}>Card number</label> <input id={`${id}-cc`} name="cc" inputmode="numeric" autocomplete="cc-number" placeholder="1234 1234 1234 1234" required pattern="[0-9 ]{15,23}" maxlength="23" data-cc /> </div> <div class="checkout__row"> <div class="checkout__field"> <label for={`${id}-exp`}>Expiry (MM/YY)</label> <input id={`${id}-exp`} name="exp" inputmode="numeric" autocomplete="cc-exp" placeholder="MM/YY" required pattern="(0[1-9]|1[0-2])\/[0-9]{2}" maxlength="5" data-exp /> </div> <div class="checkout__field"> <label for={`${id}-cvc`}>Security code</label> <input id={`${id}-cvc`} name="cvc" inputmode="numeric" autocomplete="cc-csc" placeholder="CVC" required pattern="[0-9]{3,4}" maxlength="4" /> </div> </div> <div class="checkout__field"> <label for={`${id}-ccname`}>Name on card</label> <input id={`${id}-ccname`} name="ccName" autocomplete="cc-name" required /> </div> </div> <p class="checkout__alt" data-alt-pay hidden>You will be redirected to complete the payment securely.</p> </fieldset> <p class="checkout__error" role="alert" data-error hidden></p> <button class="checkout__pay" type="submit" data-pay> <span class="checkout__spinner" aria-hidden="true"></span> <span data-pay-label>Pay now</span> </button> {demo && <p class="checkout__demo">Demo checkout — no data leaves your browser. Try code <strong>ASTRO10</strong>.</p>} </form> <section class="checkout__done" data-done hidden aria-labelledby={`${id}-done-title`}> <AnimatedCheckmark size={72} color="var(--c-success, #34d399)" /> <h2 id={`${id}-done-title`} tabindex="-1">Thank you for your order!</h2> <p>Order <strong data-order-id>#—</strong> is confirmed. A receipt is on its way to <strong data-order-email>your inbox</strong>.</p> <p class="checkout__done-total">Total paid: <strong data-order-total>—</strong></p> <a class="checkout__pay checkout__pay--link" href={continueHref}>Continue shopping</a> </section> </div> <script> import { createCart } from './cart-store'; document.querySelectorAll<HTMLElement>('[data-checkout]:not([data-bound])').forEach((root) => { root.dataset.bound = ''; const form = root.querySelector<HTMLFormElement>('[data-checkout-form]')!; const done = root.querySelector<HTMLElement>('[data-done]')!; const pay = root.querySelector<HTMLButtonElement>('[data-pay]')!; const payLabel = root.querySelector<HTMLElement>('[data-pay-label]')!; const error = root.querySelector<HTMLElement>('[data-error]')!; const cardFields = root.querySelector<HTMLElement>('[data-card-fields]')!; const altPay = root.querySelector<HTMLElement>('[data-alt-pay]')!; const firstRatePrice = root.querySelector<HTMLElement>('[data-first] ~ [data-rate-price]'); const cart = createCart(root.dataset.key); let total = { formatted: '', total: 0, count: 0 }; const firstRateText = firstRatePrice?.textContent || ''; document.addEventListener('ack:totals', (e) => { const d = (e as CustomEvent).detail; total = d; payLabel.textContent = d.count ? `Pay ${d.formatted}` : 'Pay now'; if (firstRatePrice) firstRatePrice.textContent = d.freeStandard || (d.subtotal - d.discount) >= Number(root.dataset.free || 0) && Number(root.dataset.free) > 0 ? 'Free' : firstRateText; }); document.dispatchEvent(new CustomEvent('ack:totals-request')); // Payment method toggles the card fields (and their `required`). const setMethod = () => { const method = (form.elements.namedItem('payment') as RadioNodeList)?.value || 'card'; const isCard = method === 'card'; cardFields.hidden = !isCard; altPay.hidden = isCard; cardFields.querySelectorAll('input').forEach((i) => { i.required = isCard; }); }; form.addEventListener('change', (e) => { if ((e.target as HTMLInputElement).name === 'payment') setMethod(); }); setMethod(); // Light input masks form.querySelector<HTMLInputElement>('[data-cc]')?.addEventListener('input', (e) => { const el = e.target as HTMLInputElement; el.value = el.value.replace(/\D/g, '').slice(0, 19).replace(/(\d{4})(?=\d)/g, '$1 '); }); form.querySelector<HTMLInputElement>('[data-exp]')?.addEventListener('input', (e) => { const el = e.target as HTMLInputElement; const digits = el.value.replace(/\D/g, '').slice(0, 4); el.value = digits.length > 2 ? `${digits.slice(0, 2)}/${digits.slice(2)}` : digits; }); form.addEventListener('submit', (e) => { e.preventDefault(); error.hidden = true; form.querySelectorAll('[aria-invalid]').forEach((n) => n.removeAttribute('aria-invalid')); if (!cart.items().length) { error.textContent = 'Your cart is empty — add a product before checking out.'; error.hidden = false; return; } if (!form.checkValidity()) { const invalid = Array.from(form.querySelectorAll<HTMLInputElement>('input, select')).filter((n) => !n.checkValidity()); invalid.forEach((n) => n.setAttribute('aria-invalid', 'true')); error.textContent = `Please check ${invalid.length === 1 ? '1 field' : `${invalid.length} fields`} highlighted in red.`; error.hidden = false; invalid[0]?.focus(); return; } pay.disabled = true; pay.classList.add('is-loading'); payLabel.textContent = 'Processing…'; const finish = () => { const orderId = `#${Math.floor(100000 + Math.random() * 900000)}`; const email = (form.elements.namedItem('email') as HTMLInputElement).value; root.querySelector('[data-order-id]')!.textContent = orderId; root.querySelector('[data-order-email]')!.textContent = email; root.querySelector('[data-order-total]')!.textContent = total.formatted; form.hidden = true; done.hidden = false; cart.clear(); done.querySelector<HTMLElement>('h2')?.focus(); document.dispatchEvent(new CustomEvent('ack:order-placed', { detail: { orderId, total: total.total } })); window.scrollTo({ top: 0, behavior: 'smooth' }); }; // Demo mode: simulate the payment round-trip. Replace with your provider's SDK. window.setTimeout(finish, root.hasAttribute('data-demo') ? 1100 : 0); }); }); </script> <style> .checkout { container-type: inline-size; min-width: 0; } .checkout__form { display: grid; gap: var(--sp-5, 1.25rem); } .checkout__form[hidden], .checkout__done[hidden] { display: none; } .checkout__section { display: grid; gap: var(--sp-3, 0.75rem); min-width: 0; margin: 0; padding: 0; border: 0; } .checkout--cards .checkout__section { padding: var(--sp-6, 1.5rem); border-radius: var(--r-lg, 20px); border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.05)); background: var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02)); } .checkout__section legend { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); float: left; width: 100%; margin-bottom: var(--sp-2, 0.5rem); font-size: 1.05rem; font-weight: 800; color: var(--c-text-1, #fff); } .checkout__section legend + * { clear: both; } .checkout__num { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: rgba(212, 160, 23, 0.14); color: var(--c-primary-light, #d4a017); font-size: 0.82rem; } .checkout__row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-3, 0.75rem); } .checkout__row--3 { grid-template-columns: repeat(3, minmax(0, 1fr)); } /* Reused form components, tuned to the store palette */ .checkout :global(.anim-input:focus), .checkout :global(.custom-select:focus) { border-color: var(--c-primary-light, #d4a017); box-shadow: 0 0 0 3px rgba(212, 160, 23, 0.14); background: rgba(212, 160, 23, 0.04); } .checkout :global(.anim-input:focus ~ .anim-input-label), .checkout :global(.anim-input:not(:placeholder-shown) ~ .anim-input-label) { color: var(--c-primary-light, #d4a017); } .checkout :global(.anim-input-label), .checkout :global(.select-label) { color: var(--c-text-2, #a3a3a3); } .checkout :global([aria-invalid='true']) { border-color: var(--c-danger, #f87171) !important; box-shadow: 0 0 0 3px rgba(248, 113, 113, 0.14) !important; } .checkout :global(.radio-circle::after) { background: var(--c-primary-light, #d4a017); } .checkout :global(.radio-item input:checked + .radio-circle) { border-color: var(--c-primary-light, #d4a017); box-shadow: 0 0 10px rgba(212, 160, 23, 0.25); } .checkout :global(.radio-group-legend) { display: none; } .checkout__check { position: relative; display: flex; align-items: center; gap: var(--sp-3, 0.75rem); min-height: 40px; font-size: 0.88rem; cursor: pointer; } .checkout__check input { position: absolute; opacity: 0; width: 1px; height: 1px; } .checkout__box { width: 18px; height: 18px; border-radius: 5px; border: 1.5px solid rgb(var(--ink-rgb, 255 255 255) / 0.25); flex-shrink: 0; transition: background 0.15s ease; } .checkout__check input:checked + .checkout__box { background: var(--c-primary-light, #d4a017); border-color: var(--c-primary-light, #d4a017); box-shadow: inset 0 0 0 3px var(--c-bg-1, #0a0a0a); } .checkout__check input:focus-visible + .checkout__box { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; } .checkout__rates { display: grid; gap: var(--sp-2, 0.5rem); } .checkout__rate { position: relative; display: flex; align-items: center; gap: var(--sp-3, 0.75rem); min-height: 64px; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); border-radius: var(--r-md, 12px); border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.1); cursor: pointer; transition: border-color 0.15s ease, background 0.15s ease; } .checkout__rate:hover { border-color: rgb(var(--ink-rgb, 255 255 255) / 0.22); } .checkout__rate:has(input:checked) { border-color: var(--c-primary-light, #d4a017); background: rgba(212, 160, 23, 0.06); } .checkout__rate input { position: absolute; opacity: 0; width: 1px; height: 1px; } .checkout__rate-dot { width: 20px; height: 20px; border-radius: 50%; border: 2px solid rgb(var(--ink-rgb, 255 255 255) / 0.25); flex-shrink: 0; } .checkout__rate input:checked + .checkout__rate-dot { border-color: var(--c-primary-light, #d4a017); box-shadow: inset 0 0 0 4px var(--c-bg-1, #0a0a0a); background: var(--c-primary-light, #d4a017); } .checkout__rate input:focus-visible + .checkout__rate-dot { outline: 2px solid var(--c-primary-light, #d4a017); outline-offset: 2px; } .checkout__rate-text { flex: 1; display: grid; gap: 1px; } .checkout__rate-text strong { color: var(--c-text-1, #fff); font-size: 0.92rem; } .checkout__rate-text span { font-size: 0.8rem; } .checkout__rate-price { font-weight: 800; color: var(--c-text-1, #fff); } .checkout__secure { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 0.82rem; color: var(--c-text-2, #a3a3a3); } .checkout__card { display: grid; gap: var(--sp-3, 0.75rem); padding-top: var(--sp-2, 0.5rem); } .checkout__card[hidden], .checkout__alt[hidden] { display: none; } .checkout__field { display: grid; gap: var(--sp-2, 0.5rem); } .checkout__field label { font-size: 0.82rem; font-weight: 600; color: var(--c-text-2, #a3a3a3); } .checkout__field input { width: 100%; min-height: 50px; padding: 0 var(--sp-4, 1rem); border-radius: var(--r-md, 12px); border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.1)); background: rgb(var(--ink-rgb, 255 255 255) / 0.04); color: var(--c-text-1, #fff); font: inherit; font-size: 0.95rem; letter-spacing: 0.02em; } .checkout__field input::placeholder { color: var(--c-text-3, #737373); } .checkout__field input:focus { outline: none; border-color: var(--c-primary-light, #d4a017); box-shadow: 0 0 0 3px rgba(212, 160, 23, 0.14); } .checkout__alt { margin: 0; padding: var(--sp-4, 1rem); border-radius: var(--r-md, 12px); background: rgb(var(--ink-rgb, 255 255 255) / 0.03); font-size: 0.88rem; } .checkout__error { margin: 0; padding: var(--sp-3, 0.75rem) var(--sp-4, 1rem); border-radius: var(--r-md, 12px); background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); color: var(--c-danger, #fca5a5); font-size: 0.88rem; } .checkout__error[hidden] { display: none; } .checkout__pay { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2, 0.5rem); width: 100%; min-height: 56px; border: 0; border-radius: var(--r-full, 9999px); background: var(--c-primary-light, #d4a017); color: var(--c-on-primary, #050505); font: inherit; font-size: 1rem; font-weight: 800; text-decoration: none; cursor: pointer; transition: filter 0.2s ease, transform 0.2s ease; } .checkout__pay:hover { filter: brightness(1.08); color: var(--c-on-primary, #050505); } .checkout__pay:disabled { cursor: progress; filter: saturate(0.6); } .checkout__pay:focus-visible { outline: 2px solid var(--c-text-1, #fff); outline-offset: 3px; } .checkout__spinner { display: none; width: 18px; height: 18px; border-radius: 50%; border: 2.5px solid rgba(0, 0, 0, 0.25); border-top-color: var(--c-on-primary, #050505); animation: co-spin 0.7s linear infinite; } .checkout__pay.is-loading .checkout__spinner { display: block; } @keyframes co-spin { to { transform: rotate(360deg); } } .checkout__demo { margin: 0; text-align: center; font-size: 0.8rem; color: var(--c-text-2, #a3a3a3); } .checkout__done { display: grid; justify-items: center; gap: var(--sp-3, 0.75rem); padding: var(--sp-12, 3rem) var(--sp-6, 1.5rem); border-radius: var(--r-xl, 28px); border: 1px solid rgba(52, 211, 153, 0.25); background: radial-gradient(circle at 50% 0%, rgba(52, 211, 153, 0.1), transparent 60%), var(--c-surface, rgb(var(--ink-rgb, 255 255 255) / 0.02)); text-align: center; } .checkout__done h2 { margin: 0; font-size: 1.8rem; font-weight: 900; letter-spacing: -0.03em; color: var(--c-text-1, #fff); outline: none; } .checkout__done p { margin: 0; max-width: 32rem; } .checkout__done strong { color: var(--c-text-1, #fff); } .checkout__pay--link { width: auto; padding: 0 var(--sp-8, 2rem); margin-top: var(--sp-4, 1rem); } @container (max-width: 560px) { .checkout--cards .checkout__section { padding: var(--sp-4, 1rem); } .checkout__row, .checkout__row--3 { grid-template-columns: 1fr; } } @media (prefers-reduced-motion: reduce) { .checkout__spinner { animation-duration: 2s; } } </style>

Quick Info

Category
E-commerce
Filename
CheckoutForm.astro
Dependencies
None — pure Astro + CSS
Tags
ecommerceshopcheckoutformpayment