Travel

FareOptions

Fare families side by side, Basic to Flex: the price a person and over the cheapest, what each includes, costs extra or leaves out, and the total for everyone.

#travel#flights#forms#commerce#interactive

Demo

Preview
Choose your fare
Classic for 2 passengers$238.00

Pick a fare: on_change gets it.

Lisbon to Porto, Alfa Pendular
Turística for 3 passengers93,60 €

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/fare_options.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/fare_options.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/fare_options.qwe
let fare_options_demo_fares = [    { id: "basic", name: "Basic", price: 89.00m, note: "Seat given at check-in", features: [        { label: "Personal item", value: true }, { label: "Cabin bag", value: false }, { label: "Checked bag", value: "fee" },        { label: "Seat choice", value: "fee" }, { label: "Changes", value: false }, { label: "Refund", value: false }] },    { id: "classic", name: "Classic", price: 119.00m, badge: "Most chosen", features: [        { label: "Personal item", value: true }, { label: "Cabin bag", value: "8 kg" }, { label: "Checked bag", value: "1 × 23 kg" },        { label: "Seat choice", value: "Standard seats" }, { label: "Changes", value: "fee" }, { label: "Refund", value: false }] },    { id: "flex", name: "Flex", price: 189.00m, features: [        { label: "Personal item", value: true }, { label: "Cabin bag", value: "8 kg" }, { label: "Checked bag", value: "2 × 23 kg" },        { label: "Seat choice", value: "Any seat, extra legroom too" }, { label: "Changes", value: "Free, up to 2 h before" },        { label: "Refund", value: true }] },]component FareOptionsDemoLive() {    state note = "Pick a fare: on_change gets it."    <div.demo-stack>        <FareOptions id="fare-options-demo" name="trip" passengers={2} value="classic" fares={fare_options_demo_fares}                     on_change={(f) => { note = `on_change: ${f.id}, ${f.total} in all.` }}/>        <p.demo-note id="fare-options-demo-note">{note}</p>    </div>}view FareOptionsDemo() =    <div.demo-stack>        <FareOptionsDemoLive/>        <FareOptions id="fare-options-demo-eur" name="rail" label="Lisbon to Porto, Alfa Pendular" passengers={3} currency="EUR" locale="de-DE" fares={[            { id: "turistica", name: "Turística", price: 31.20m, features: [{ label: "Seat", value: "2nd class" }, { label: "Changes", value: "fee" }] },            { id: "conforto", name: "Conforto", price: 44.30m, features: [{ label: "Seat", value: "1st class" }, { label: "Changes", value: true }] },            { id: "promo", name: "Promo", price: 15.50m, disabled: true, note: "Sold out", features: [{ label: "Changes", value: false }] }]}/>    </div>

Source

ui/fare_options.qwe
// FareOptions: the fares a flight is sold at, side by side (Basic, Classic, Flex),// each with its price a person, what it adds over the cheapest, and what comes with// it: a cabin bag, a checked bag, a seat, changes, a refund, miles, each included, for// a fee or not at all. Picking one shows the total for everyone travelling. Each card// is a real radio, so a plain <form> sends the fare's id as `name`, and the arrow keys// move between them.////   <FareOptions name="fare" passengers={2} value="classic" fares={[//       { id: "basic", name: "Basic", price: 89.00m, features: [{ label: "Cabin bag", value: false },//           { label: "Checked bag", value: false }, { label: "Changes", value: "fee" }, { label: "Refund", value: false }] },//       { id: "classic", name: "Classic", price: 119.00m, badge: "Most chosen", features: [{ label: "Cabin bag", value: true },//           { label: "Checked bag", value: "1 × 23 kg" }, { label: "Changes", value: "fee" }, { label: "Refund", value: false }] },//       { id: "flex", name: "Flex", price: 189.00m, features: [...] }]}/>//// fares: a list of { id, name, price (a person; decimals best, numbers are read as// decimals), features, badge?, note?, disabled? (sold out: it cannot be chosen) }.// features: a list of { label, value }: true (included), false (not included), "fee"// (for a fee) or text (included, saying how: "1 × 23 kg", "Free, up to 24 h before").// value: the id chosen to start with (the first fare's by default). passengers: how// many travel (1), for the total. currency: ISO code ("USD"); locale: how amounts are// written ("en-US"). name: what a form gets the id as ("fare"), and the start of the// ids. label: the legend ("Choose your fare"). on_change(fare): inside one of your// components, given the fare chosen (its map, with `total` added, a decimal). id,// class.import ui/theme.qwe use ui_clslet fare_options_css = css`.ui-fareopt { min-width: 0; margin: 0; padding: 0; border: 0; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); }.ui-fareopt-legend { margin-bottom: 10px; padding: 0; font-size: 16px; font-weight: 650; }.ui-fareopt-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr)); gap: 10px; }.ui-fareopt-card { position: relative; display: flex; flex-direction: column; gap: 10px; min-width: 0; padding: 16px; border: 1px solid var(--ui-border);  border-radius: var(--ui-radius); background: var(--ui-surface); cursor: pointer; transition: border-color .15s, box-shadow .15s, background .15s; }.ui-fareopt-card input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }.ui-fareopt-card:hover { border-color: var(--ui-strong); }.ui-fareopt-card:has(input:checked) { border-color: var(--ui-accent); background: color-mix(in srgb, var(--ui-accent-soft) 45%, var(--ui-surface));  box-shadow: inset 0 0 0 1px var(--ui-accent); }.ui-fareopt-card:has(input:focus-visible) { box-shadow: inset 0 0 0 1px var(--ui-accent), var(--ui-focus); }.ui-fareopt-card:has(input:disabled) { opacity: .5; cursor: not-allowed; }.ui-fareopt-badge { position: absolute; top: -10px; right: 12px; height: 20px; padding: 0 8px; border-radius: 999px; background: var(--ui-accent);  color: var(--ui-accent-fg); font: 600 11px/20px var(--ui-font); letter-spacing: .02em; }.ui-fareopt-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }.ui-fareopt-name { font-size: 16px; font-weight: 650; }.ui-fareopt-dot { flex: none; position: relative; width: 18px; height: 18px; margin-top: 2px; border: 1px solid var(--ui-strong); border-radius: 50%;  background: var(--ui-surface); }.ui-fareopt-dot::after { content: ""; position: absolute; inset: 4px; border-radius: 50%; background: var(--ui-accent); transform: scale(0); transition: transform .12s; }.ui-fareopt-card input:checked ~ .ui-fareopt-top .ui-fareopt-dot { border: 1.5px solid var(--ui-accent); }.ui-fareopt-card input:checked ~ .ui-fareopt-top .ui-fareopt-dot::after { transform: scale(1); }.ui-fareopt-price { display: grid; gap: 1px; font-variant-numeric: tabular-nums; }.ui-fareopt-amount { font-size: 22px; font-weight: 700; letter-spacing: -.01em; }.ui-fareopt-per { color: var(--ui-muted); font-size: 12px; }.ui-fareopt-more { color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-fareopt-feats { display: grid; gap: 6px; margin: 0; padding: 10px 0 0; border-top: 1px solid var(--ui-border); list-style: none; font-size: 13px; }.ui-fareopt-feat { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 8px; align-items: start; }.ui-fareopt-feat svg { width: 16px; height: 16px; margin-top: 1px; }.ui-fareopt-yes svg { color: var(--ui-success); }.ui-fareopt-fee svg { color: var(--ui-warning); }.ui-fareopt-no { color: var(--ui-muted); }.ui-fareopt-no svg { color: var(--ui-muted); }.ui-fareopt-how { display: block; color: var(--ui-muted); font-size: 12px; }.ui-fareopt-note { margin-top: auto; color: var(--ui-muted); font-size: 12px; }.ui-fareopt-total { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; margin-top: 12px; padding: 12px 16px;  border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font-variant-numeric: tabular-nums; }.ui-fareopt-total output { font-size: 18px; font-weight: 700; }@media (prefers-reduced-motion: reduce) { .ui-fareopt-card, .ui-fareopt-dot::after { transition: none; } }`let fare_options_icons = {    yes: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>,    fee: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5c-.5-1-1.5-1.5-2.5-1.5-1.4 0-2.5.8-2.5 2s1.1 1.6 2.5 2 2.5.8 2.5 2-1.1 2-2.5 2c-1 0-2-.5-2.5-1.5M12 6.5V8M12 16v1.5"/></svg>,    no: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17"/></svg>,}// An amount in its currency ("$119.00", "119,00 €").fn fare_options_money(x: dec, currency: str, locale: str) -> str {    let m = money.of(x, currency, "half_up")    type_of(m) == "error" ? `${format_number(round(x, 2, "half_up"), locale, 2)} ${currency}` : money.format(m, locale)}// A feature's state: "yes", "fee" or "no", and the words it adds.fn fare_options_feature(f: map) -> map =    match f.value {        true => { kind: "yes", said: "included", how: null }        false | null => { kind: "no", said: "not included", how: null }        "fee" => { kind: "fee", said: "for a fee", how: "For a fee" }        _ => { kind: "yes", said: `included, ${f.value}`, how: str(f.value) }    }component FareOptions(fares: list, value: str? = null, passengers: int = 1, currency: str = "USD", locale: str = "en-US",                      name: str = "fare", label: str = "Choose your fare", on_change = null, id: str? = null, class: str? = null) {    style global fare_options_css    state chosen = value ?? (fares | find((f) => f.disabled != true) ?? fares[0]).id    let base = `fare-options-${name}`    let open = fares | filter((f) => f.disabled != true)    let cheapest = min((len(open) > 0 ? open : fares) | map((f) => dec(f.price)))    let cheapest_name = ((len(open) > 0 ? open : fares) | find((f) => dec(f.price) == cheapest)).name    let picked = fares | find((f) => f.id == chosen)    let count = passengers < 1 ? 1 : passengers    let total = picked != null ? dec(picked.price) * count : null    <fieldset class={ui_cls(["ui-fareopt", class])} id={id} role="radiogroup" aria-labelledby={`${base}-legend`}>        <legend.ui-fareopt-legend id={`${base}-legend`}>{label}</legend>        <div.ui-fareopt-list>            {for f in fares => {                let fid = `${base}-${f.id}`                let price = dec(f.price)                let more = price - cheapest                let feats = f.features ?? []                <label.ui-fareopt-card for={fid} key={f.id} data-fare={f.id}>                    <input type="radio" id={fid} name={name} value={f.id} checked={chosen == f.id} disabled={f.disabled == true}                           aria-describedby={`${fid}-price ${fid}-feats`}                           @change={() => {                               chosen = f.id                               on_change && on_change(merge(f, { total: dec(f.price) * count }))                           }}/>                    {f.badge != null && <span.ui-fareopt-badge>{f.badge}</span>}                    <span.ui-fareopt-top>                        <span.ui-fareopt-name>{f.name}</span>                        <span.ui-fareopt-dot aria-hidden="true"></span>                    </span>                    <span.ui-fareopt-price id={`${fid}-price`}>                        <span.ui-fareopt-amount data-amount={str(price)}>{fare_options_money(price, currency, locale)}</span>                        {" "}<span.ui-fareopt-per>per person</span>                        {more > 0 && f.disabled != true && [" ", <span.ui-fareopt-more>+{fare_options_money(more, currency, locale)} on {cheapest_name}</span>]}                    </span>                    {len(feats) > 0 && <ul.ui-fareopt-feats id={`${fid}-feats`}>                        {for x in feats => {                            let s = fare_options_feature(x)                            <li class={ui_cls(["ui-fareopt-feat", `ui-fareopt-${s.kind}`])} data-state={s.kind}>                                {fare_options_icons[s.kind]}                                <span>{x.label}<span.ui-sr>: {s.said}</span>{s.how != null && <span.ui-fareopt-how aria-hidden="true">{s.how}</span>}</span>                            </li>                        }}                    </ul>}                    {f.note != null && <span.ui-fareopt-note>{f.note}</span>}                </label>            }}        </div>        <div.ui-fareopt-total>            <span id={`${base}-total-label`}>{picked != null ? picked.name : ""} for {count == 1 ? "1 passenger" : `${count} passengers`}</span>            <output aria-labelledby={`${base}-total-label`} aria-live="polite" data-amount={total != null ? str(total) : null}>                {total != null ? fare_options_money(total, currency, locale) : "—"}            </output>        </div>    </fieldset>}
Thread
811Useful? Like it, or add to its thread below.

Thread 0 posts

On FareOptions: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.