RatePrompt
Asks for a rating only after real use, with limits on how often, and a route to send a problem instead.
Demo
The app. Day 0. Asks after 7 days, 5 starts and 3 good moments.
Press Finish a task.
Get it
It needs theme.qwe, as every component does, and force_update.qwe and welcome_carousel.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/force_update.qwe$ curl -O https://quillolang.org/components/ui/welcome_carousel.qwe$ curl -O https://quillolang.org/components/ui/rate_prompt.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/rate_prompt.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/rate_prompt.qwe
// The same answer a moment later, as a `server fn` gives it: a Promise.fn rate_prompt_demo_late(answer) = js("Promise.resolve", js_new("Promise", (done) => { done(answer) }))component RatePromptDemoLive() { state run = 0 state moment = 0 state days = 0 state why = "Press Finish a task." state sent = "" state fail = false state busy = false state stars = 0 state slow = false let now = 1793000000000 + days * 86400000 <div.demo-stack> <div.demo-phone id="rate-prompt-demo-frame"> <div style="padding: 20px; display: grid; gap: 12px"> <p class="demo-note">The app. Day {days}. Asks after 7 days, 5 starts and 3 good moments.</p> <Button size="lg" id="rate-prompt-demo-task" on_click={() => { moment = moment + 1 }}>Finish a task</Button> <Button size="lg" variant="outline" id="rate-prompt-demo-start" on_click={() => { days = days + 1; run = run + 1 }}>Start again a day later</Button> <Button size="lg" variant="outline" id="rate-prompt-demo-week" on_click={() => { days = days + 100; run = run + 1 }}>Start again 100 days later</Button> <label><input type="checkbox" id="rate-prompt-demo-busy" bind:checked={busy}/> In the middle of something</label> <label><input type="checkbox" id="rate-prompt-demo-fail" bind:checked={fail}/> Sending fails</label> <label><input type="checkbox" id="rate-prompt-demo-slow" bind:checked={slow}/> Answers come as Promises</label> </div> <RatePrompt key={run} app_name="Fieldbook" remember="ui-demo-rate" moment={moment} version="2.3.0" now={now} ready={!busy} ios="https://apps.apple.com/app/id1234567890?action=write-review" android="https://play.google.com/store/apps/details?id=org.quillolang.fieldbook" web="https://quillolang.org/rate" on_problem={(t, s) => { if !fail { sent = `${s}: ${t}` } slow ? rate_prompt_demo_late(!fail) : !fail }} on_rated={(s) => { stars = s }} on_check={(w) => { why = w == "" ? "It asked." : `Not yet: ${w}.` }}/> </div> <p.demo-note id="rate-prompt-demo-why" data-sent={sent} data-stars={stars}>{why}</p> </div>}view RatePromptDemo() = <RatePromptDemoLive/>Source
ui/rate_prompt.qwe
// RatePrompt: asks for a rating, only once a person has really used the app, and just after// something went well. It keeps its own count, limits how often it asks, and gives anyone// the choice of telling you what is wrong instead of going to the store. Whatever the// stars, both routes are offered, as the stores forbid steering only the happy ones to a review.//// component App() {// state saved = 0// <NoteEditor on_save={() => { saved = saved + 1 }}/>// <RatePrompt app_name="Fieldbook" remember="rate" moment={saved} version="2.3.0"// ios="https://apps.apple.com/app/id1234567890?action=write-review"// android="https://play.google.com/store/apps/details?id=org.example.app&showAllReviews=true"// on_problem={(text, stars) => send_feedback(text, stars)}/>// }//// moment: a count of good moments, which you raise when something went well (a note saved, a// run finished): it asks only just after one. ready: false holds it back while the person is// in the middle of something. trouble: true holds it back after an error. version: this app's// version: a rating lasts for that version. app_name ("this app"). remember: the name its// count is kept under ("ui-rate-prompt"). ios, android, web: the store's page for rating, https.// min_days: days since the first start (7). min_uses: starts of the app (5), half an hour apart at// least. min_moments: good moments (3). gap_days: days between asks (120). per_year: asks in 365// days (3, Apple's own limit). problem_days: days of quiet after someone sent a problem (120).// now: the time in ms, for a test or a demo (the clock otherwise).// on_problem(text, stars): inside one of your components, given what was typed and the stars// (0 for none); it may wait, and an error value or false means it was not sent, and the text// stays to try again. on_rated(stars): when stars are picked. on_check(why): at each good moment,// "" when it asks, or the reason it did not: "never", "rated", "held", "trouble", "days", "uses",// "moments", "gap", "year" or "problem".//// rate_prompt_why_not(stats, rules, now, version) is the rule, alone. Apple's guidelines want its// own review sheet for ratings inside an app, which needs native code; a phone app's bridge has no// call for it, so this leads to the store's page instead.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonimport ui/force_update.qwe use force_update_platform, force_update_linkimport ui/welcome_carousel.qwe use welcome_carousel_keep, welcome_carousel_recalllet rate_prompt_css = css`.ui-rate-prompt-host { display: contents; }.ui-rate-prompt { position: fixed; z-index: 55; inset: auto 12px calc(max(12px, env(safe-area-inset-bottom)) + var(--ui-rate-prompt-keyboard, 0px)) 12px; display: grid; gap: 10px; box-sizing: border-box; max-height: calc(100% - 24px); overflow: auto; overscroll-behavior: contain; padding: 16px; border: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 4px); background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 15px/1.45 var(--ui-font); animation: ui-rate-prompt-in .24s cubic-bezier(.2, .8, .2, 1);}@keyframes ui-rate-prompt-in { from { transform: translateY(24px); opacity: 0; } }.ui-rate-prompt h2 { margin: 0; font-size: 18px; line-height: 1.3; }.ui-rate-prompt p { margin: 0; color: var(--ui-muted); }.ui-rate-prompt-stars { display: flex; justify-content: center; flex-wrap: wrap; }.ui-rate-prompt-star { display: grid; place-items: center; width: 48px; height: 48px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--ui-strong); cursor: pointer; }.ui-rate-prompt-star svg { width: 32px; height: 32px; }.ui-rate-prompt-star[aria-checked="true"], .ui-rate-prompt-star[data-lit] { color: var(--ui-warning); }.ui-rate-prompt-star:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-rate-prompt-acts { display: grid; gap: 6px; }.ui-rate-prompt-pair { display: flex; flex-wrap: wrap; gap: 4px; justify-content: space-between; }.ui-rate-prompt textarea { box-sizing: border-box; width: 100%; min-height: 96px; padding: 10px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); resize: vertical; background: var(--ui-bg); color: var(--ui-fg); font: 16px/1.4 var(--ui-font);}.ui-rate-prompt textarea:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-rate-prompt-bad { color: var(--ui-danger) !important; font-size: 14px; }@media (min-width: 700px) { .ui-rate-prompt { inset: auto 24px calc(24px + var(--ui-rate-prompt-keyboard, 0px)) auto; width: 380px; } }@media (prefers-reduced-motion: reduce) { .ui-rate-prompt { animation: none; } }`let rate_prompt_day = 86400000// What was kept, as text, or a fresh start from `now`. Anything else in storage is a fresh start.fn rate_prompt_stats(text, now: num) { let blank = { first: now, last_use: 0, uses: 0, moments: 0, asked: [], rated: null, never: false, problem: 0 } if type_of(text) != "str" { return blank } let got = json.parse(text) if is_err(got) || type_of(got) != "map" { return blank } let number = (k, d) => { let v = get(got, k) type_of(v) == "int" || type_of(v) == "float" ? v : d } let asked = get(got, "asked") { first: number("first", now), last_use: number("last_use", 0), uses: number("uses", 0), moments: number("moments", 0), asked: type_of(asked) == "list" ? (asked | filter((a) => type_of(a) == "int" || type_of(a) == "float")) : [], rated: get(got, "rated"), never: get(got, "never") == true, problem: number("problem", 0) }}// One more start of the app, unless the last was within half an hour: the same sitting.fn rate_prompt_used(stats, now: num) = now - stats.last_use >= 1800000 ? { ...stats, uses: stats.uses + 1, last_use: now } : stats// Why it should not ask, or "" when it should. In order: what never changes, then what// the person has not done yet, then the limits.fn rate_prompt_why_not(stats, rules, now: num, version) -> str { if stats.never { return "never" } if stats.rated != null && stats.rated == version { return "rated" } if now - stats.first < rules.min_days * rate_prompt_day { return "days" } if stats.uses < rules.min_uses { return "uses" } if stats.moments < rules.min_moments { return "moments" } let last = len(stats.asked) > 0 ? max(stats.asked) : 0 if last > 0 && now - last < rules.gap_days * rate_prompt_day { return "gap" } if len(stats.asked | filter((a) => now - a < 365 * rate_prompt_day)) >= rules.per_year { return "year" } if stats.problem > 0 && now - stats.problem < rules.problem_days * rate_prompt_day { return "problem" } ""}// Keeps the last few asks: only a year of them ever counts.fn rate_prompt_asked(stats, now: num) { let all = stats.asked + [now] { ...stats, asked: slice(all, max([0, len(all) - 6]), len(all)) }}component RatePrompt(app_name: str = "this app", moment: int = 0, ready: bool = true, trouble: bool = false, version: str? = null, remember: str = "ui-rate-prompt", ios: str? = null, android: str? = null, web: str? = null, min_days: int = 7, min_uses: int = 5, min_moments: int = 3, gap_days: int = 120, per_year: int = 3, problem_days: int = 120, now = null, on_problem = null, on_rated = null, on_check = null, class: str? = null) { style global rate_prompt_css state loaded = false state stats = null state counted = moment state platform = "web" state step = "" // "", "ask", "store", "problem" or "sent" state stars = 0 state text = "" state problem_said = "" state sending = false let link = force_update_link(platform, ios, android, web) let clock = () => now ?? js("Date.now") let rules = { min_days: min_days, min_uses: min_uses, min_moments: min_moments, gap_days: gap_days, per_year: per_year, problem_days: problem_days } let keep = (s) => { stats = s welcome_carousel_keep(remember, json.stringify(s)) } let good_moment = () => { let by = moment - counted counted = moment let t = clock() let next = { ...stats, moments: stats.moments + by } let why = !ready ? "held" : trouble ? "trouble" : rate_prompt_why_not(next, rules, t, version) if why == "" { keep(rate_prompt_asked(next, t)) step = "ask" stars = 0 } else { keep(next) } on_check && on_check(why) } if loaded && moment > counted && step == "" { good_moment() } let close = () => { step = "" problem_said = "" } let pick = (n: int) => { stars = n on_rated && on_rated(n) step = n >= 4 || link == null ? "store" : "problem" if n >= 4 && link == null { keep({ ...stats, rated: version ?? "any" }) } } let rated = () => { keep({ ...stats, rated: version ?? "any" }) close() } let send = () => { let words = trim(text) if words == "" { problem_said = "Write what went wrong first." } else if !sending { sending = true problem_said = "" let said = js("Promise.resolve", on_problem != null ? on_problem(words, stars) : null) sending = false if is_err(said) || said == false { problem_said = "That did not send. Your words are still here. Try again." } else { keep({ ...stats, problem: clock() }) text = "" step = "sent" } } } <div.ui-rate-prompt-host @mount={(el) => { platform = force_update_platform(js("navigator.userAgent"), js("navigator.maxTouchPoints")) let kept = js("Promise.resolve", welcome_carousel_recall(remember)) let s = rate_prompt_used(rate_prompt_stats(is_err(kept) ? null : kept, clock()), clock()) keep(s) loaded = true let shut = (e) => { if e.key == "Escape" && step != "" { close() } } let vv = js("window").visualViewport let shift = (_e) => { let hidden = max([0, round(js("innerHeight") - vv.height - vv.offsetTop)]) el.style.setProperty("--ui-rate-prompt-keyboard", `${hidden}px`) } js("document.addEventListener", "keydown", shut) if vv != null { vv.addEventListener("resize", shift) } () => { js("document.removeEventListener", "keydown", shut) if vv != null { vv.removeEventListener("resize", shift) } } }}> {step != "" && <section class={ui_cls(["ui-rate-prompt", class])} role="dialog" aria-label={`Rate ${app_name}`}> {step == "ask" && <h2>How is {app_name} going for you?</h2>} {step == "ask" && <div.ui-rate-prompt-stars role="radiogroup" aria-label="Your rating"> {for n in range(1, 6) => <button.ui-rate-prompt-star key={n} type="button" role="radio" aria-checked={stars == n ? "true" : "false"} aria-label={n == 1 ? "1 star" : `${n} stars`} data-lit={n <= stars ? "1" : null} @click={() => { pick(n) }}> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="m12 2.8 2.8 6 6.5.8-4.8 4.5 1.3 6.5L12 17.3l-5.8 3.3 1.3-6.5L2.7 9.6l6.5-.8z"/></svg> </button>} </div>} {step == "ask" && <div.ui-rate-prompt-acts> <Button size="lg" variant="soft" full={true} on_click={() => { step = "problem" }}>Report a problem instead</Button> <div.ui-rate-prompt-pair> <Button size="lg" variant="ghost" tone="neutral" on_click={close}>Not now</Button> <Button size="lg" variant="ghost" tone="neutral" on_click={() => { keep({ ...stats, never: true }); close() }}>Do not ask again</Button> </div> </div>} {step == "store" && <h2>Thank you</h2>} {step == "store" && (link != null ? <p>{stars >= 4 ? "Would you say so in the store? It takes a moment, and it helps others find us." : "You can leave a rating in the store."}</p> : <p>That helps us a lot.</p>)} {step == "store" && <div.ui-rate-prompt-acts> {link != null && <Button size="lg" full={true} href={link}>Rate in the store</Button>} {link != null && <Button size="lg" variant="ghost" full={true} tone="neutral" on_click={rated}>I have done it</Button>} <div.ui-rate-prompt-pair> <Button size="lg" variant="ghost" tone="neutral" on_click={() => { step = "problem" }}>Report a problem</Button> <Button size="lg" variant="ghost" tone="neutral" on_click={close}>No thanks</Button> </div> </div>} {step == "problem" && <h2>What went wrong?</h2>} {step == "problem" && <label for="ui-rate-prompt-text"><span class="ui-sr">What went wrong?</span></label>} {step == "problem" && <textarea id="ui-rate-prompt-text" maxlength="1000" placeholder="It goes straight to the people who make this." bind:value={text}></textarea>} {step == "problem" && problem_said != "" && <p.ui-rate-prompt-bad role="alert">{problem_said}</p>} {step == "problem" && <div.ui-rate-prompt-acts> <Button size="lg" full={true} loading={sending} on_click={send}>Send</Button> <div.ui-rate-prompt-pair> {link != null && <Button size="lg" variant="ghost" tone="neutral" href={link}>Rate in the store</Button>} <Button size="lg" variant="ghost" tone="neutral" on_click={close}>Not now</Button> </div> </div>} {step == "sent" && <h2>Thank you</h2>} {step == "sent" && <p role="status">We have it, and we read every one.</p>} {step == "sent" && <Button size="lg" full={true} on_click={close}>Close</Button>} </section>} </div>}Related
ForceUpdateCompares versions as numbers, shows a soft or a hard update screen, and the store's link by platform.3 views · 6 downloadsRetryBannerFailed loads with a back-off shown as a countdown, a Try now, cleared on success.3 views · 6 downloadsActionSheetA list of actions rising from the bottom, with a destructive style and Cancel: swipe down or the phone's back gesture closes it; on a tablet, a card by what opened it.3 views · 9 downloadsActivityRingsProgress rings inside each other that go round again past 100%, drawn once, with a legend and a sentence each for screen readers.3 views · 6 downloadsAdaptiveDialogA sheet on a phone and a centred dialog on a tablet or desktop: the same content, chosen by width, changing live on rotation.3 views · 5 downloadsAlertInboxAlerts by severity, swiped to acknowledge or snooze, with buttons and keys to the same, and a badge that stays right as snoozes run out.3 views · 8 downloads
Thread
Thread 0 posts
On RatePrompt: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.