@vibes.diy/prompts 14.3.175 → 14.3.176
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.
- package/llms/backend.md +5 -1
- package/llms/spotify.md +149 -72
- package/llms/stripe-subscriptions.md +196 -117
- package/llms/stripe.md +130 -93
- package/llms/use-vibe.md +77 -0
- package/llms/youtube.md +147 -66
- package/package.json +5 -5
|
@@ -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.
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`
|
|
67
|
-
|
|
68
|
-
`
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
answers a Stripe error whose message says so
|
|
75
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
<
|
|
538
|
-
<
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
<
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
<Button onClick={() =>
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
<
|
|
646
|
-
{
|
|
647
|
-
<
|
|
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
|
-
<
|
|
650
|
-
|
|
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
|
}
|