@vibes.diy/prompts 14.3.175 → 14.3.177

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,11 +26,15 @@ that record grants. An app where each visitor sells their own subscriptions is
26
26
  a platform (Stripe Connect) and a different thing — say so and build the
27
27
  one-merchant half.
28
28
 
29
- ## 1. Bring your own Stripe key, turn on the portal, and explain how
29
+ ## 1. The setup items: the owner's Stripe secret key, and the Customer portal
30
30
 
31
- The owner sets up Stripe once. The steps are written for someone who has never
32
- opened Stripe, so each one is a link to the exact page and uses the words that
33
- page uses:
31
+ The owner sets up Stripe once. The Setup surface the two items land on — the
32
+ owner-only entry in the app's navigation, the verdict from `/setup`, the
33
+ re-read on focus that unlocks the Join buttons in place — is the use-vibe
34
+ docs' *Some apps need setup before they work*; this recipe contributes two
35
+ items to it. The Stripe items are the secret key and the Customer portal: the backend answers missing-key while ctx.secrets.STRIPE_SECRET_KEY is absent, reads whether a portal configuration has been saved, and its steps link Stripe's API keys page and Stripe's Customer portal settings by URL and name STRIPE_SECRET_KEY in so many words.
36
+ The steps are written for someone who has never opened Stripe, so each one is
37
+ a link to the exact page and uses the words that page uses:
34
38
 
35
39
  1. Open Stripe's **API keys** page in test mode:
36
40
  <https://dashboard.stripe.com/test/apikeys> (sign in, or create a Stripe
@@ -46,33 +50,35 @@ page uses:
46
50
  **Cancellations**, turn on **Cancel subscriptions** and choose **At end of
47
51
  billing period**, then press **Save**. The app sends members there to cancel
48
52
  or change a card, and the portal does not exist until it is saved once.
49
- 4. To go live, take the **live** secret key from
50
- <https://dashboard.stripe.com/apikeys> (**Reveal live key**, `sk_live_`),
51
- set it the same way, and save the portal once more at
52
- <https://dashboard.stripe.com/settings/billing/portal>.
53
-
54
- The app carries those steps in its own UI, because the person reading them is
55
- the person who has to do them — and it fills in the vibe's real handle and
56
- slug, which the backend knows as `ctx.appInfo`, so the Settings link and the
57
- terminal command are copy-ready rather than templates. The Settings tab opens
58
- in another tab, so the app re-reads `/status` whenever it regains focus or
59
- becomes visible again: the owner saves the key, comes back, and the card gives
60
- way to the Join buttons without a reload.
61
-
62
- When the key is missing, the app shows the owner the setup steps for a Stripe secret key and the Customer Portal instead of an error, and the steps link to Stripe's API keys page, Stripe's Customer portal settings and this vibe's own Settings tab by URL.
63
-
64
- The backend answers `{ ok: false, reason: "missing-key" }` when
65
- `ctx.secrets.STRIPE_SECRET_KEY` is absent, and `/status` carries
66
- `ownerHandle` and `appSlug` so the client can render the numbered steps
67
- with real links: `https://dashboard.stripe.com/test/apikeys` for the key,
68
- `https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` for where
69
- it goes, `https://dashboard.stripe.com/test/settings/billing/portal` for the
70
- portal, and `STRIPE_SECRET_KEY` in so many words. "Open the Stripe dashboard"
71
- and "the Settings page" are not instructions; a person who has never seen
72
- either would have to search for both. A visitor who is not the owner sees a
73
- calmer sentence — memberships are not open yet — never the steps. A portal that was never saved
74
- answers a Stripe error whose message says so; keep that sentence (§ 7) and the
75
- owner reads step 3 off it.
53
+ 4. Go live. When a test payment goes through, switch to your live key to take
54
+ real payments: open Stripe's **API keys** page in live mode,
55
+ <https://dashboard.stripe.com/apikeys>, press **Reveal live key** beside
56
+ **Secret key** (it starts with `sk_live_`), set `STRIPE_SECRET_KEY` to it
57
+ the same way, and save the **Customer portal** once more in live mode at
58
+ <https://dashboard.stripe.com/settings/billing/portal> — test-mode settings
59
+ stay in test mode. This step stays on the Setup screen after both items
60
+ are done, with a badge that reads *Test mode* until the live key is in.
61
+
62
+ Those steps are the items' copy on the Setup surface, with the vibe's real
63
+ handle and slug filled in from the `ownerHandle` and `appSlug` the `/setup`
64
+ route hands over. `https://dashboard.stripe.com/test/apikeys` for the key,
65
+ `https://vibes.diy/vibes/mine/<ownerHandle>/<appSlug>/settings` for where it
66
+ goes, `https://dashboard.stripe.com/test/settings/billing/portal` for the
67
+ portal, and `STRIPE_SECRET_KEY` in so many words: "Open the Stripe dashboard"
68
+ and "the Settings page" are directions to search for, and a person who has
69
+ never seen either has to go looking. The portal item is checked with one read
70
+ of `GET /v1/billing_portal/configurations?is_default=true&active=true` — the
71
+ active default is the configuration `/portal` opens, and a portal that was
72
+ never saved has none — made only on the **owner's** `/setup` call and kept in
73
+ a `setup` document the backend writes, so the owner sees the item turn to
74
+ *Done* the moment they press Save and a visitor's read costs Stripe nothing. A
75
+ refused read (a wrong key, a platform refusal, Stripe down) keeps the last
76
+ verdict and rides the item as `problem`, which the Setup screen shows the
77
+ owner in Stripe's own words; only a successful empty answer means not saved.
78
+ A portal that was skipped answers a Stripe error whose message says so (§ 8).
79
+ In the consumer flow, a Join button whose items are not done reads
80
+ *Memberships are not open yet*; the owner's Setup entry is where the steps
81
+ are, and later it is where the owner swaps the test key for the live one.
76
82
 
77
83
  ## 2. The key never reaches the browser, and neither does Stripe.js
78
84
 
@@ -298,6 +304,22 @@ async function stripe(ctx, method, path, form, headers = {}) {
298
304
  return { ok: true, body };
299
305
  }
300
306
 
307
+ // Has the owner saved the Customer portal? Stripe is asked only on the OWNER's call, and the answer
308
+ // is kept in a document the backend writes, so a visitor's /setup read never reaches Stripe and a
309
+ // blip at Stripe keeps the last verdict. The /portal route opens the ACTIVE DEFAULT configuration,
310
+ // so that is the one asked for; a refused read is a problem shown to the owner, never "not saved".
311
+ const PORTAL_STATE = "setup:stripe-portal";
312
+ async function portalSaved(ctx, owner) {
313
+ const kept = await ctx.db.get(PORTAL_STATE, { db: DB });
314
+ const known = kept?.saved === true;
315
+ if (!owner || !ctx.secrets.STRIPE_SECRET_KEY) return { done: known };
316
+ const configurations = await stripe(ctx, "GET", "/billing_portal/configurations?is_default=true&active=true&limit=1");
317
+ if (!configurations.ok) return { done: known, problem: configurations.message ?? "Stripe could not be reached; the last verdict stands." };
318
+ const saved = (configurations.body.data?.length ?? 0) > 0;
319
+ if (known !== saved) await ctx.db.put({ _id: PORTAL_STATE, type: "setup", saved, checkedAt: Date.now() }, { db: DB, id: PORTAL_STATE, ifRev: kept?._rev });
320
+ return { done: saved };
321
+ }
322
+
301
323
  // Stripe's answer, reduced to the facts the app keeps. The period bounds are Unix seconds, and
302
324
  // newer API versions carry them on the subscription item rather than the subscription.
303
325
  function mirror(sub) {
@@ -364,9 +386,19 @@ export async function fetch(request, ctx) {
364
386
  const url = new URL(request.url);
365
387
  const me = ctx.userInfo?.userHandle;
366
388
 
367
- if (url.pathname === "/status" && request.method === "GET") {
368
- // ownerHandle + appSlug let the setup card link this vibe's own Settings tab.
369
- return json({ ok: true, configured: Boolean(ctx.secrets.STRIPE_SECRET_KEY), owner: me === ctx.appInfo.ownerHandle, graceDays: GRACE_DAYS, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
389
+ // The setup verdict: ready for everyone, the items (and the handle + slug the Setup screen
390
+ // links with) for the owner alone.
391
+ if (url.pathname === "/setup" && request.method === "GET") {
392
+ const owner = me === ctx.appInfo.ownerHandle;
393
+ const items = [
394
+ { id: "stripe-key", label: "Stripe secret key", done: Boolean(ctx.secrets.STRIPE_SECRET_KEY) },
395
+ { id: "stripe-portal", label: "Customer portal", ...(await portalSaved(ctx, owner)) },
396
+ ];
397
+ const ready = items.every((item) => item.done);
398
+ if (!owner) return json({ ok: true, ready, owner });
399
+ // `live` is the Go-live step's badge: a test key takes test payments, a live key real ones.
400
+ const live = (ctx.secrets.STRIPE_SECRET_KEY ?? "").startsWith("sk_live_");
401
+ return json({ ok: true, ready, owner, live, items, graceDays: GRACE_DAYS, ownerHandle: ctx.appInfo.ownerHandle, appSlug: ctx.appInfo.appSlug });
370
402
  }
371
403
 
372
404
  if (!me) return json({ ok: false, reason: "sign-in" }, 401);
@@ -375,6 +407,9 @@ export async function fetch(request, ctx) {
375
407
 
376
408
  if (url.pathname === "/subscribe" && request.method === "POST") {
377
409
  if (!ctx.secrets.STRIPE_SECRET_KEY) return json({ ok: false, reason: "missing-key" }, 503);
410
+ // The portal is where a member cancels, so nobody joins before it exists: the KEPT verdict,
411
+ // never a Stripe read per press.
412
+ if ((await ctx.db.get(PORTAL_STATE, { db: DB }))?.saved !== true) return json({ ok: false, reason: "setup-incomplete" }, 503);
378
413
  const { plan } = await request.json();
379
414
  const price = PLANS[plan];
380
415
  if (!price) return json({ ok: false, reason: "unknown-plan" }, 400);
@@ -516,6 +551,13 @@ export function club(doc, oldDoc, user, ctx) {
516
551
  return { channels: [doc.membersOnly ? "members" : "public"], grant: { public: ["public"] } };
517
552
  }
518
553
 
554
+ // The setup verdict the backend keeps (the portal's saved state): written by backend.js alone,
555
+ // read on the owner's channel.
556
+ if (doc.type === "setup") {
557
+ ctx.requireBackend();
558
+ return { channels: ["memberships"] };
559
+ }
560
+
519
561
  throw { forbidden: "unknown document type" };
520
562
  }
521
563
  ```
@@ -524,37 +566,77 @@ export function club(doc, oldDoc, user, ctx) {
524
566
 
525
567
  ### `App.jsx`
526
568
  ```jsx app=stripe-membership
527
- import React, { useEffect, useState } from "react";
569
+ import React, { useEffect, useRef, useState } from "react";
528
570
  import { useFireproof } from "use-fireproof";
529
571
  import { apiFetch, useViewer } from "use-vibes";
530
- import { Badge, Button, Heading, Stack, Text } from "@vibes.diy/pouf";
572
+ import { Badge, Button, Heading, Row, Stack, Text } from "@vibes.diy/pouf";
573
+
574
+ // The setup verdict, read on mount and again whenever the app comes back into view: the owner
575
+ // sets the key or saves the portal in another tab and returns, and the Join buttons unlock without
576
+ // a reload.
577
+ function useSetup() {
578
+ const [setup, setSetup] = useState(null); // { ready, owner, items?, graceDays?, ownerHandle?, appSlug? }
579
+ const latest = useRef(0);
580
+ useEffect(() => {
581
+ // Mount, focus and visibility reads overlap; only the latest one may land, or a slow stale
582
+ // answer overwrites a fresh one.
583
+ const load = () => {
584
+ const n = ++latest.current;
585
+ apiFetch("/setup").then((r) => r.json()).catch(() => ({ ready: false, owner: false })).then((s) => { if (n === latest.current) setSetup(s); });
586
+ };
587
+ load();
588
+ const onVisible = () => { if (document.visibilityState === "visible") load(); };
589
+ document.addEventListener("visibilitychange", onVisible);
590
+ window.addEventListener("focus", load);
591
+ return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
592
+ }, []);
593
+ return setup;
594
+ }
531
595
 
532
- // The owner's setup card: every step is a link to the exact page, in that page's own words.
533
- function SetupSteps({ status }) {
534
- const vibe = `${status.ownerHandle}/${status.appSlug}`;
596
+ // The Setup surface, the owner's alone: two items, every step a link to the exact page in that
597
+ // page's own words, with this vibe's real handle and slug filled in.
598
+ function SetupScreen({ setup }) {
599
+ const vibe = `${setup.ownerHandle}/${setup.appSlug}`;
535
600
  const settingsUrl = `https://vibes.diy/vibes/mine/${vibe}/settings`;
536
601
  return (
537
- <ol>
538
- <li>
539
- Open Stripe's <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noreferrer">API keys page (test mode)</a>,
540
- use your Stripe account (the page offers to create one), and under <b>Standard keys</b> press <b>Reveal test key</b> beside <b>Secret key</b>.
541
- Copy it; it starts with <code>sk_test_</code>.
542
- </li>
543
- <li>
544
- Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings).
545
- Under <b>Secrets</b>, enter the key <code>STRIPE_SECRET_KEY</code>, paste the value, and save.
546
- From a terminal instead: <code>npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe {vibe}</code>.
547
- </li>
548
- <li>
549
- Open Stripe's <a href="https://dashboard.stripe.com/test/settings/billing/portal" target="_blank" rel="noreferrer">Customer portal settings</a>:
550
- under <b>Cancellations</b> turn on <b>Cancel subscriptions</b>, choose <b>At end of billing period</b>, and press <b>Save</b>.
551
- Members use that page to cancel or change a card.
552
- </li>
553
- <li>
554
- Come back here: the Join buttons work as soon as the key is saved. In test mode the card 4242 4242 4242 4242 pays.
555
- To go live, do the same with the <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer">live key</a> and the live portal.
556
- </li>
557
- </ol>
602
+ <Stack>
603
+ <Heading>Setup</Heading>
604
+ {setup.items.map((item) => (
605
+ <Row key={item.id} gap={2}>
606
+ <Text>{item.label}</Text>
607
+ <Badge tone={item.done ? "up" : "info"}>{item.done ? "Done" : "To do"}</Badge>
608
+ {item.problem && <Text muted>{item.problem}</Text>}
609
+ </Row>
610
+ ))}
611
+ <ol>
612
+ <li>
613
+ Open Stripe's <a href="https://dashboard.stripe.com/test/apikeys" target="_blank" rel="noreferrer">API keys page (test mode)</a>,
614
+ use your Stripe account (the page offers to create one), and under <b>Standard keys</b> press <b>Reveal test key</b> beside <b>Secret key</b>.
615
+ Copy it; it starts with <code>sk_test_</code>.
616
+ </li>
617
+ <li>
618
+ Open <a href={settingsUrl} target="_blank" rel="noreferrer">this vibe's Settings tab</a> (My Vibes → this vibe → Settings).
619
+ Under <b>Secrets</b>, enter the key <code>STRIPE_SECRET_KEY</code>, paste the value, and save.
620
+ From a terminal instead: <code>npx vibes-diy secrets set STRIPE_SECRET_KEY --vibe {vibe}</code>.
621
+ </li>
622
+ <li>
623
+ Open Stripe's <a href="https://dashboard.stripe.com/test/settings/billing/portal" target="_blank" rel="noreferrer">Customer portal settings</a>:
624
+ under <b>Cancellations</b> turn on <b>Cancel subscriptions</b>, choose <b>At end of billing period</b>, and press <b>Save</b>.
625
+ Members use that page to cancel or change a card.
626
+ </li>
627
+ <li>
628
+ Come back here: the Join buttons work as soon as both items are done. In test mode the card 4242 4242 4242 4242 pays.
629
+ </li>
630
+ <li>
631
+ {/* The Go-live step stays here once the items are done; its badge says which key is in. */}
632
+ <Badge tone={setup.live ? "up" : "info"}>{setup.live ? "Live" : "Test mode"}</Badge>{" "}
633
+ When a test payment goes through, switch to your live key to take real payments: open Stripe's{" "}
634
+ <a href="https://dashboard.stripe.com/apikeys" target="_blank" rel="noreferrer">API keys page (live mode)</a>, press <b>Reveal live key</b> beside <b>Secret key</b>
635
+ (it starts with <code>sk_live_</code>), set <code>STRIPE_SECRET_KEY</code> to it the same way, and save the{" "}
636
+ <a href="https://dashboard.stripe.com/settings/billing/portal" target="_blank" rel="noreferrer">Customer portal settings (live mode)</a> once more.
637
+ </li>
638
+ </ol>
639
+ </Stack>
558
640
  );
559
641
  }
560
642
 
@@ -563,21 +645,11 @@ export default function App() {
563
645
  const { useLiveQuery } = useFireproof("club"); // memberships AND posts: one database, one grant set
564
646
  const { docs: memberships } = useLiveQuery("type", { key: "membership" });
565
647
  const { docs: posts } = useLiveQuery("type", { key: "post" });
566
- const [status, setStatus] = useState(null); // { configured, owner, graceDays } from the backend
648
+ const setup = useSetup();
649
+ const [screen, setScreen] = useState("club");
567
650
  const [busy, setBusy] = useState(false);
568
651
  const [error, setError] = useState(null);
569
652
 
570
- // Read status on mount, and again whenever the app comes back into view: the owner sets the key
571
- // in another tab and returns, and the setup card has to give way to the buttons without a reload.
572
- useEffect(() => {
573
- const load = () => apiFetch("/status").then((r) => r.json()).then(setStatus).catch(() => setStatus({ configured: false, owner: false }));
574
- load();
575
- const onVisible = () => { if (document.visibilityState === "visible") load(); };
576
- document.addEventListener("visibilitychange", onVisible);
577
- window.addEventListener("focus", load);
578
- return () => { document.removeEventListener("visibilitychange", onVisible); window.removeEventListener("focus", load); };
579
- }, []);
580
-
581
653
  // Back from Stripe: the fragment arrives just after first render and on hashchange, so read both.
582
654
  useEffect(() => {
583
655
  const settle = () => {
@@ -606,7 +678,7 @@ export default function App() {
606
678
  const data = await res.json();
607
679
  if (!data.ok) {
608
680
  tab.close();
609
- setError(data.reason === "stripe" ? data.message : data.reason === "already-member" ? "You already have a membership — manage it below." : "Memberships are not available right now.");
681
+ setError(data.reason === "stripe" ? data.message : data.reason === "already-member" ? "You already have a membership — manage it below." : "Memberships are not open yet.");
610
682
  return;
611
683
  }
612
684
  tab.location.href = data.url;
@@ -615,63 +687,70 @@ export default function App() {
615
687
  }
616
688
  };
617
689
 
618
- const configured = status?.configured === true;
619
- if (status && !configured && status.owner) {
620
- return (
621
- <Stack>
622
- <Heading>Set up memberships</Heading>
623
- <Text>Three steps in Stripe, once, and the Join buttons go live for everyone.</Text>
624
- <SetupSteps status={status} />
625
- </Stack>
626
- );
627
- }
628
-
690
+ const ready = setup?.ready === true;
629
691
  const mine = viewer ? memberships.find((m) => m._id === `membership-${viewer.userHandle}`) : undefined;
630
692
  const entitled = mine?.entitled === true;
631
693
  const ends = mine?.currentPeriodEnd ? new Date(mine.currentPeriodEnd).toLocaleDateString() : null;
632
694
 
633
695
  return (
634
696
  <Stack>
635
- <Heading>The club</Heading>
636
- {!viewer ? (
637
- <Stack>
638
- <Text>Members read every post. Join for $5 a month, cancel any time.</Text>
639
- <Button onClick={() => requestLogin()}>Sign in to join</Button>
640
- </Stack>
641
- ) : entitled ? (
642
- <Badge tone="up">{mine.cancelAtPeriodEnd ? `Member until ${ends}` : mine.status === "past_due" ? "Payment due — please update your card" : "Member"}</Badge>
697
+ <Row gap={2}>
698
+ <Button variant="quiet" onClick={() => setScreen("club")}>The club</Button>
699
+ {/* The Setup entry: the owner's alone, highlighted while an item is still to do, quiet once done. */}
700
+ {setup?.owner && (
701
+ <Button variant={ready ? "quiet" : "solid"} onClick={() => setScreen("setup")}>
702
+ Setup{ready ? "" : " · to do"}
703
+ </Button>
704
+ )}
705
+ </Row>
706
+ {screen === "setup" && setup?.owner ? (
707
+ <SetupScreen setup={setup} />
643
708
  ) : (
644
709
  <Stack>
645
- <Text>Members read every post. Join for $5 a month, cancel any time.</Text>
646
- {mine?.subscriptionId && mine.status !== "canceled" ? (
647
- <Text>Your last payment did not go through. Update your card below to keep reading.</Text>
710
+ <Heading>The club</Heading>
711
+ {!viewer ? (
712
+ <Stack>
713
+ <Text>Members read every post. Join for $5 a month, cancel any time.</Text>
714
+ <Button onClick={() => requestLogin()}>Sign in to join</Button>
715
+ </Stack>
716
+ ) : entitled ? (
717
+ <Badge tone="up">{mine.cancelAtPeriodEnd ? `Member until ${ends}` : mine.status === "past_due" ? "Payment due — please update your card" : "Member"}</Badge>
648
718
  ) : (
649
- <Button onClick={() => openStripe("/subscribe", { plan: "monthly" })} disabled={!configured || busy} loading={busy}>
650
- Join — Stripe opens in a new tab
719
+ <Stack>
720
+ <Text>Members read every post. Join for $5 a month, cancel any time.</Text>
721
+ {mine?.subscriptionId && mine.status !== "canceled" ? (
722
+ <Text>Your last payment did not go through. Update your card below to keep reading.</Text>
723
+ ) : ready ? (
724
+ <Button onClick={() => openStripe("/subscribe", { plan: "monthly" })} disabled={busy} loading={busy}>
725
+ Join — Stripe opens in a new tab
726
+ </Button>
727
+ ) : (
728
+ <Text muted>Memberships are not open yet.</Text>
729
+ )}
730
+ </Stack>
731
+ )}
732
+ {/* The portal is reachable whenever Stripe knows this member, entitled or not: a lapsed
733
+ member needs it most, to update the card or to cancel. */}
734
+ {mine?.customerId && (
735
+ <Button variant="quiet" onClick={() => openStripe("/portal", {})} disabled={busy} loading={busy}>
736
+ Manage membership — Stripe opens in a new tab
651
737
  </Button>
652
738
  )}
739
+ {error && <Text>{error}</Text>}
740
+ <ul>
741
+ {posts.map((p) => (
742
+ <li key={p._id}>{p.membersOnly && <Badge tone="info">Members</Badge>} {p.title}</li>
743
+ ))}
744
+ </ul>
745
+ {setup?.owner && (
746
+ <ul>
747
+ {memberships.map((m) => (
748
+ <li key={m._id}>@{m.memberHandle} · {m.plan} · {m.status}{m.entitled ? " · entitled" : ""}{m.currentPeriodEnd ? ` · until ${new Date(m.currentPeriodEnd).toLocaleDateString()}` : ""}</li>
749
+ ))}
750
+ </ul>
751
+ )}
653
752
  </Stack>
654
753
  )}
655
- {/* The portal is reachable whenever Stripe knows this member, entitled or not: a lapsed
656
- member needs it most, to update the card or to cancel. */}
657
- {mine?.customerId && (
658
- <Button variant="quiet" onClick={() => openStripe("/portal", {})} disabled={busy} loading={busy}>
659
- Manage membership — Stripe opens in a new tab
660
- </Button>
661
- )}
662
- {error && <Text>{error}</Text>}
663
- <ul>
664
- {posts.map((p) => (
665
- <li key={p._id}>{p.membersOnly && <Badge tone="info">Members</Badge>} {p.title}</li>
666
- ))}
667
- </ul>
668
- {status?.owner && (
669
- <ul>
670
- {memberships.map((m) => (
671
- <li key={m._id}>@{m.memberHandle} · {m.plan} · {m.status}{m.entitled ? " · entitled" : ""}{m.currentPeriodEnd ? ` · until ${new Date(m.currentPeriodEnd).toLocaleDateString()}` : ""}</li>
672
- ))}
673
- </ul>
674
- )}
675
754
  </Stack>
676
755
  );
677
756
  }