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.
Demo
Pick a fare: on_change gets it.
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.qwemain.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>}Related
BaggageSelectorChecked bags for each passenger with counters, what the first, second and third bag cost, the cabin allowance, the total and the saving against the airport.9 views · 8 downloadsFlightResultA flight in search results: departure and arrival times, the time in the air, stops and layovers, cabin and bags, and the fare with Select.9 views · 9 downloadsFlightSearchA flight search form: return or one way, from and to with a swap button and airport suggestions, dates, travellers and cabin, checked before it searches.8 views · 7 downloadsRoomPickerA hotel's rooms with their rates (non-refundable, free cancellation, breakfast) and a counter for each, rooms left, the stay's total, who it sleeps and Reserve.9 views · 9 downloadsSeatMapA plane's cabin to pick a seat for each passenger: aisles, window and aisle seats, taken seats, exit and extra legroom rows, a price per seat and the total.8 views · 7 downloadsCarRentalCardA hire car offer: class and model or similar, seats, bags, gearbox and air conditioning, mileage and fuel policy, supplier rating, pick-up and the price a day and in all.10 views · 11 downloads
Thread
Thread 0 posts
On FareOptions: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.