Great for a small team
Verified purchaseMarta R.
Set up in an afternoon. The only thing missing is a dark mode for the invoices.
12 people found this helpful
One review: stars (halves too), title, text, reviewer and date, a Verified mark, images, the seller's reply and a Helpful vote.
Set up in an afternoon. The only thing missing is a dark mode for the invoices.
Not voted.
It needs theme.qwe, as every component does, and avatar.qwe; 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/avatar.qwe$ curl -O https://quillolang.org/components/ui/review_card.qweimport ui/theme.qwe use theme_cssuse ui/review_card.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>The code of the preview above.
component ReviewCardDemoLive() { state votes = 12 state voted = false <div.demo-stack> <ReviewCard id="rc-live" rating={4.5} title="Great for a small team" name="Marta R." date="2026-09-14" verified={true} text="Set up in an afternoon. The only thing missing is a dark mode for the invoices." helpful={votes} voted={voted} on_helpful={() => { votes = voted ? votes - 1 : votes + 1; voted = !voted }}/> <p.demo-note>{voted ? "You found this helpful." : "Not voted."}</p> </div>}view ReviewCardDemo() = <div.demo-stack> <form method="post" action="/reviews/helpful" id="review-card-demo-form"> <ReviewCard id="rc-1" rating={5} title="Exactly as described" name="Ola N." date="2026-10-02" verified={true} helpful={3} text="Arrived in two days, well packed.\nWould buy again." images={["https://picsum.photos/seed/box1/128", "https://picsum.photos/seed/box2/128"]}/> <ReviewCard id="rc-2" rating={2} title="Smaller than it looks" name="Jun K." date="2026-08-21" helpful={0} text="The photos make it look twice the size." response={{ name: "The shop", date: "2026-08-22", text: "Sorry about that: the dimensions are now on the page. We have sent a return label." }}/> <ReviewCard id="rc-3" rating={3.5} name="Anonymous" date="2026-07-30" text="Fine."/> </form> <ReviewCardDemoLive/> </div>// ReviewCard: one review of a product or a place: the stars, a title, the text, who// wrote it and when, a Verified mark, and how many found it helpful (with a button// to say so). ReviewList (review_list.qwe) shows many of them sorted and filtered.//// <ReviewCard id="r1" rating={4} title="Solid, a little heavy" text="..." name="Marta R."// date="2026-09-14" verified={true} helpful={12}/>// <ReviewCard {...r} voted={contains(mine, r.id)} on_helpful={() => { vote(r.id) }}/>//// id: the review's id (what Helpful sends). rating: 0 to 5, halves allowed, read to// screen readers as "4 out of 5 stars". title, text: what they wrote. name, src: who,// with their picture (initials without one). date: an ISO date, shown as "Sep 14,// 2026". verified: a "Verified purchase" mark. helpful: how many found it helpful,// with a Helpful button (left out, no button); voted: this reader did, which shows// the button pressed. images: a few image URLs they attached, as thumbnails.// response: { name, text, date? }, the seller's answer under it.// The Helpful button calls on_helpful() inside one of your components; otherwise it// is a submit button named `helpful` with the review's id, for a form around the// reviews. Needs avatar.qwe.import ui/avatar.qwe use Avatarimport ui/theme.qwe use ui_clslet review_card_css = css`.ui-review-card { display: grid; gap: 8px; min-width: 0; padding: 16px 0; border-bottom: 1px solid var(--ui-border); color: var(--ui-fg); font: 14px/1.5 var(--ui-font); }.ui-review-card:first-child { padding-top: 0; }.ui-review-card-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }.ui-review-card-stars { display: inline-flex; gap: 1px; color: var(--ui-warning); }.ui-review-card-stars svg { width: 15px; height: 15px; }.ui-review-card-star-off { color: var(--ui-border); }.ui-review-card-star-half { clip-path: inset(0 50% 0 0); }.ui-review-card-title { margin: 0; font-size: 15px; font-weight: 600; }.ui-review-card-verified { display: inline-flex; align-items: center; gap: 3px; color: var(--ui-success); font-size: 12px; font-weight: 500; }.ui-review-card-verified svg { width: 13px; height: 13px; }.ui-review-card-who { display: flex; align-items: center; gap: 8px; color: var(--ui-muted); font-size: 13px; }.ui-review-card-name { color: var(--ui-fg); font-weight: 500; }.ui-review-card-text { margin: 0; white-space: pre-line; }.ui-review-card-images { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }.ui-review-card-images img { display: block; width: 64px; height: 64px; border-radius: var(--ui-radius-sm); object-fit: cover; }.ui-review-card-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; color: var(--ui-muted); font-size: 13px; }.ui-review-card-vote { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: 999px; background: var(--ui-surface); color: var(--ui-fg); font: 500 13px/1 var(--ui-font); cursor: pointer; }.ui-review-card-vote:hover { border-color: var(--ui-strong); }.ui-review-card-vote:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-review-card-vote[aria-pressed="true"] { border-color: var(--ui-accent); background: var(--ui-accent-soft); color: var(--ui-accent); }.ui-review-card-vote svg { width: 14px; height: 14px; }.ui-review-card-reply { display: grid; gap: 4px; margin-top: 4px; padding: 10px 12px; border-left: 3px solid var(--ui-border); background: var(--ui-subtle); border-radius: 0 var(--ui-radius-sm) var(--ui-radius-sm) 0; }.ui-review-card-reply-who { font-size: 13px; color: var(--ui-muted); }.ui-review-card-reply-who b { color: var(--ui-fg); font-weight: 600; }.ui-review-card-reply p { margin: 0; }`let review_card_months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]// An ISO date as "Sep 14, 2026"; anything else as it is.fn review_card_date(d) -> str { if d == null { return "" } let p = split(slice(str(d), 0, 10), "-") len(p) == 3 && int(p[1]) != null && int(p[2]) != null ? `${review_card_months[int(p[1]) - 1]} ${int(p[2])}, ${p[0]}` : str(d)}// The stars of a rating out of 5: full, half and empty ones, as one picture.fn review_card_stars(rating: num, label: str = "") = <span.ui-review-card-stars role="img" aria-label={`${label != "" ? label + ": " : ""}${rating} out of 5 stars`}> {for i in range(0, 5) => { let fill = rating >= i + 1 ? "full" : rating >= i + 0.5 ? "half" : "off" <svg viewBox="0 0 24 24" class={fill == "off" ? "ui-review-card-star-off" : null} fill="currentColor" aria-hidden="true"> {fill == "half" ? [<path class="ui-review-card-star-off" d="M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6L2.5 9.4l6.6-.8Z"/>, <path.ui-review-card-star-half d="M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6L2.5 9.4l6.6-.8Z"/>] : <path d="M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6L2.5 9.4l6.6-.8Z"/>} </svg> }} </span>view ReviewCard(id = null, rating: num = 0, title: str? = null, text: str = "", name: str = "Anonymous", src: str? = null, date = null, verified: bool = false, helpful: int? = null, voted: bool = false, images: list = [], response = null, on_helpful = null, class: str? = null) { style global review_card_css <article class={ui_cls(["ui-review-card", class])} id={id != null ? `review-${id}` : null}> <div.ui-review-card-head> {review_card_stars(rating)} {title != null && <h4.ui-review-card-title>{title}</h4>} {verified && <span.ui-review-card-verified> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg> Verified purchase </span>} </div> <div.ui-review-card-who> <Avatar name={name} src={src} size="xs"/> <span.ui-review-card-name>{name}</span> {date != null && <time datetime={str(date)}>{review_card_date(date)}</time>} </div> {text != "" && <p.ui-review-card-text>{text}</p>} {len(images) > 0 && <ul.ui-review-card-images>{for u in images => <li key={u}><img src={u} alt="" loading="lazy"/></li>}</ul>} {helpful != null && <div.ui-review-card-foot> <span>{helpful == 0 ? "Was this helpful?" : `${helpful} ${helpful == 1 ? "person" : "people"} found this helpful`}</span> {on_helpful != null ? <button.ui-review-card-vote type="button" aria-pressed={voted ? "true" : "false"} @click={() => { on_helpful() }}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 10v11H3V10h4Zm0 0 4-8a3 3 0 0 1 3 3v4h5a2 2 0 0 1 2 2.3l-1.4 8A2 2 0 0 1 17.6 21H7"/></svg> Helpful </button> : <button.ui-review-card-vote type="submit" name="helpful" value={str(id ?? "")} aria-pressed={voted ? "true" : "false"}> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 10v11H3V10h4Zm0 0 4-8a3 3 0 0 1 3 3v4h5a2 2 0 0 1 2 2.3l-1.4 8A2 2 0 0 1 17.6 21H7"/></svg> Helpful </button>} </div>} {response != null && <div.ui-review-card-reply> <div.ui-review-card-reply-who><b>{response.name}</b> replied{response.date != null && ` · ${review_card_date(response.date)}`}</div> <p>{response.text}</p> </div>} </article>}On ReviewCard: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.