Salon — Gift Card Purchase / Redeem
A luxe, editorial gift-card experience for a boutique salon. The Buy tab renders a rose-gold card that updates live as you set an amount preset or custom value, recipient name, personal note and delivery date, then totals the purchase with add-to-cart and checkout toasts. The Redeem tab validates a code, shakes on errors, and reveals the balance with an animated count-up plus issue details. Vanilla HTML, CSS and JS — fully responsive and keyboard accessible.
MCP
Code
:root {
--gold: #b08d57;
--gold-d: #8c6d3f;
--gold-soft: #efe2cf;
--rose: #c9a78f;
--rose-soft: #f3e6dc;
--ink: #1c1814;
--ink-2: #3d362f;
--muted: #8a7d70;
--cream: #f7f1e8;
--bg: #faf6ef;
--white: #ffffff;
--line: rgba(28, 24, 20, 0.1);
--line-2: rgba(28, 24, 20, 0.18);
--ok: #5f8a6b;
--warn: #c08a3e;
--danger: #b3503e;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 22px;
--serif: "Cormorant Garamond", Georgia, serif;
--sans: "Inter", system-ui, -apple-system, sans-serif;
--shadow-sm: 0 1px 2px rgba(28, 24, 20, 0.05), 0 4px 14px rgba(28, 24, 20, 0.05);
--shadow-md: 0 18px 48px -18px rgba(28, 24, 20, 0.32);
--shadow-card: 0 28px 60px -24px rgba(60, 44, 22, 0.55);
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: var(--sans);
line-height: 1.5;
color: var(--ink);
background: var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3 {
font-family: var(--serif);
font-weight: 600;
line-height: 1.1;
margin: 0;
}
button {
font-family: inherit;
cursor: pointer;
}
input,
textarea {
font-family: inherit;
}
.gc {
max-width: 980px;
margin: 0 auto;
padding: clamp(28px, 5vw, 64px) clamp(18px, 4vw, 40px) 72px;
}
/* ---------- header ---------- */
.gc-head {
text-align: center;
max-width: 560px;
margin: 0 auto clamp(28px, 4vw, 44px);
}
.kicker {
margin: 0 0 14px;
font-size: 0.72rem;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--gold-d);
font-weight: 600;
}
.gc-head h1 {
font-size: clamp(2.6rem, 7vw, 4rem);
font-weight: 600;
letter-spacing: -0.01em;
}
.lede {
margin: 16px auto 0;
color: var(--ink-2);
font-size: 1.02rem;
max-width: 44ch;
}
/* ---------- tabs ---------- */
.tabs {
position: relative;
display: flex;
gap: 4px;
width: max-content;
max-width: 100%;
margin: 0 auto clamp(26px, 4vw, 40px);
padding: 5px;
background: var(--white);
border: 1px solid var(--line);
border-radius: 999px;
box-shadow: var(--shadow-sm);
}
.tab {
position: relative;
z-index: 1;
border: 0;
background: transparent;
padding: 11px 28px;
border-radius: 999px;
font-size: 0.86rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--muted);
transition: color 0.25s ease;
}
.tab.is-active {
color: var(--ink);
}
.tab:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.tab-ink {
position: absolute;
z-index: 0;
top: 5px;
bottom: 5px;
left: 5px;
width: calc(50% - 5px);
border-radius: 999px;
background: linear-gradient(135deg, var(--gold-soft), var(--rose-soft));
border: 1px solid var(--gold-soft);
box-shadow: 0 2px 8px rgba(176, 141, 87, 0.25);
transition: transform 0.32s cubic-bezier(0.65, 0, 0.35, 1);
}
.tabs[data-active="redeem"] .tab-ink {
transform: translateX(100%);
}
/* ---------- layout ---------- */
.grid {
display: grid;
grid-template-columns: 0.92fr 1.08fr;
gap: clamp(22px, 3.5vw, 40px);
align-items: start;
}
.panel {
display: none;
}
.panel.is-active {
display: block;
animation: fade 0.4s ease both;
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* ---------- preview card ---------- */
.preview {
position: sticky;
top: 28px;
}
.preview-label,
.preview-note {
font-size: 0.74rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
margin: 0 0 14px;
}
.preview-note {
text-transform: none;
letter-spacing: 0;
font-weight: 400;
font-size: 0.82rem;
line-height: 1.55;
color: var(--muted);
margin: 18px 2px 0;
}
.card {
position: relative;
overflow: hidden;
aspect-ratio: 1.62 / 1;
padding: clamp(20px, 3.5vw, 28px);
border-radius: var(--r-lg);
color: #fff8ee;
background:
radial-gradient(120% 140% at 0% 0%, #4a3a28 0%, transparent 55%),
linear-gradient(135deg, #2a221a 0%, #3d2f20 42%, #6b5235 100%);
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
isolation: isolate;
}
.card::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid rgba(239, 226, 207, 0.35);
pointer-events: none;
}
.card-sheen {
position: absolute;
inset: -40% -10%;
z-index: -1;
background: linear-gradient(
115deg,
transparent 35%,
rgba(239, 226, 207, 0.22) 48%,
rgba(255, 248, 238, 0.45) 51%,
rgba(239, 226, 207, 0.22) 54%,
transparent 67%
);
transform: translateX(-30%);
animation: sheen 6.5s ease-in-out infinite;
}
@keyframes sheen {
0%,
62%,
100% {
transform: translateX(-60%);
}
78% {
transform: translateX(60%);
}
}
.card-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-brand {
font-family: var(--serif);
font-size: 1.28rem;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--gold-soft);
}
.card-mark {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgba(239, 226, 207, 0.5);
font-family: var(--serif);
font-size: 0.92rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--gold-soft);
}
.card-kicker {
margin: auto 0 2px;
font-size: 0.66rem;
letter-spacing: 0.3em;
text-transform: uppercase;
color: rgba(239, 226, 207, 0.72);
}
.card-amount {
margin: 0;
font-family: var(--serif);
font-size: clamp(2.6rem, 7vw, 3.4rem);
font-weight: 600;
line-height: 1;
letter-spacing: -0.01em;
transition: transform 0.18s ease;
}
.card-amount.bump {
transform: scale(1.06);
}
.card-foot {
display: flex;
justify-content: space-between;
gap: 14px;
margin-top: 16px;
}
.card-meta {
display: block;
font-size: 0.62rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(239, 226, 207, 0.6);
margin-bottom: 3px;
}
.card-foot strong {
font-weight: 500;
font-size: 0.92rem;
}
.card-from {
text-align: right;
}
.card-message {
margin: 12px 0 0;
font-family: var(--serif);
font-style: italic;
font-size: 0.98rem;
line-height: 1.35;
color: rgba(255, 248, 238, 0.88);
min-height: 1.3em;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* ---------- fields ---------- */
form {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
.field legend,
.lbl {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.03em;
color: var(--ink-2);
padding: 0;
}
.lbl em {
font-style: normal;
font-weight: 400;
color: var(--muted);
text-transform: none;
letter-spacing: 0;
margin-left: 4px;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
input[type="text"],
input[type="email"],
input[type="number"],
input[type="date"],
textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--white);
color: var(--ink);
font-size: 0.92rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input::placeholder,
textarea::placeholder {
color: var(--muted);
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(176, 141, 87, 0.16);
}
textarea {
resize: vertical;
min-height: 72px;
}
.counter {
font-size: 0.74rem;
color: var(--muted);
align-self: flex-end;
}
.hint {
margin: 0;
font-size: 0.76rem;
color: var(--muted);
}
/* ---------- amounts ---------- */
.amounts {
display: grid;
grid-template-columns: repeat(2, 1fr) 1.3fr;
gap: 10px;
}
.amount {
border: 1px solid var(--line-2);
background: var(--white);
border-radius: var(--r-sm);
padding: 13px 8px;
font-size: 0.98rem;
font-weight: 600;
color: var(--ink-2);
transition: all 0.2s ease;
}
.amount:hover {
border-color: var(--gold);
color: var(--ink);
}
.amount.is-active {
border-color: var(--gold);
background: linear-gradient(135deg, var(--gold-soft), var(--rose-soft));
color: var(--gold-d);
box-shadow: 0 4px 14px -6px rgba(176, 141, 87, 0.5);
}
.amount:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.amount-custom {
grid-column: 3;
grid-row: 1 / span 2;
display: flex;
align-items: center;
gap: 2px;
padding: 0 12px;
cursor: text;
}
.amount-custom .cur {
color: var(--muted);
font-weight: 600;
}
.amount-custom input {
border: 0;
padding: 0;
background: transparent;
box-shadow: none;
font-weight: 600;
font-size: 0.98rem;
}
.amount-custom input:focus {
box-shadow: none;
}
.amount-custom:focus-within {
border-color: var(--gold);
box-shadow: 0 0 0 3px rgba(176, 141, 87, 0.16);
}
/* ---------- summary ---------- */
.summary {
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 16px 18px;
background: var(--cream);
}
.summary-line {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 0.88rem;
color: var(--ink-2);
padding: 5px 0;
}
.summary-line strong {
font-weight: 600;
}
.summary-total {
margin-top: 6px;
padding-top: 12px;
border-top: 1px solid var(--line-2);
font-size: 1rem;
}
.summary-total strong {
font-family: var(--serif);
font-size: 1.5rem;
font-weight: 600;
color: var(--gold-d);
}
/* ---------- buttons ---------- */
.actions {
display: flex;
gap: 12px;
}
.btn {
flex: 1;
border: 1px solid transparent;
border-radius: 999px;
padding: 14px 20px;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.01em;
transition: transform 0.15s ease, box-shadow 0.25s ease, background 0.25s ease;
}
.btn:active {
transform: translateY(1px);
}
.btn:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 2px;
}
.btn-primary {
background: linear-gradient(135deg, var(--gold) 0%, var(--gold-d) 100%);
color: #fff8ee;
box-shadow: 0 12px 26px -12px rgba(140, 109, 63, 0.7);
}
.btn-primary:hover {
box-shadow: 0 16px 32px -12px rgba(140, 109, 63, 0.85);
}
.btn-ghost {
background: var(--white);
border-color: var(--line-2);
color: var(--ink);
}
.btn-ghost:hover {
border-color: var(--gold);
color: var(--gold-d);
}
.btn-block {
width: 100%;
flex: none;
}
/* ---------- redeem ---------- */
.code-input {
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 600;
text-align: center;
font-size: 1.04rem;
}
.seed {
border: 0;
background: none;
padding: 0;
color: var(--gold-d);
font-weight: 600;
font-size: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
.code-msg {
margin: -8px 0 0;
font-size: 0.82rem;
min-height: 1.2em;
font-weight: 500;
}
.code-msg.is-error {
color: var(--danger);
}
.code-msg.is-ok {
color: var(--ok);
}
.code-input.shake {
animation: shake 0.4s ease;
border-color: var(--danger);
}
@keyframes shake {
10%,
90% {
transform: translateX(-2px);
}
20%,
80% {
transform: translateX(4px);
}
30%,
50%,
70% {
transform: translateX(-7px);
}
40%,
60% {
transform: translateX(7px);
}
}
.balance {
margin-top: 4px;
border: 1px solid var(--gold-soft);
border-radius: var(--r-lg);
padding: 26px 24px;
background:
radial-gradient(120% 130% at 100% 0%, var(--rose-soft) 0%, transparent 60%),
var(--cream);
text-align: center;
animation: reveal 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes reveal {
from {
opacity: 0;
transform: translateY(14px) scale(0.97);
}
}
.balance-kicker {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--gold-d);
font-weight: 600;
}
.balance-amount {
margin: 6px 0 18px;
font-family: var(--serif);
font-size: clamp(3rem, 9vw, 4rem);
font-weight: 600;
line-height: 1;
color: var(--ink);
letter-spacing: -0.01em;
}
.balance-meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 0 0 20px;
padding: 16px 0 0;
border-top: 1px solid var(--line);
}
.balance-meta div {
display: flex;
flex-direction: column;
gap: 4px;
}
.balance-meta dt {
font-size: 0.64rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
}
.balance-meta dd {
margin: 0;
font-size: 0.88rem;
font-weight: 600;
color: var(--ink);
}
.pill {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 600;
background: rgba(95, 138, 107, 0.14);
color: var(--ok);
}
.pill.is-low {
background: rgba(192, 138, 62, 0.16);
color: var(--warn);
}
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 28px;
transform: translate(-50%, 140%);
z-index: 60;
max-width: 90vw;
padding: 13px 22px;
border-radius: 999px;
background: var(--ink);
color: var(--cream);
font-size: 0.88rem;
font-weight: 500;
box-shadow: var(--shadow-md);
opacity: 0;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
pointer-events: none;
}
.toast.show {
transform: translate(-50%, 0);
opacity: 1;
}
/* ---------- responsive ---------- */
@media (max-width: 820px) {
.grid {
grid-template-columns: 1fr;
}
.preview {
position: static;
max-width: 420px;
margin: 0 auto;
}
}
@media (max-width: 520px) {
.gc {
padding: 26px 16px 64px;
}
.tabs {
width: 100%;
}
.tab {
flex: 1;
padding: 11px 12px;
}
.row {
grid-template-columns: 1fr;
}
.amounts {
grid-template-columns: 1fr 1fr;
}
.amount-custom {
grid-column: 1 / -1;
grid-row: auto;
padding: 12px;
}
.actions {
flex-direction: column;
}
.balance-meta {
grid-template-columns: 1fr;
text-align: left;
}
.balance-meta div {
flex-direction: row;
justify-content: space-between;
align-items: baseline;
}
}
@media (prefers-reduced-motion: reduce) {
.card-sheen,
.card-amount,
.panel.is-active,
.balance {
animation: none !important;
transition: none !important;
}
}
/* Visibility guard: honor the [hidden] attribute over base display */
.panel[hidden] {
display: none;
}(function () {
"use strict";
/* ---------- toast helper ---------- */
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2600);
}
function money(n) {
return "$" + Number(n).toLocaleString("en-US");
}
/* ============================================================
TABS
============================================================ */
var tabs = document.getElementById("tabs");
var tabButtons = Array.prototype.slice.call(tabs.querySelectorAll(".tab"));
var panels = {
buy: document.getElementById("panel-buy"),
redeem: document.getElementById("panel-redeem"),
};
function activateTab(name) {
tabs.setAttribute("data-active", name);
tabButtons.forEach(function (btn) {
var on = btn.dataset.tab === name;
btn.classList.toggle("is-active", on);
btn.setAttribute("aria-selected", on ? "true" : "false");
btn.tabIndex = on ? 0 : -1;
});
Object.keys(panels).forEach(function (key) {
var p = panels[key];
var on = key === name;
p.classList.toggle("is-active", on);
p.hidden = !on;
});
}
tabButtons.forEach(function (btn, i) {
btn.addEventListener("click", function () {
activateTab(btn.dataset.tab);
});
btn.addEventListener("keydown", function (e) {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
var dir = e.key === "ArrowRight" ? 1 : -1;
var next = (i + dir + tabButtons.length) % tabButtons.length;
tabButtons[next].focus();
activateTab(tabButtons[next].dataset.tab);
});
});
/* ============================================================
BUY — live preview + summary
============================================================ */
var state = { amount: 75 };
var cardAmount = document.getElementById("cardAmount");
var cardTo = document.getElementById("cardTo");
var cardMessage = document.getElementById("cardMessage");
var cardExpiry = document.getElementById("cardExpiry");
var sumValue = document.getElementById("sumValue");
var sumTotal = document.getElementById("sumTotal");
var amountsWrap = document.getElementById("amounts");
var presetBtns = Array.prototype.slice.call(
amountsWrap.querySelectorAll(".amount[data-amount]")
);
var customInput = document.getElementById("customAmount");
var amountHint = document.getElementById("amountHint");
var recipient = document.getElementById("recipient");
var deliveryDate = document.getElementById("deliveryDate");
var message = document.getElementById("message");
var msgCount = document.getElementById("msgCount");
var email = document.getElementById("email");
function bumpCard() {
cardAmount.classList.remove("bump");
void cardAmount.offsetWidth;
cardAmount.classList.add("bump");
}
function renderAmount() {
var valid = state.amount >= 25 && state.amount <= 2000;
cardAmount.textContent = money(state.amount || 0);
sumValue.textContent = money(valid ? state.amount : 0);
sumTotal.textContent = money(valid ? state.amount : 0);
bumpCard();
}
presetBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
presetBtns.forEach(function (b) {
b.classList.remove("is-active");
b.setAttribute("aria-checked", "false");
});
btn.classList.add("is-active");
btn.setAttribute("aria-checked", "true");
customInput.value = "";
state.amount = Number(btn.dataset.amount);
amountHint.textContent = "Between $25 and $2,000.";
renderAmount();
});
});
customInput.addEventListener("input", function () {
var v = Number(customInput.value);
presetBtns.forEach(function (b) {
b.classList.remove("is-active");
b.setAttribute("aria-checked", "false");
});
if (!customInput.value) {
state.amount = 0;
amountHint.textContent = "Between $25 and $2,000.";
} else if (v < 25 || v > 2000) {
state.amount = 0;
amountHint.textContent = "Please enter an amount between $25 and $2,000.";
} else {
state.amount = v;
amountHint.textContent = "Lovely — " + money(v) + " gift card.";
}
renderAmount();
});
recipient.addEventListener("input", function () {
var name = recipient.value.trim();
cardTo.textContent = name || "A dear friend";
});
message.addEventListener("input", function () {
var txt = message.value.trim();
msgCount.textContent = String(message.value.length);
cardMessage.textContent =
txt || "Wishing you an afternoon of pure indulgence.";
});
// delivery date — default to today, set on the card as "valid until +1 year"
function fmtDate(d) {
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
(function initDate() {
var today = new Date();
var iso = today.toISOString().slice(0, 10);
deliveryDate.min = iso;
deliveryDate.value = iso;
})();
function renderExpiry() {
var base = deliveryDate.value ? new Date(deliveryDate.value) : new Date();
var exp = new Date(base.getFullYear() + 2, base.getMonth(), base.getDate());
cardExpiry.textContent = fmtDate(exp);
}
deliveryDate.addEventListener("change", renderExpiry);
// initial paint
renderAmount();
renderExpiry();
/* ---------- buy actions ---------- */
function validateBuy() {
if (!state.amount || state.amount < 25 || state.amount > 2000) {
amountHint.textContent = "Choose an amount between $25 and $2,000.";
amountsWrap.scrollIntoView({ behavior: "smooth", block: "center" });
return false;
}
var em = email.value.trim();
if (!em || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em)) {
email.focus();
toast("Add a valid recipient email to continue.");
return false;
}
return true;
}
document.getElementById("addCart").addEventListener("click", function () {
if (!validateBuy()) return;
toast(money(state.amount) + " gift card added to cart.");
});
document.getElementById("buyForm").addEventListener("submit", function (e) {
e.preventDefault();
if (!validateBuy()) return;
var to = recipient.value.trim() || "your recipient";
toast(money(state.amount) + " gift card on its way to " + to + ".");
});
/* ============================================================
REDEEM
============================================================ */
var DEMO_CARDS = {
"ML-2026-AURA": {
balance: 180,
issued: "Aria Vance",
used: "May 2, 2026",
status: "Active",
},
"ML-GLOW-7781": {
balance: 42.5,
issued: "Noor Haddad",
used: "Apr 18, 2026",
status: "Low balance",
},
"ML-LUXE-0099": {
balance: 500,
issued: "Celeste Moreau",
used: "Never",
status: "Active",
},
};
var redeemForm = document.getElementById("redeemForm");
var codeInput = document.getElementById("code");
var codeMsg = document.getElementById("codeMsg");
var balanceBox = document.getElementById("balance");
var balanceAmount = document.getElementById("balanceAmount");
var balIssued = document.getElementById("balIssued");
var balUsed = document.getElementById("balUsed");
var balStatus = document.getElementById("balStatus");
document.querySelectorAll(".seed").forEach(function (s) {
s.addEventListener("click", function () {
codeInput.value = s.dataset.seed;
codeInput.focus();
codeMsg.textContent = "";
codeMsg.className = "code-msg";
});
});
codeInput.addEventListener("input", function () {
codeInput.value = codeInput.value.toUpperCase();
});
function hideBalance() {
balanceBox.hidden = true;
balanceBox.setAttribute("aria-hidden", "true");
}
function showError(text) {
codeMsg.textContent = text;
codeMsg.className = "code-msg is-error";
codeInput.classList.remove("shake");
void codeInput.offsetWidth;
codeInput.classList.add("shake");
hideBalance();
}
function showBalance(card) {
codeMsg.textContent = "Card verified.";
codeMsg.className = "code-msg is-ok";
balIssued.textContent = card.issued;
balUsed.textContent = card.used;
balStatus.textContent = card.status;
balStatus.className = "pill" + (card.balance < 50 ? " is-low" : "");
balanceBox.hidden = false;
balanceBox.setAttribute("aria-hidden", "false");
// count-up reveal
var target = card.balance;
var start = performance.now();
var dur = 900;
function tick(now) {
var t = Math.min(1, (now - start) / dur);
var eased = 1 - Math.pow(1 - t, 3);
var val = target * eased;
balanceAmount.textContent =
"$" + (target % 1 ? val.toFixed(2) : Math.round(val).toLocaleString("en-US"));
if (t < 1) requestAnimationFrame(tick);
else
balanceAmount.textContent =
"$" +
(target % 1
? target.toFixed(2)
: target.toLocaleString("en-US"));
}
requestAnimationFrame(tick);
}
redeemForm.addEventListener("submit", function (e) {
e.preventDefault();
var code = codeInput.value.trim().toUpperCase();
if (!code) {
showError("Please enter your gift card code.");
return;
}
if (!/^ML-[A-Z0-9]{3,5}-[A-Z0-9]{3,5}$/.test(code)) {
showError("That doesn't look like a Maison Lumière code (ML-XXXX-XXXX).");
return;
}
var card = DEMO_CARDS[code];
if (!card) {
showError("We couldn't find a card with that code. Please check and retry.");
return;
}
showBalance(card);
toast("Balance retrieved · " + money(card.balance));
});
document.getElementById("applyBalance").addEventListener("click", function () {
toast("Balance linked to your profile for your next visit.");
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap"
/>
<link rel="stylesheet" href="style.css" />
<title>Gift Cards · Maison Lumière Salon</title>
</head>
<body>
<main class="gc" aria-labelledby="gc-title">
<header class="gc-head">
<p class="kicker">Maison Lumière Salon</p>
<h1 id="gc-title">The Gift of Lumière</h1>
<p class="lede">
A considered gift for the people you adore — redeemable across every
service, treatment and boutique product at the maison.
</p>
</header>
<div
class="tabs"
role="tablist"
aria-label="Gift card actions"
id="tabs"
>
<button
class="tab is-active"
role="tab"
id="tab-buy"
aria-selected="true"
aria-controls="panel-buy"
data-tab="buy"
>
Buy a card
</button>
<button
class="tab"
role="tab"
id="tab-redeem"
aria-selected="false"
aria-controls="panel-redeem"
tabindex="-1"
data-tab="redeem"
>
Redeem
</button>
<span class="tab-ink" aria-hidden="true"></span>
</div>
<section class="grid">
<!-- ============ PREVIEW CARD ============ -->
<aside class="preview" aria-live="polite">
<p class="preview-label">Live preview</p>
<div class="card" id="card">
<div class="card-sheen" aria-hidden="true"></div>
<div class="card-top">
<span class="card-brand">Maison Lumière</span>
<span class="card-mark" aria-hidden="true">ML</span>
</div>
<p class="card-kicker">Gift Card</p>
<p class="card-amount" id="cardAmount">$150</p>
<div class="card-foot">
<div class="card-to">
<span class="card-meta">To</span>
<strong id="cardTo">A dear friend</strong>
</div>
<div class="card-from">
<span class="card-meta">Valid until</span>
<strong id="cardExpiry">—</strong>
</div>
</div>
<p class="card-message" id="cardMessage">
Wishing you an afternoon of pure indulgence.
</p>
</div>
<p class="preview-note">
Digital cards are delivered by email on your chosen date.
Never expires for in-salon use.
</p>
</aside>
<!-- ============ BUY PANEL ============ -->
<div
class="panel is-active"
id="panel-buy"
role="tabpanel"
aria-labelledby="tab-buy"
tabindex="0"
>
<form id="buyForm" novalidate>
<fieldset class="field">
<legend>Choose an amount</legend>
<div class="amounts" id="amounts" role="radiogroup" aria-label="Gift card amount">
<button type="button" class="amount is-active" data-amount="75" role="radio" aria-checked="true">$75</button>
<button type="button" class="amount" data-amount="150" role="radio" aria-checked="false">$150</button>
<button type="button" class="amount" data-amount="250" role="radio" aria-checked="false">$250</button>
<button type="button" class="amount" data-amount="500" role="radio" aria-checked="false">$500</button>
<div class="amount amount-custom">
<span class="cur">$</span>
<input
id="customAmount"
type="number"
inputmode="numeric"
min="25"
max="2000"
step="5"
placeholder="Custom"
aria-label="Custom amount in dollars"
/>
</div>
</div>
<p class="hint" id="amountHint">Between $25 and $2,000.</p>
</fieldset>
<div class="row">
<label class="field">
<span class="lbl">Recipient name</span>
<input id="recipient" type="text" maxlength="34" placeholder="e.g. Aria Vance" autocomplete="name" />
</label>
<label class="field">
<span class="lbl">Delivery date</span>
<input id="deliveryDate" type="date" />
</label>
</div>
<label class="field">
<span class="lbl">Personal message <em>optional</em></span>
<textarea
id="message"
maxlength="120"
rows="3"
placeholder="Add a few warm words…"
></textarea>
<span class="counter"><span id="msgCount">0</span>/120</span>
</label>
<label class="field">
<span class="lbl">Recipient email</span>
<input id="email" type="email" placeholder="[email protected]" autocomplete="email" />
</label>
<div class="summary">
<div class="summary-line">
<span>Card value</span>
<strong id="sumValue">$75</strong>
</div>
<div class="summary-line">
<span>Processing</span>
<strong>Free</strong>
</div>
<div class="summary-line summary-total">
<span>Total today</span>
<strong id="sumTotal">$75</strong>
</div>
</div>
<div class="actions">
<button type="button" class="btn btn-ghost" id="addCart">Add to cart</button>
<button type="submit" class="btn btn-primary" id="purchase">Purchase gift card</button>
</div>
</form>
</div>
<!-- ============ REDEEM PANEL ============ -->
<div
class="panel"
id="panel-redeem"
role="tabpanel"
aria-labelledby="tab-redeem"
tabindex="0"
hidden
>
<form id="redeemForm" novalidate>
<label class="field">
<span class="lbl">Enter your gift card code</span>
<input
id="code"
type="text"
class="code-input"
placeholder="ML-XXXX-XXXX"
autocomplete="off"
spellcheck="false"
aria-describedby="codeMsg"
/>
<span class="counter">Try <button type="button" class="seed" data-seed="ML-2026-AURA">ML-2026-AURA</button></span>
</label>
<p class="code-msg" id="codeMsg" role="status" aria-live="polite"></p>
<button type="submit" class="btn btn-primary btn-block" id="redeemBtn">Check balance</button>
<div class="balance" id="balance" hidden aria-hidden="true">
<p class="balance-kicker">Card balance</p>
<p class="balance-amount" id="balanceAmount">$0</p>
<dl class="balance-meta">
<div><dt>Issued to</dt><dd id="balIssued">—</dd></div>
<div><dt>Last used</dt><dd id="balUsed">—</dd></div>
<div><dt>Status</dt><dd><span class="pill" id="balStatus">Active</span></dd></div>
</dl>
<button type="button" class="btn btn-ghost btn-block" id="applyBalance">Apply to my next visit</button>
</div>
</form>
</div>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Gift Card Purchase / Redeem
A two-tab gift-card module for Maison Lumière Salon, built to feel like a real boutique checkout rather than a placeholder. The Buy tab pairs a tactile, rose-gold preview card with a clean form: choose a preset or type a custom amount, name your recipient, write a short note, and pick a delivery date. Every keystroke flows into the card in real time — the value bumps as it changes, the message sets in serif italics, and a tidy summary tallies the total before you add to cart or purchase.
The Redeem tab keeps the same calm typography. Enter a code and the field validates its shape before checking it against demo cards; an invalid entry shakes with a gentle correction, while a valid one unfurls a balance panel with an animated count-up and the card’s issue details. Seeded codes are surfaced inline so the flow is easy to try.
Throughout, gold hairlines, generous whitespace and a Cormorant Garamond display face carry the maison aesthetic. Interactions are keyboard-friendly with proper tablist roles and aria-live feedback, contrast meets WCAG AA, and the layout reflows gracefully down to 360px. No frameworks, no build step — drop in three files and it works.