CardControls
A card's controls beside its masked face: freeze it, set daily spending and cash limits, and allow online, ATM, contactless and abroad use.
Demo
DebitVisa
Allowed: online, ATM, contactless. Blocked: abroad.
Change something.
CreditMastercard
Frozen: no payments or withdrawals go through until you unfreeze the card.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/card_controls.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/card_controls.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/card_controls.qwe
component CardControlsDemoLive() { state said = "" <div.demo-stack> <CardControls id="card-controls-demo" brand="Visa" kind="Debit" holder="Dana Whitfield" number="4000 1234 5678 4821" expiry="08/29" spend_limit={1500} cash_limit={300} abroad={false} on_change={(s) => { said = `frozen ${s.frozen}, online ${s.online}, atm ${s.atm}, abroad ${s.abroad}, spend ${s.spend_limit}, cash ${s.cash_limit}` }}/> <p.demo-note>{said == "" ? "Change something." : `on_change: ${said}`}</p> </div>}view CardControlsDemo() = <div.demo-stack> <CardControlsDemoLive/> <CardControls id="card-controls-demo-credit" brand="Mastercard" kind="Credit" holder="Dana Whitfield" number="1937" expiry="02/28" face="dark" frozen={true} currency="EUR" locale="de-DE" spend_limit={2000} spend_max={8000} cash_limit={0} cash_max={500}/> </div>Source
ui/card_controls.qwe
// CardControls: a debit or credit card's controls beside the card itself (its// number masked): a switch to freeze it, daily limits for spending and for cash,// and switches for online, ATM, contactless and abroad use. A line under them// says what is allowed and what is blocked. Every control is a real input with a// name (frozen, online, atm, contactless, abroad, spend_limit, cash_limit), so a// plain <form> around it sends them.//// <CardControls brand="Visa" kind="Debit" holder="Dana Whitfield" number="4821" expiry="08/29"// spend_limit={1500} spend_max={5000} cash_limit={300} cash_max={1000} abroad={false}/>// <CardControls number="1937" kind="Credit" frozen={true} on_change={(s) => { settings = s }}/>//// brand ("Visa", "Mastercard", ...), kind ("Debit" or "Credit"), holder, number// (only its last four show), expiry ("MM/YY"): what the card face shows. face:// "accent" (default), "dark" or "green". frozen: whether it starts frozen; a// frozen card shows so, and the line says nothing goes through. online, atm,// contactless, abroad: each use allowed (true) or not. spend_limit and cash_limit:// the daily limits to start with; spend_max (5000) and cash_max (1000) the most// each can be set to, step (50) what each move of a slider adds. currency ("USD")// and locale: how the limits are written. on_change(settings): inside one of your// components, given { frozen, online, atm, contactless, abroad, spend_limit,// cash_limit } after each change. id: the element's id, and the start of its fields'.import ui/theme.qwe use ui_clslet card_controls_css = css`.ui-cardctl { display: grid; grid-template-columns: minmax(0, 300px) minmax(0, 1fr); gap: 20px 28px; align-items: start; min-width: 0; font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }@media (max-width: 640px) { .ui-cardctl { grid-template-columns: minmax(0, 1fr); } }.ui-cardctl-face { --f: var(--ui-accent); position: relative; display: grid; grid-template-rows: auto 1fr auto auto; gap: 8px; aspect-ratio: 1.586; max-width: 300px; padding: 18px 20px; border-radius: 14px; overflow: hidden; color: #fff; background: linear-gradient(135deg, color-mix(in srgb, var(--f) 92%, #fff) 0%, var(--f) 45%, color-mix(in srgb, var(--f) 60%, #000) 100%); box-shadow: var(--ui-shadow); transition: filter .2s; }.ui-cardctl-dark .ui-cardctl-face { --f: #23262e; }.ui-cardctl-green .ui-cardctl-face { --f: #0f7a55; }.ui-cardctl-face::after { content: ""; position: absolute; right: -40px; top: -60px; width: 190px; height: 190px; border-radius: 50%; background: rgba(255, 255, 255, .08); }.ui-cardctl-top { display: flex; justify-content: space-between; align-items: center; font-size: 12px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }.ui-cardctl-brand { font-size: 16px; font-style: italic; font-weight: 800; letter-spacing: .02em; text-transform: none; }.ui-cardctl-chip { width: 36px; height: 27px; align-self: center; }.ui-cardctl-pan { font: 500 16px var(--ui-mono); letter-spacing: .12em; white-space: nowrap; }.ui-cardctl-bottom { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; text-transform: uppercase; letter-spacing: .05em; }.ui-cardctl-bottom small { display: block; font-size: 9px; opacity: .75; }.ui-cardctl-ice { position: absolute; inset: 0; z-index: 1; display: none; place-content: center; justify-items: center; gap: 4px; background: rgba(200, 222, 255, .35); backdrop-filter: blur(1.5px); color: #fff; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; text-shadow: 0 1px 2px rgba(0, 0, 0, .35); }.ui-cardctl-ice svg { width: 30px; height: 30px; }.ui-cardctl-frozen .ui-cardctl-face { filter: grayscale(.85); }.ui-cardctl-frozen .ui-cardctl-ice { display: grid; }.ui-cardctl-panel { display: grid; gap: 14px; min-width: 0; }.ui-cardctl-group { display: grid; margin: 0; padding: 0; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-cardctl-legend { float: left; width: 100%; padding: 10px 14px 0; color: var(--ui-muted); font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; }.ui-cardctl-row { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 11px 14px; cursor: pointer; }.ui-cardctl-row + .ui-cardctl-row { border-top: 1px solid var(--ui-border); }.ui-cardctl-text { display: grid; gap: 1px; }.ui-cardctl-name { font-weight: 500; }.ui-cardctl-hint { color: var(--ui-muted); font-size: 12px; }.ui-cardctl-row input[type=checkbox] { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; }.ui-cardctl-track { flex: none; position: relative; width: 38px; height: 22px; border-radius: 999px; background: var(--ui-strong); transition: background .15s; }.ui-cardctl-track::after { content: ""; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, .25); transition: transform .15s; }.ui-cardctl-row input:checked + .ui-cardctl-track { background: var(--ui-accent); }.ui-cardctl-row input:checked + .ui-cardctl-track::after { transform: translateX(16px); }.ui-cardctl-row input:focus-visible + .ui-cardctl-track { box-shadow: var(--ui-focus); }.ui-cardctl-freeze input:checked + .ui-cardctl-track { background: var(--ui-info); }.ui-cardctl-limit { display: grid; gap: 6px; padding: 11px 14px; }.ui-cardctl-limit + .ui-cardctl-limit { border-top: 1px solid var(--ui-border); }.ui-cardctl-limit-head { display: flex; justify-content: space-between; gap: 10px; }.ui-cardctl-limit output { font-weight: 600; font-variant-numeric: tabular-nums; }.ui-cardctl-limit input[type=range] { width: 100%; margin: 0; accent-color: var(--ui-accent); }.ui-cardctl-scale { display: flex; justify-content: space-between; color: var(--ui-muted); font-size: 11px; font-variant-numeric: tabular-nums; }.ui-cardctl-uses { transition: opacity .15s; }.ui-cardctl-frozen .ui-cardctl-uses { opacity: .6; }.ui-cardctl-summary { margin: 0; padding: 9px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); color: var(--ui-muted); font-size: 13px; }.ui-cardctl-frozen .ui-cardctl-summary { background: var(--ui-info-soft); color: var(--ui-info); font-weight: 500; }@media (prefers-reduced-motion: reduce) { .ui-cardctl-face, .ui-cardctl-track, .ui-cardctl-track::after, .ui-cardctl-uses { transition: none; } }`// A limit as text in its currency, without cents when it is whole ("$1,500").fn card_controls_money(amount, currency: str, locale: str?) -> str { let m = money.of(dec(amount), currency, "half_up") if is_err(m) { return str(amount) } let text = locale != null ? money.format(m, locale) : money.format(m) let units = money.minor_units(currency) units == 0 || m.amount != floor(m.amount) ? text : re.replace(text, `[.,]0{${units}}(\\D*)$`, "$1")}// What the card may do, in a sentence: "Allowed: online, contactless. Blocked: ATM, abroad."fn card_controls_summary(frozen: bool, uses: list) -> str { if frozen { return "Frozen: no payments or withdrawals go through until you unfreeze the card." } let on = uses | filter((u) => u.on) | map((u) => u.word) let off = uses | filter((u) => !u.on) | map((u) => u.word) let a = len(on) > 0 ? `Allowed: ${join(on, ", ")}.` : "Every use is blocked." len(off) > 0 && len(on) > 0 ? `${a} Blocked: ${join(off, ", ")}.` : a}component CardControls(number: str = "", brand: str = "Visa", kind: str = "Debit", holder: str? = null, expiry: str? = null, face: str = "accent", frozen: bool = false, online: bool = true, atm: bool = true, contactless: bool = true, abroad: bool = true, spend_limit: int = 1500, cash_limit: int = 300, spend_max: int = 5000, cash_max: int = 1000, step: int = 50, currency: str = "USD", locale: str? = null, on_change = null, id: str? = null, class: str? = null) { style global card_controls_css state is_frozen = frozen state use_online = online state use_atm = atm state use_contactless = contactless state use_abroad = abroad state spend = spend_limit state cash = cash_limit let code = upper(currency) let base = id ?? "card-controls" let last4 = slice(re.replace(number, "[^0-9A-Za-z]", ""), -4) let uses = [ { key: "online", label: "Online and phone", hint: "Shops on the web, in apps and by phone", on: use_online, word: "online" }, { key: "atm", label: "Cash machines", hint: "Withdrawals at ATMs", on: use_atm, word: "ATM" }, { key: "contactless", label: "Contactless", hint: "Tap to pay, with the card or a phone", on: use_contactless, word: "contactless" }, { key: "abroad", label: "Abroad", hint: "Payments and cash outside the country", on: use_abroad, word: "abroad" }, ] let report = () => { on_change && on_change({ frozen: is_frozen, online: use_online, atm: use_atm, contactless: use_contactless, abroad: use_abroad, spend_limit: spend, cash_limit: cash }) } let flip = (key: str, on: bool) => { match key { "online" => { use_online = on } "atm" => { use_atm = on } "contactless" => { use_contactless = on } _ => { use_abroad = on } } report() } let said = `${brand} ${lower(kind)} card ending in ${last4}${is_frozen ? ", frozen" : ""}` <div class={ui_cls(["ui-cardctl", face != "accent" && `ui-cardctl-${face}`, is_frozen && "ui-cardctl-frozen", class])} id={id}> <div.ui-cardctl-face role="img" aria-label={said}> <div.ui-cardctl-top><span>{kind}</span><span.ui-cardctl-brand>{brand}</span></div> <svg.ui-cardctl-chip viewBox="0 0 36 27" aria-hidden="true"><rect x=".5" y=".5" width="35" height="26" rx="5" fill="#e6c76a" stroke="#b8963c"/><path d="M12 1v25M24 1v25M1 9h11M24 9h11M1 18h11M24 18h11" stroke="#b8963c" fill="none"/></svg> <div.ui-cardctl-pan aria-hidden="true">•••• •••• •••• {last4}</div> <div.ui-cardctl-bottom aria-hidden="true"> <span><small>Card holder</small>{holder ?? ""}</span> {expiry != null && <span><small>Expires</small>{expiry}</span>} </div> <div.ui-cardctl-ice aria-hidden="true"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M12 2v20M3.3 7l17.4 10M3.3 17 20.7 7M9 4l3 2 3-2M9 20l3-2 3 2M4 10.5l1-3.5-3.5-1M20 13.5l-1 3.5 3.5 1M4 13.5l1 3.5-3.5 1M20 10.5l-1-3.5 3.5-1"/></svg> Frozen </div> </div> <div.ui-cardctl-panel> <div.ui-cardctl-group> <label.ui-cardctl-row.ui-cardctl-freeze> <span.ui-cardctl-text> <span.ui-cardctl-name>Freeze card</span> <span.ui-cardctl-hint>Stops every payment and withdrawal until you unfreeze it</span> </span> <input type="checkbox" role="switch" id={`${base}-frozen`} name="frozen" checked={is_frozen} @change={(e) => { is_frozen = e.checked; report() }}/> <span.ui-cardctl-track aria-hidden="true"></span> </label> </div> <fieldset.ui-cardctl-group> <legend.ui-cardctl-legend>Daily limits</legend> <div.ui-cardctl-limit> <div.ui-cardctl-limit-head> <label for={`${base}-spend`}>Spending</label> <output for={`${base}-spend`} id={`${base}-spend-out`}>{card_controls_money(spend, code, locale)}</output> </div> <input type="range" id={`${base}-spend`} name="spend_limit" min="0" max={spend_max} step={step} value={spend} aria-valuetext={`${card_controls_money(spend, code, locale)} a day`} @input={(e) => { spend = int(e.value) ?? spend }} @change={() => report()}/> <div.ui-cardctl-scale aria-hidden="true"><span>{card_controls_money(0, code, locale)}</span><span>{card_controls_money(spend_max, code, locale)}</span></div> </div> <div.ui-cardctl-limit> <div.ui-cardctl-limit-head> <label for={`${base}-cash`}>Cash withdrawals</label> <output for={`${base}-cash`} id={`${base}-cash-out`}>{card_controls_money(cash, code, locale)}</output> </div> <input type="range" id={`${base}-cash`} name="cash_limit" min="0" max={cash_max} step={step} value={cash} aria-valuetext={`${card_controls_money(cash, code, locale)} a day`} @input={(e) => { cash = int(e.value) ?? cash }} @change={() => report()}/> <div.ui-cardctl-scale aria-hidden="true"><span>{card_controls_money(0, code, locale)}</span><span>{card_controls_money(cash_max, code, locale)}</span></div> </div> </fieldset> <fieldset.ui-cardctl-group.ui-cardctl-uses> <legend.ui-cardctl-legend>Where it works</legend> {for u in uses => <label.ui-cardctl-row key={u.key}> <span.ui-cardctl-text> <span.ui-cardctl-name>{u.label}</span> <span.ui-cardctl-hint>{u.hint}</span> </span> <input type="checkbox" role="switch" id={`${base}-${u.key}`} name={u.key} checked={u.on} @change={(e) => flip(u.key, e.checked)}/> <span.ui-cardctl-track aria-hidden="true"></span> </label>} </fieldset> <p.ui-cardctl-summary role="status">{card_controls_summary(is_frozen, uses)}</p> </div> </div>}Related
AccountCardA bank account at a glance: type, nickname, masked number, current and available balance, what is on hold, and quick actions.8 views · 7 downloadsAccountDetailsThe details to share to get paid: account name and number, IBAN in groups of four (checked), BIC, sort code or routing number, each with a copy button.6 views · 6 downloadsAccountPickerChoose the account to pay from as radio cards with each one's masked number and available balance, those short of the amount marked.7 views · 7 downloadsCurrencyWalletBalances in several currencies with the total in a base currency, each with its rate, its worth and share, and convert and send actions.9 views · 6 downloadsLoanSummaryA loan in a card: the outstanding balance out of the amount borrowed, how much is paid off as a bar, the rate, and the next payment and when it is due.8 views · 7 downloadsRemittanceQuoteSending money abroad: you send, the fee, the rate against the mid-market rate, what they get and when it arrives, worked out as the amount changes.8 views · 5 downloads
Thread
Thread 0 posts
On CardControls: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.