Mobile

PaywallSheet

Plans with a recommended one, the price per month and per year worked out, restore, terms and honest trial wording.

#mobile#interactive#tablet#paywall#subscription#pricing

Demo

Preview

Keep every note

  • Unlimited notes
  • Photos and voice memos
  • Sync across your devices
Plans

Free for 7 days, then $59.99 a year. Your first payment is on November 8, 2026, unless you cancel at least a day before. It renews every year until you cancel, which you can do at any time in your phone's settings.

Plans in yen

Plans

Free for 7 days, then ¥6,800 a year. Your first payment is on January 4, 2027, unless you cancel at least a day before. It renews every year until you cancel, which you can do at any time in your phone's settings.

Odd data

Plans

4.99 today, then every 3 months. It renews every 3 months until you cancel, which you can do at any time in your phone's settings.

Nothing bought.

Get it

It needs theme.qwe, as every component does, and price.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/price.qwe$ curl -O https://quillolang.org/components/ui/paywall_sheet.qwe
main.qwe
import ui/theme.qwe use theme_cssuse ui/paywall_sheet.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>

Example

The code of the preview above.

demos/paywall_sheet.qwe
let paywall_sheet_demo_plans = [    { id: "week", name: "Weekly", price: "2.99", period: "week" },    { id: "month", name: "Monthly", price: "9.99", period: "month" },    { id: "year", name: "Yearly", price: "59.99", period: "year", trial_days: 7, recommended: true },    { id: "life", name: "For life", price: "149.00", period: "once" },    { id: "bad", name: "Broken", price: "nine dollars", period: "month" },]let paywall_sheet_demo_yen = [    { id: "m", name: "Monthly", price: 980, period: "month" },    { id: "y", name: "Yearly", price: 6800, period: "year", trial_days: 7, recommended: true },]let paywall_sheet_demo_odd = [    { id: "q", name: "Quarterly", price: "4.99", period: "quarter", trial_days: "seven" },]// The same answer a moment later, as a `server fn` gives it: a Promise.fn paywall_sheet_demo_late(answer) = js("Promise.resolve", js_new("Promise", (done) => { done(answer) }))fn paywall_sheet_demo_buy(fail: bool, plan) {    if fail { return { ok: false, message: "The store said no." } }    if plan.id == "life" { return { ok: false, cancelled: true } }    if plan.id == "week" { return null }    true}component PaywallSheetDemoLive() {    state said = "Nothing bought."    state fail = false    state slow = false    state now = false    // A named `let` here and an inline function below: callbacks of both kinds.    let buying = (plan) => {        let answer = paywall_sheet_demo_buy(fail, plan)        slow ? paywall_sheet_demo_late(answer) : answer    }    <div.demo-stack>        <div.demo-row id="paywall-sheet-demo-one">            <PaywallSheet trigger="See plans" title="Keep every note" today="2026-11-01" terms="#terms" privacy="#privacy"                benefits={["Unlimited notes", "Photos and voice memos", "Sync across your devices"]} plans={paywall_sheet_demo_plans}                on_buy={buying}                on_restore={() => {                    let answer = fail ? { ok: false } : { ok: true, message: "Restored your Yearly plan." }                    slow ? paywall_sheet_demo_late(answer) : answer                }}                on_bought={(plan) => { said = `Bought ${plan.id}.` }}                on_close={() => { said = `${said} Closed.` }}/>        </div>        <div.demo-row id="paywall-sheet-demo-two">            <PaywallSheet id="paywall-sheet-yen" trigger="In yen" title="Plans in yen" today="2026-12-28" currency="JPY" plans={paywall_sheet_demo_yen}/>        </div>        <div.demo-row id="paywall-sheet-demo-three">            <PaywallSheet id="paywall-sheet-odd" trigger="Odd data" title="Odd data" currency="NOPE" today="not a date" plans={paywall_sheet_demo_odd}/>            <Button size="lg" variant="outline" id="paywall-sheet-demo-now" on_click={() => { now = true }}>Open from state</Button>        </div>        {now && <PaywallSheet id="paywall-sheet-now" open={true} title="Opened from state" plans={paywall_sheet_demo_plans} on_close={() => { now = false }}/>}        <label><input type="checkbox" id="paywall-sheet-demo-fail" bind:checked={fail}/> Let the store refuse</label>        <label><input type="checkbox" id="paywall-sheet-demo-slow" bind:checked={slow}/> Answers come as Promises</label>        <p.demo-note id="paywall-sheet-demo-said">{said}</p>    </div>}view PaywallSheetDemo() = <PaywallSheetDemoLive/>

Source

ui/paywall_sheet.qwe
// PaywallSheet: the plans of a subscription in a sheet from the bottom of the screen, a// centred card on a tablet. One plan is recommended, and every plan shows what it costs a// month and a year, worked out, never rounded in the seller's favour. A trial says when the// first payment comes. Restore, the terms and a way out are always there. It takes no// money itself: on_buy and on_restore are the app's own.////   <PaywallSheet trigger="See plans" title="Keep every note" today={date.today()}//       benefits={["Unlimited notes", "Photos and voice memos", "Sync across your devices"]}//       terms="/terms" privacy="/privacy" plans={[//           { id: "month", name: "Monthly", price: "9.99", period: "month" },//           { id: "year", name: "Yearly", price: "59.99", period: "year", trial_days: 7, recommended: true }//       ]} on_buy={(plan) => buy(plan.id)} on_restore={() => restore()}/>//// plans: a list of { id, name, price, period, trial_days, recommended }. price: the amount// for one period, as text or a decimal. period: "week", "month", "quarter", "half" (six months),// "year" or "once" (a single payment, nothing renewing). A plan with a price that cannot be read// is left out rather than shown wrong. trial_days: days free before the first payment. recommended: the// one picked to start with, marked "Best value" (or `badge`, for other words); the first plan otherwise.// today: the date a trial starts, so that it can say when it ends; without it, it says how long,// not when. currency ("USD"), locale ("en-US"): how money and dates are written.// title, benefits (a list of text), terms and privacy (addresses), id ("paywall-sheet").// trigger: the text of a button that opens it. Inside one of your components, open it from// your state: open={...} and on_close(). on_buy(plan), on_restore(): inside one of your// components; each may wait, and gives true or { ok: true }, or { ok: false, message }, or// { ok: false, cancelled: true } when the person backed out of the store's own sheet, which// says nothing. An error value or null means it did not go through. on_bought(plan): after on_buy worked.//// Dragging the handle down closes it, past a third of its height or with a flick; Not now,// the Escape key and a tap above it do too. The stores require a working restore,// the price and the length of a subscription, and terms: leave none of them out.import ui/theme.qwe use ui_clsimport ui/button.qwe use Buttonimport ui/price.qwe use price_textlet paywall_sheet_css = css`.ui-paywall-sheet-host { display: contents; }.ui-paywall-sheet {  box-sizing: border-box; inset: auto 0 0 0; width: 100%; max-width: 100vw; max-height: calc(100dvh - env(safe-area-inset-top) - 16px); margin: 0 auto; padding: 0;  overflow: hidden; border: 1px solid var(--ui-border); border-bottom: 0; border-radius: calc(var(--ui-radius) + 8px) calc(var(--ui-radius) + 8px) 0 0;  background: var(--ui-surface); color: var(--ui-fg); box-shadow: var(--ui-shadow-lg); font: 15px/1.45 var(--ui-font);  transition: transform .26s cubic-bezier(.2, .8, .2, 1);}.ui-paywall-sheet[open] { display: flex; flex-direction: column; animation: ui-paywall-sheet-up .28s cubic-bezier(.2, .8, .2, 1); }.ui-paywall-sheet::backdrop { background: var(--ui-overlay); }.ui-paywall-sheet-held { transition: none; }@keyframes ui-paywall-sheet-up { from { transform: translateY(100%); } }.ui-paywall-sheet-top { position: relative; flex: none; display: flex; align-items: center; justify-content: flex-end; min-height: 44px; }.ui-paywall-sheet-grip {  position: absolute; inset: 0; display: grid; place-items: center; width: 100%; height: 44px; padding: 0; border: 0; background: transparent; cursor: grab;  touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;}.ui-paywall-sheet-grip span { width: 40px; height: 5px; border-radius: 999px; background: var(--ui-strong); }.ui-paywall-sheet-grip:focus-visible { outline: none; }.ui-paywall-sheet-grip:focus-visible span { background: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-paywall-sheet-top .ui-btn { position: relative; }.ui-paywall-sheet-body { flex: 1; min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 0 20px 12px; container: ui-paywall-sheet / inline-size; }.ui-paywall-sheet-body h2 { margin: 0 0 8px; font-size: 24px; line-height: 1.25; }.ui-paywall-sheet-good { display: grid; gap: 4px; margin: 0 0 14px; padding: 0; list-style: none; }.ui-paywall-sheet-good li { display: flex; gap: 8px; }.ui-paywall-sheet-good svg { flex: none; width: 20px; height: 20px; margin-top: 1px; color: var(--ui-success); }.ui-paywall-sheet-plans { position: relative; display: grid; gap: 8px; margin: 0 0 12px; padding: 0; border: 0; min-width: 0; }.ui-paywall-sheet-plan {  position: relative; display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 2px 10px; align-items: center; min-height: 64px; padding: 10px 12px; min-width: 0;  border: 1.5px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-bg); cursor: pointer;}.ui-paywall-sheet-plan:has(input:checked) { border-color: var(--ui-accent); background: var(--ui-accent-soft); }.ui-paywall-sheet-plan:has(input:focus-visible) { box-shadow: var(--ui-focus); }.ui-paywall-sheet-plan input { width: 20px; height: 20px; margin: 0; accent-color: var(--ui-accent); }.ui-paywall-sheet-name { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; min-width: 0; font-weight: 600; }.ui-paywall-sheet-badge { padding: 1px 7px; border-radius: 999px; background: var(--ui-accent); color: var(--ui-accent-fg); font-size: 11px; font-weight: 600; white-space: nowrap; }.ui-paywall-sheet-costs { display: grid; justify-items: end; font-variant-numeric: tabular-nums; text-align: end; }.ui-paywall-sheet-cost { font-weight: 600; }.ui-paywall-sheet-other { color: var(--ui-muted); font-size: 13px; }.ui-paywall-sheet-save { grid-column: 2 / -1; color: var(--ui-success); font-size: 13px; font-weight: 600; }.ui-paywall-sheet-foot { flex: none; display: grid; gap: 6px; padding: 8px 20px max(10px, env(safe-area-inset-bottom)); border-top: 1px solid var(--ui-border); background: var(--ui-surface); }.ui-paywall-sheet-wording { margin: 0; color: var(--ui-muted); font-size: 13px; }.ui-paywall-sheet-said { margin: 0; font-size: 14px; color: var(--ui-success); }.ui-paywall-sheet-said[data-bad] { color: var(--ui-danger); }.ui-paywall-sheet-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 0 14px; }.ui-paywall-sheet-link { display: inline-flex; align-items: center; min-height: 44px; min-width: 44px; padding: 0 2px; border: 0; background: none; color: var(--ui-muted); font: 13px var(--ui-font); text-decoration: underline; cursor: pointer; }.ui-paywall-sheet-link:focus-visible { outline: none; box-shadow: var(--ui-focus); }@container ui-paywall-sheet (min-width: 560px) {  .ui-paywall-sheet-plans { grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); grid-auto-flow: column; }  .ui-paywall-sheet-plan { grid-template-columns: 22px minmax(0, 1fr); align-content: start; }  .ui-paywall-sheet-costs { grid-column: 1 / -1; justify-items: start; text-align: start; }  .ui-paywall-sheet-save { grid-column: 1 / -1; }}/* The whole window, however wide it is: a dialog sits above any pane. */@media (min-width: 700px) {  .ui-paywall-sheet { inset: 0; width: min(760px, 92vw); max-height: min(88dvh, 720px); height: fit-content; margin: auto; border-bottom: 1px solid var(--ui-border); border-radius: calc(var(--ui-radius) + 6px); }  .ui-paywall-sheet-grip { display: none; }}@media (prefers-reduced-motion: reduce) { .ui-paywall-sheet { transition: none; } .ui-paywall-sheet[open] { animation: none; } }`// Words for each period: "a year", and how many of them fit in a year.fn paywall_sheet_rate(period: str?) = match period {    "week" => { per: "a week", every: "week", fits: 52, name: "week" }    "month" => { per: "a month", every: "month", fits: 12, name: "month" }    "quarter" => { per: "every 3 months", every: "3 months", fits: 4, name: "quarter" }    "half" => { per: "every 6 months", every: "6 months", fits: 2, name: "half" }    "year" => { per: "a year", every: "year", fits: 1, name: "year" }    "once" => { per: "once", every: "", fits: 0, name: "once" }    _ => null}// An amount as a decimal, or null when it is not one a price could be.fn paywall_sheet_price(amount) {    if amount == null || type_of(amount) == "bool" { return null }    let t = trim(str(amount))    if !re.test(t, "^[0-9]{1,9}([.][0-9]{1,6})?$") { return null }    dec(t)}// Rounded up to what a currency can charge. A monthly figure rounded down would add up to// less than the year costs, so it is the only rounding here, and it goes against the seller.fn paywall_sheet_up(amount, currency: str) {    let units = money.minor_units(currency)    // A currency nobody knows gives an error value: two places, as the most common.    let scale = dec(`1${repeat("0", is_err(units) ? 2 : units)}`)    ceil(amount * scale) / scale}// The plan's cost over a year, or null for a single payment.fn paywall_sheet_year_cost(plan) {    let rate = paywall_sheet_rate(get(plan, "period"))    let price = paywall_sheet_price(get(plan, "price"))    rate == null || rate.fits == 0 || price == null ? null : price * rate.fits}// The plans that can be shown, each with its figures: { plan, rate, price, year, month }.fn paywall_sheet_figures(plans: list, currency: str) -> list {    let usable = plans | filter((p) => paywall_sheet_rate(get(p, "period")) != null && paywall_sheet_price(get(p, "price")) != null)    for p in usable {        let year = paywall_sheet_year_cost(p)        { plan: p, rate: paywall_sheet_rate(p.period), price: paywall_sheet_price(p.price), year: year,          month: year != null ? paywall_sheet_up(year / 12, currency) : null }    }}// The whole per cent saved against `base` a year, never rounded up; null when none.fn paywall_sheet_saving(year, base) {    if year == null || base == null || base <= dec(0) || year >= base { return null }    let pct = floor((dec(1) - year / base) * dec(100))    pct >= dec(1) ? int(pct) : null}// What savings are measured against: the monthly plan, as people think in months, or// the shortest period there is.fn paywall_sheet_base(rows: list) {    let monthly = rows | filter((r) => r.rate.name == "month")    if len(monthly) > 0 { return monthly[0].year }    let priced = rows | filter((r) => r.year != null) | sort_by((r) => -r.rate.fits)    len(priced) > 0 ? priced[0].year : null}fn paywall_sheet_days(n: int) -> str = n == 1 ? "1 day" : `${n} days`// A plan's free days: a whole number within reason, or none. Anything else in the data// is not a trial, as promising one that was not meant would be the worse mistake.fn paywall_sheet_trial(plan) -> int {    let n = get(plan, "trial_days")    type_of(n) == "int" && n > 0 && n <= 3650 ? n : 0}// When a trial's first payment falls, as text, or null without a good date.fn paywall_sheet_charge_day(today, days: int, locale: str) -> str? {    if type_of(today) != "str" || !date.valid(today, "date") { return null }    date.format(date.add_days(today, days), { locale: locale, style: "long" })}// What a plan will cost and when, in plain words: the line under the button.fn paywall_sheet_wording(row, today, currency: str, locale: str) -> str {    let cost = price_text(row.price, currency, locale)    let trial = paywall_sheet_trial(row.plan)    if row.rate.name == "once" { return `One payment of ${cost}. Nothing renews.` }    let renew = `It renews every ${row.rate.every} until you cancel, which you can do at any time in your phone's settings.`    if trial > 0 {        let day = paywall_sheet_charge_day(today, trial, locale)        let when = day != null ? `Your first payment is on ${day}` : `Your first payment comes when the ${paywall_sheet_days(trial)} are up`        return `Free for ${paywall_sheet_days(trial)}, then ${cost} ${row.rate.per}. ${when}, unless you cancel at least a day before. ${renew}`    }    `${cost} today, then ${row.rate.name == "year" ? "every year" : `every ${row.rate.every}`}. ${renew}`}// The button's words: what pressing it does, money included.fn paywall_sheet_action(row, currency: str, locale: str) -> str {    let cost = price_text(row.price, currency, locale)    let trial = paywall_sheet_trial(row.plan)    if row.rate.name == "once" { return `Buy for ${cost}` }    trial > 0 ? `Start ${trial}-day free trial` : `Subscribe for ${cost} ${row.rate.per}`}// A callback's answer: { ok, cancelled, message }. Only true or ok: true is success.fn paywall_sheet_answer(said) {    if is_err(said) || said == null { return { ok: false, cancelled: false, message: null } }    if said == true { return { ok: true, cancelled: false, message: null } }    if type_of(said) != "map" { return { ok: false, cancelled: false, message: null } }    { ok: get(said, "ok") == true, cancelled: get(said, "cancelled") == true, message: get(said, "message") }}// Whether letting go closes it: `dy` pixels down, at `speed`, with `height` to go.fn paywall_sheet_closes(dy: num, speed: num, height: num) -> bool =    dy >= height / 3 || (dy >= 12 && speed >= 0.5)component PaywallSheet(plans: list = [], title: str = "Choose a plan", benefits: list = [], today = null, currency: str = "USD",                       locale: str = "en-US", badge: str = "Best value", terms: str? = null, privacy: str? = null, trigger: str? = null,                       open: bool = false, id: str = "paywall-sheet", on_buy = null, on_restore = null, on_bought = null, on_close = null,                       class: str? = null) {    style global paywall_sheet_css    let rows = paywall_sheet_figures(plans, currency)    let start = find(rows, (r) => get(r.plan, "recommended") == true) ?? (len(rows) > 0 ? rows[0] : null)    state picked = start != null ? get(start.plan, "id") : null    state dlg = null    state busy = ""    state said = ""    state said_bad = false    state held = false    let finger = null    let y0 = 0    let last_y = 0    let last_t = 0    let speed = 0    let row = find(rows, (r) => get(r.plan, "id") == picked) ?? start    let base = paywall_sheet_base(rows)    if open && dlg != null && !dlg.open { dlg.showModal() }    if !open && dlg != null && dlg.open && trigger == null { dlg.close() }    let tell = (text: str, bad: bool) => {        said = text        said_bad = bad    }    let buy = () => {        if busy == "" && row != null {            busy = "buy"            tell("", false)            let got = paywall_sheet_answer(js("Promise.resolve", on_buy != null ? on_buy(row.plan) : null))            busy = ""            if got.ok {                on_bought && on_bought(row.plan)                dlg.close()            } else if !got.cancelled {                tell(got.message ?? "The purchase did not go through. You have not been charged.", true)            }        }    }    let restore = () => {        if busy == "" {            busy = "restore"            tell("", false)            let got = paywall_sheet_answer(js("Promise.resolve", on_restore != null ? on_restore() : null))            busy = ""            if got.ok { tell(got.message ?? "Your purchases are restored.", false) }            else if !got.cancelled { tell(got.message ?? "No purchases were found to restore.", true) }        }    }    let spring = () => {        finger = null        held = false        dlg.style.transform = ""    }    <div.ui-paywall-sheet-host>        {trigger != null && <button.ui-btn.ui-btn-solid.ui-btn-lg type="button" aria-haspopup="dialog" @click={() => { dlg.showModal() }}>{trigger}</button>}        <dialog class={ui_cls(["ui-paywall-sheet", held && "ui-paywall-sheet-held", class])} aria-labelledby={`${id}-title`}                @mount={(el) => {                    dlg = el                    if open && !el.open { el.showModal() }                }}                @close={() => {                    tell("", false)                    on_close && on_close()                }}                @click={(e) => { if e.target.tagName == "DIALOG" { dlg.close() } }}>            <div.ui-paywall-sheet-top>                <button.ui-paywall-sheet-grip type="button" aria-label="Close"                        @pointerdown={(e) => {                            if finger == null {                                finger = e.pointerId                                e.target.closest("button").setPointerCapture(e.pointerId)                                y0 = e.clientY                                last_y = e.clientY                                last_t = e.timeStamp                                speed = 0                                held = true                            }                        }}                        @pointermove={(e) => {                            if e.pointerId == finger {                                let gap = e.timeStamp - last_t                                if gap > 0 { speed = speed * 0.4 + (e.clientY - last_y) / gap * 0.6 }                                last_y = e.clientY                                last_t = e.timeStamp                                dlg.style.transform = `translateY(${max([0, e.clientY - y0])}px)`                            }                        }}                        @pointerup={(e) => {                            if e.pointerId == finger {                                let dy = e.clientY - y0                                let moving = e.timeStamp - last_t > 80 ? 0 : speed                                let height = dlg.offsetHeight                                spring()                                if paywall_sheet_closes(dy, moving, height) { dlg.close() }                            }                        }}                        @pointercancel={(e) => { if e.pointerId == finger { spring() } }}                        @click={(e) => {                            // The keyboard's Enter. A finger's tap leaves the sheet where it is, as it may be a slip.                            if e.detail == 0 { dlg.close() }                        }}><span aria-hidden="true"></span></button>                <Button variant="ghost" tone="neutral" size="lg" on_click={() => { dlg.close() }}>Not now</Button>            </div>            <div.ui-paywall-sheet-body>                <h2 id={`${id}-title`}>{title}</h2>                {len(benefits) > 0 && <ul.ui-paywall-sheet-good>                    {for b in benefits => <li key={b}>                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 13l4 4L19 7"/></svg>                        <span>{b}</span>                    </li>}                </ul>}                <fieldset.ui-paywall-sheet-plans>                    <legend class="ui-sr">Plans</legend>                    {for r in rows {                        let pid = get(r.plan, "id")                        let save = paywall_sheet_saving(r.year, base)                        let trial = paywall_sheet_trial(r.plan)                        <label.ui-paywall-sheet-plan key={pid} data-plan={pid}>                            <input type="radio" name={`${id}-plan`} value={pid} checked={pid == picked} @change={() => { picked = pid; tell("", false) }}/>                            <span.ui-paywall-sheet-name>{get(r.plan, "name") ?? pid}                                {get(r.plan, "recommended") == true && <span.ui-paywall-sheet-badge>{badge}</span>}</span>                            <span.ui-paywall-sheet-costs>                                <span.ui-paywall-sheet-cost>{price_text(r.price, currency, locale)} {r.rate.per}</span>                                {r.rate.name != "month" && r.month != null && <span.ui-paywall-sheet-other>{price_text(r.month, currency, locale)} a month</span>}                                {r.rate.name != "year" && r.year != null && <span.ui-paywall-sheet-other>{price_text(r.year, currency, locale)} a year</span>}                                {r.rate.name == "once" && <span.ui-paywall-sheet-other>Yours to keep</span>}                            </span>                            {(save != null || trial > 0) && <span.ui-paywall-sheet-save>                                {[trial > 0 ? `${paywall_sheet_days(trial)} free` : null, save != null ? `Save ${save}%` : null] | filter((x) => x != null) | join(". ")}                            </span>}                        </label>                    }}                </fieldset>            </div>            <div.ui-paywall-sheet-foot>                {said != "" && <p.ui-paywall-sheet-said role="status" data-bad={said_bad ? "1" : null}>{said}</p>}                {row != null && <p.ui-paywall-sheet-wording id={`${id}-wording`}>{paywall_sheet_wording(row, today, currency, locale)}</p>}                {row != null                    ? <Button size="lg" full={true} loading={busy == "buy"} disabled={busy != ""} on_click={buy}>{paywall_sheet_action(row, currency, locale)}</Button>                    : <p.ui-paywall-sheet-wording>No plans are on offer just now.</p>}                <div.ui-paywall-sheet-links>                    <button.ui-paywall-sheet-link type="button" disabled={busy != ""} @click={restore}>{busy == "restore" ? "Restoring" : "Restore purchases"}</button>                    {terms != null && <a.ui-paywall-sheet-link href={terms}>Terms</a>}                    {privacy != null && <a.ui-paywall-sheet-link href={privacy}>Privacy</a>}                </div>            </div>        </dialog>    </div>}
Thread
28Useful? Like it, or add to its thread below.

Thread 0 posts

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

No posts yet. The first one opens the thread.