Travel

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.

#travel#hotels#forms#commerce#interactive

Demo

Preview

Double room

Only 2 left
22 m² · 1 double bed · Sleeps 2
  • City view
  • Air conditioning
  • Rain shower
  • Saver rate
    • Non-refundable
    was $128.00 $109.00 / night $436.00 for 4 nights0
  • Flexible rate
    • Free cancellation
    • Breakfast included
    Cancel free until 3 Nov, 23:59
    $129.00 / night $516.00 for 4 nights0

Family suite

38 m² · 1 king bed and 2 single beds · Sleeps 4
  • Balcony
  • Kitchenette
  • Bath
  • Flexible rate
    • Free cancellation
    $219.00 / night $876.00 for 4 nights0
No rooms chosen yet$0.00

Add a room: on_change gets the rates chosen.

Bed in a dorm

1 bunk bed · Sleeps 1
  • Standard rate
    • Breakfast included
    24,50 € / night0
No rooms chosen yet0,00 €

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.qwe
main.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>}
Thread
99Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.