@use 'sprucecss/scss/spruce' as *;
.coupon-form {
background-color: color('surface');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('lg');
padding: spacer('m');
@include layout-stack('s');
&__title {
font-size: font-size('h5');
}
&__form {
display: flex;
gap: spacer('xs');
.form-control {
flex: 1 1 auto;
min-inline-size: 0;
text-transform: uppercase;
&::placeholder {
text-transform: none;
}
}
}
&__message {
color: color('muted');
font-size: config('font-size-sm', $typography);
&--error {
color: color('invalid', 'form');
}
&--success {
color: color('success', 'alert');
}
}
&__applied {
display: flex;
flex-wrap: wrap;
gap: spacer('xs');
list-style: none;
padding: 0;
&:empty {
display: none;
}
> li {
margin-block: 0;
padding-block: 0;
}
}
&__chip {
align-items: center;
background-color: color-alpha('success', 0.12, 'alert');
border-radius: radius('pill');
color: color('heading');
display: inline-flex;
font-size: config('font-size-sm', $typography);
gap: spacer('xxs');
margin: 0 !important;
padding-block: spacer('xxs');
padding-inline: spacer('s') spacer('xxs');
> svg {
block-size: 1rem;
color: color('success', 'alert');
inline-size: 1rem;
}
}
&__code {
font-weight: 700;
letter-spacing: 0.04em;
}
&__detail {
color: color('muted');
}
&__remove {
@include clear-btn;
align-items: center;
block-size: 1.5rem;
border-radius: radius('pill');
color: color('muted');
display: inline-flex;
inline-size: 1.5rem;
justify-content: center;
&:hover {
background-color: color-alpha('danger', 0.12, 'alert');
color: color('danger', 'alert');
}
&:focus-visible {
outline: 2px solid color('focus-ring');
outline-offset: 1px;
}
svg {
block-size: 0.9rem;
inline-size: 0.9rem;
}
}
&__totals {
border-block-start: config('border-width-base', $display) solid color('border');
margin-block: spacer('s') 0;
padding-block-start: spacer('s');
@include layout-stack('xxs');
div {
display: flex;
justify-content: space-between;
}
dt {
font-weight: 400;
}
dd {
font-variant-numeric: tabular-nums;
margin: 0;
}
[hidden] {
display: none;
}
}
&__discount dd {
color: color('success', 'alert');
font-weight: 600;
}
&__total {
color: color('heading');
font-size: config('font-size-lg', $typography);
font-weight: 700;
dt {
font-weight: 700;
}
}
}
<section class="coupon-form" aria-labelledby="coupon-form-title" data-coupon-form data-subtotal="128.00">
<h2 class="coupon-form__title" id="coupon-form-title">Discount code</h2>
<form class="coupon-form__form" action="#" method="post" novalidate data-coupon-form-form>
<label class="sr-only" for="coupon-code">Discount code</label>
<input class="form-control" id="coupon-code" name="code" type="text" autocomplete="off" autocapitalize="characters" spellcheck="false" placeholder="Enter a code" aria-describedby="coupon-form-message">
<button class="btn btn--secondary" type="submit">Apply</button>
</form>
<p class="coupon-form__message" id="coupon-form-message" aria-live="polite" data-coupon-form-message>Try <strong>SPRUCE10</strong> or <strong>FREESHIP</strong>.</p>
<ul class="coupon-form__applied" role="list" data-coupon-form-applied></ul>
<dl class="coupon-form__totals">
<div>
<dt>Subtotal</dt>
<dd>$128.00</dd>
</div>
<div class="coupon-form__discount" hidden data-coupon-form-discount-row>
<dt>Discount</dt>
<dd data-coupon-form-discount>−$0.00</dd>
</div>
<div>
<dt>Shipping</dt>
<dd data-coupon-form-shipping>$6.90</dd>
</div>
<div class="coupon-form__total">
<dt>Total</dt>
<dd data-coupon-form-total>$134.90</dd>
</div>
</dl>
<template data-coupon-form-chip>
<li class="coupon-form__chip">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8z"/><path d="M7.5 7.5h.01"/></svg>
<span class="coupon-form__code"></span>
<span class="coupon-form__detail"></span>
<button class="coupon-form__remove" type="button"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg><span class="sr-only"></span></button>
</li>
</template>
</section>
(() => {
const codes = {
SPRUCE10: { type: 'percent', value: 10, detail: '10% off' },
FREESHIP: { type: 'shipping', detail: 'Free shipping' }
};
const shippingCost = 6.9;
const money = (value) => `$${value.toFixed(2)}`;
document.querySelectorAll('[data-coupon-form]').forEach((section) => {
const form = section.querySelector('[data-coupon-form-form]');
const input = form.elements.code;
const message = section.querySelector('[data-coupon-form-message]');
const applied = section.querySelector('[data-coupon-form-applied]');
const template = section.querySelector('[data-coupon-form-chip]');
const subtotal = Number(section.dataset.subtotal);
const active = new Set();
const setMessage = (text, type) => {
message.textContent = text;
message.className = `coupon-form__message${type ? ` coupon-form__message--${type}` : ''}`;
input.setAttribute('aria-invalid', String(type === 'error'));
};
const render = () => {
let discount = 0;
let shipping = shippingCost;
applied.replaceChildren();
active.forEach((code) => {
const coupon = codes[code];
const chip = template.content.firstElementChild.cloneNode(true);
if (coupon.type === 'percent') discount += subtotal * coupon.value / 100;
if (coupon.type === 'shipping') shipping = 0;
chip.querySelector('.coupon-form__code').textContent = code;
chip.querySelector('.coupon-form__detail').textContent = coupon.detail;
chip.querySelector('.coupon-form__remove .sr-only').textContent = `Remove the code ${code}`;
chip.querySelector('.coupon-form__remove').dataset.code = code;
applied.append(chip);
});
section.querySelector('[data-coupon-form-discount-row]').hidden = discount === 0;
section.querySelector('[data-coupon-form-discount]').textContent = `−${money(discount)}`;
section.querySelector('[data-coupon-form-shipping]').textContent = shipping ? money(shipping) : 'Free';
section.querySelector('[data-coupon-form-total]').textContent = money(subtotal - discount + shipping);
};
form.addEventListener('submit', (event) => {
event.preventDefault();
const code = input.value.trim().toUpperCase();
if (!code) {
setMessage('Enter a discount code.', 'error');
} else if (!codes[code]) {
setMessage(`The code ${code} isn’t valid. Check the spelling, or it may have expired.`, 'error');
} else if (active.has(code)) {
setMessage(`The code ${code} is already applied.`, 'error');
} else {
active.add(code);
render();
setMessage(`${code} applied: ${codes[code].detail.toLowerCase()}.`, 'success');
form.reset();
return;
}
input.focus();
});
applied.addEventListener('click', (event) => {
const button = event.target.closest('.coupon-form__remove');
if (!button) return;
active.delete(button.dataset.code);
render();
setMessage(`The code ${button.dataset.code} is removed.`);
input.focus();
});
});
})();