RoomPicker
A 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.
Demo
Add a room: on_change gets the rates chosen.
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/room_picker.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/room_picker.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/room_picker.qwe
component RoomPickerDemoLive() { state note = "Add a room: on_change gets the rates chosen." <div.demo-stack> <RoomPicker id="room-picker-demo" name="stay" nights={4} action="#reserve" on_change={(rs) => { note = `on_change: ${join(rs | map((r) => `${r.count} × ${r.room}/${r.rate} = ${r.total}`), "; ")}` }} rooms={[ { id: "dbl", name: "Double room", size: "22 m²", beds: "1 double bed", sleeps: 2, left: 2, image: "https://picsum.photos/seed/double-room/400/300", features: ["City view", "Air conditioning", "Rain shower"], rates: [{ id: "nr", label: "Saver rate", price: 109.00m, old: 128.00m, refundable: false }, { id: "flex", label: "Flexible rate", price: 129.00m, refundable: true, breakfast: true, note: "Cancel free until 3 Nov, 23:59" }] }, { id: "fam", name: "Family suite", size: "38 m²", beds: "1 king bed and 2 single beds", sleeps: 4, image: "https://picsum.photos/seed/family-suite/400/300", features: ["Balcony", "Kitchenette", "Bath"], rates: [{ id: "flex", label: "Flexible rate", price: 219.00m, refundable: true }] }]}/> <p.demo-note id="room-picker-demo-note">{note}</p> </div>}view RoomPickerDemo() = <div.demo-stack> <RoomPickerDemoLive/> <RoomPicker id="room-picker-demo-hostel" name="beds" currency="EUR" locale="de-DE" max_rooms={2} reserve_label="Book beds" rooms={[ { id: "dorm", name: "Bed in a dorm", beds: "1 bunk bed", sleeps: 1, rates: [{ id: "std", label: "Standard rate", price: 24.50m, breakfast: true }] }]}/> </div>Source
ui/room_picker.qwe
// RoomPicker: a hotel's rooms for chosen dates, to book one or several: each room type// with its photo, size, beds, how many it sleeps and what it has, how few are left,// and under it its rates (non-refundable, free cancellation, with breakfast), each// with its price a night and for the stay and a counter of rooms. The total for the// rooms chosen, how many they sleep and Reserve follow at the foot. It is a real// <form>: Reserve posts `name`_<room>_<rate> = count for each rate chosen to `action`.//// <RoomPicker nights={4} action="/book" rooms={[// { id: "dbl", name: "Double room", size: "22 m²", beds: "1 double bed", sleeps: 2, left: 3,// features: ["City view", "Air conditioning"], image: "/img/dbl.jpg",// rates: [{ id: "nr", label: "Saver rate", price: 109.00m, refundable: false },// { id: "flex", label: "Flexible rate", price: 129.00m, refundable: true, breakfast: true }] }]}/>//// rooms: a list of { id, name, rates, image?, size?, beds?, sleeps?, features?, left?// }. left: how many of that room are left (under 5 says so; the counters stop there);// rates: a list of { id, label, price (a night; decimals best, numbers are read as// decimals), old? (a night before, struck through), refundable? (true: free// cancellation; false: says non-refundable; left out says nothing), breakfast? (true:// included), note? }. nights: the stay's length (1). max_rooms: the most of one room// type when left is not given (5). currency: ISO code ("USD"); locale: how amounts are// written ("en-US"). name: the start of the fields' names ("rooms"). action: where// Reserve posts (this page). reserve_label ("Reserve"). on_change(rooms): inside one// of your components, given the rates chosen, a list of { room, rate, count, total }// (total a decimal for the whole stay), after each change. id, class.import ui/theme.qwe use ui_clslet room_picker_css = css`.ui-roompick { display: grid; gap: 12px; min-width: 0; margin: 0; color: var(--ui-fg); font: 14px/1.4 var(--ui-font); font-variant-numeric: tabular-nums; }.ui-roompick-room { display: grid; grid-template-columns: 200px minmax(0, 1fr); overflow: hidden; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); }.ui-roompick-room:has(.ui-roompick-on) { border-color: var(--ui-accent); }.ui-roompick-bare { grid-template-columns: 1fr; }.ui-roompick-media { position: relative; min-height: 150px; background: var(--ui-subtle); }.ui-roompick-media img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }.ui-roompick-body { display: grid; align-content: start; min-width: 0; }.ui-roompick-about { display: grid; gap: 5px; padding: 14px 16px 10px; }.ui-roompick-titlerow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }.ui-roompick .ui-roompick-name { margin: 0; font-size: 16px; font-weight: 650; }.ui-roompick-left { padding: 1px 8px; border-radius: 999px; background: var(--ui-danger-soft); color: var(--ui-danger); font-size: 12px; font-weight: 600; }.ui-roompick-facts { color: var(--ui-muted); font-size: 13px; }.ui-roompick-feats { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-roompick-feats li { padding: 1px 8px; border: 1px solid var(--ui-border); border-radius: 999px; color: var(--ui-muted); font-size: 12px; }.ui-roompick-rates { margin: 0; padding: 0; list-style: none; }.ui-roompick-rate { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 6px 18px; padding: 10px 16px; border-top: 1px solid var(--ui-border); }.ui-roompick-on { background: color-mix(in srgb, var(--ui-accent-soft) 50%, var(--ui-surface)); }.ui-roompick-terms { display: grid; gap: 2px; min-width: 0; }.ui-roompick-terms b { font-weight: 600; }.ui-roompick-perks { display: flex; flex-wrap: wrap; gap: 2px 12px; margin: 0; padding: 0; list-style: none; font-size: 12px; }.ui-roompick-yes { color: var(--ui-success); font-weight: 600; }.ui-roompick-yes::before { content: "✓ "; }.ui-roompick-no { color: var(--ui-muted); }.ui-roompick-note { color: var(--ui-muted); font-size: 12px; }.ui-roompick-price { display: grid; justify-items: end; gap: 1px; text-align: right; }.ui-roompick-old { color: var(--ui-danger); font-size: 12px; text-decoration: line-through; }.ui-roompick-night { font-size: 17px; font-weight: 700; white-space: nowrap; }.ui-roompick-night small { color: var(--ui-muted); font-size: 12px; font-weight: 500; }.ui-roompick-stay { color: var(--ui-muted); font-size: 12px; }.ui-roompick-count { display: flex; align-items: center; gap: 8px; }.ui-roompick-count output { min-width: 18px; font-size: 15px; font-weight: 650; text-align: center; }.ui-roompick-step { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 1px solid var(--ui-border); border-radius: 50%; background: var(--ui-surface); color: var(--ui-fg); font: 17px/1 var(--ui-font); cursor: pointer; }.ui-roompick-step:hover:not(:disabled) { border-color: var(--ui-accent); color: var(--ui-accent); }.ui-roompick-step:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-roompick-step:disabled { opacity: .35; cursor: not-allowed; }.ui-roompick-foot { position: sticky; bottom: 0; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 16px; padding: 12px 16px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); box-shadow: var(--ui-shadow); }.ui-roompick-sum { display: grid; gap: 1px; }.ui-roompick-sum span { color: var(--ui-muted); font-size: 13px; }.ui-roompick-sum output { font-size: 20px; font-weight: 700; }.ui-roompick-go { height: 40px; padding: 0 22px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 15px var(--ui-font); cursor: pointer; }.ui-roompick-go:hover:not(:disabled) { filter: brightness(1.08); }.ui-roompick-go:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-roompick-go:disabled { opacity: .45; cursor: not-allowed; }@media (max-width: 640px) { .ui-roompick-room { grid-template-columns: 1fr; } .ui-roompick-media { min-height: 0; aspect-ratio: 16 / 9; } .ui-roompick-rate { grid-template-columns: minmax(0, 1fr) auto; } .ui-roompick-price { grid-column: 1; grid-row: 2; justify-items: start; text-align: left; } .ui-roompick-count { grid-column: 2; grid-row: 1 / span 2; }}`// An amount in its currency ("$129.00", "129,00 €").fn room_picker_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)}// "3 rooms", "1 room": a count and its word.fn room_picker_count(n: int, one: str, many: str) -> str = `${n} ${n == 1 ? one : many}`component RoomPicker(rooms: list, nights: int = 1, max_rooms: int = 5, currency: str = "USD", locale: str = "en-US", name: str = "rooms", action: str? = null, reserve_label: str = "Reserve", on_change = null, id: str? = null, class: str? = null) { style global room_picker_css state counts = {} let base = `room-picker-${name}` let stay = nights < 1 ? 1 : nights let key_of = (room, rate) => `${room.id}:${rate.id}` let of_room = (room) => sum((room.rates ?? []) | map((r) => get(counts, key_of(room, r)) ?? 0)) let chosen = flatten(rooms | map((room) => (room.rates ?? []) | map((rate) => { let n = get(counts, key_of(room, rate)) ?? 0 { room: room.id, rate: rate.id, count: n, total: dec(rate.price) * n * stay, sleeps: (room.sleeps ?? 0) * n } }))) | filter((c) => c.count > 0) let total = sum(chosen | map((c) => c.total)) + 0m let how_many = sum(chosen | map((c) => c.count)) let sleeps = sum(chosen | map((c) => c.sleeps)) let step = (room, rate, by: int) => { let k = key_of(room, rate) let n = (get(counts, k) ?? 0) + by let most = room.left ?? max_rooms if n >= 0 && (by < 0 || of_room(room) < most) { counts = set(counts, k, n) if on_change != null { on_change(flatten(rooms | map((rm) => (rm.rates ?? []) | map((rt) => { let c = get(counts, key_of(rm, rt)) ?? 0 { room: rm.id, rate: rt.id, count: c, total: dec(rt.price) * c * stay } }))) | filter((c) => c.count > 0)) } } } <form class={ui_cls(["ui-roompick", class])} id={id} method="post" action={action} aria-label="Choose rooms"> {for room in rooms => { let most = room.left ?? max_rooms let full = of_room(room) >= most let facts = join([room.size, room.beds, room.sleeps != null ? `Sleeps ${room.sleeps}` : null] | filter((x) => x != null), " · ") <section class={ui_cls(["ui-roompick-room", room.image == null && "ui-roompick-bare"])} key={room.id} data-room={room.id} aria-labelledby={`${base}-${room.id}`}> {room.image != null && <div.ui-roompick-media><img src={room.image} alt="" loading="lazy"/></div>} <div.ui-roompick-body> <div.ui-roompick-about> <div.ui-roompick-titlerow> <h3.ui-roompick-name id={`${base}-${room.id}`}>{room.name}</h3> {room.left != null && room.left < 5 && <span.ui-roompick-left>{room.left == 1 ? "Only 1 left" : `Only ${room.left} left`}</span>} </div> {facts != "" && <span.ui-roompick-facts>{facts}</span>} {len(room.features ?? []) > 0 && <ul.ui-roompick-feats aria-label="In the room">{for f in room.features => <li>{f}</li>}</ul>} </div> <ul.ui-roompick-rates> {for rate in (room.rates ?? []) => { let k = key_of(room, rate) let n = get(counts, k) ?? 0 let night = dec(rate.price) let perks = [ rate.refundable == true && { yes: true, text: "Free cancellation" }, rate.refundable == false && { yes: false, text: "Non-refundable" }, rate.breakfast == true && { yes: true, text: "Breakfast included" }, ] | filter((x) => x != false) <li class={ui_cls(["ui-roompick-rate", n > 0 && "ui-roompick-on"])} key={rate.id} data-rate={rate.id}> <span.ui-roompick-terms> <b id={`${base}-${room.id}-${rate.id}`}>{rate.label}</b> {len(perks) > 0 && <ul.ui-roompick-perks>{for p in perks => <li class={p.yes ? "ui-roompick-yes" : "ui-roompick-no"}>{p.text}</li>}</ul>} {rate.note != null && <span.ui-roompick-note>{rate.note}</span>} </span> <span.ui-roompick-price> {rate.old != null && dec(rate.old) > night && <span.ui-roompick-old><span.ui-sr>was </span>{room_picker_money(dec(rate.old), currency, locale)}</span>} {" "}<span.ui-roompick-night>{room_picker_money(night, currency, locale)} <small>/ night</small></span> {stay > 1 && [" ", <span.ui-roompick-stay>{room_picker_money(night * stay, currency, locale)} for {stay} nights</span>]} </span> <span.ui-roompick-count role="group" aria-label={`${room.name}, ${rate.label}`}> <button.ui-roompick-step type="button" aria-label={`One ${lower(room.name)} fewer, ${lower(rate.label)}`} disabled={n <= 0} @click={() => step(room, rate, -1)}>−</button> <output aria-live="polite" aria-label={room_picker_count(n, "room", "rooms")}>{n}</output> <button.ui-roompick-step type="button" aria-label={`One ${lower(room.name)} more, ${lower(rate.label)}`} disabled={full} @click={() => step(room, rate, 1)}>+</button> </span> {n > 0 && <input type="hidden" name={`${name}_${room.id}_${rate.id}`} value={str(n)}/>} </li> }} </ul> </div> </section> }} <div.ui-roompick-foot> <div.ui-roompick-sum> <span id={`${base}-sum`}>{how_many == 0 ? "No rooms chosen yet" : `${room_picker_count(how_many, "room", "rooms")} · sleeps ${sleeps} · ${room_picker_count(stay, "night", "nights")}`}</span> <output aria-labelledby={`${base}-sum`} aria-live="polite" data-amount={str(total)}>{room_picker_money(total, currency, locale)}</output> </div> <button.ui-roompick-go type="submit" disabled={how_many == 0}>{reserve_label}</button> </div> </form>}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 downloadsFareOptionsFare 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.8 views · 11 downloadsHotelCardA hotel in search results: photo, stars, the review score badge, location, amenities, cancellation terms and the price a night with the stay's total.9 views · 10 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 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 downloads
Thread
Thread 0 posts
On RoomPicker: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.