@tribe-nest/forge 3.59.0 → 3.60.0
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/package.json +1 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/data/queries/useChat.ts +42 -39
- package/src/data/queries/useCommunities.ts +343 -0
- package/src/data/queries/useCommunity.ts +93 -89
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSubscriptions.ts +22 -8
- package/src/i18n/de.json +145 -1
- package/src/i18n/en.json +145 -1
- package/src/index.ts +19 -7
- package/src/runtime/pages.spec.ts +31 -0
- package/src/runtime/pages.ts +21 -22
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +205 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/auth/useSignupForm.ts +10 -2
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +55 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityHome.ts +88 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/index.ts +8 -4
- package/src/ui/headless/memberHome/index.ts +1 -1
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/index.ts +17 -12
- package/src/ui/styled/AccountDashboard.tsx +48 -256
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
- package/src/ui/styled/communities/CommunityHome.tsx +175 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/index.ts +10 -0
- package/src/ui/styled/communities/shared.tsx +149 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/forge-utilities.css +19 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
- package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useCommunityUpgrade, type UseCommunityUpgradeOptions } from "../../headless/communities/useCommunityUpgrade";
|
|
3
|
+
import { useSiteConfig } from "../../../data/queries/useWebsite";
|
|
4
|
+
import { useForgeT } from "../../../i18n";
|
|
5
|
+
import { usePaymentRenderer } from "../../payment/ForgePaymentProvider";
|
|
6
|
+
import { formatCurrency } from "../../format/useFormatCurrency";
|
|
7
|
+
import { Loading } from "../Loading";
|
|
8
|
+
import { PaystackPayButton } from "../PaystackPayButton";
|
|
9
|
+
import { CommunitySheet, useCommunityStyles } from "./shared";
|
|
10
|
+
|
|
11
|
+
export interface CommunityUpgradeSheetProps extends UseCommunityUpgradeOptions {
|
|
12
|
+
open: boolean;
|
|
13
|
+
onOpenChange: (open: boolean) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The upgrade sheet: only the tiers above the member's current one. On Stripe
|
|
18
|
+
* one tap moves the saved subscription with a prorated charge; on Paystack the
|
|
19
|
+
* sheet says the card is collected again BEFORE the fan confirms. Every locked
|
|
20
|
+
* item's "Unlock with {tier}" opens this with that tier chosen. Built on
|
|
21
|
+
* `useCommunityUpgrade`.
|
|
22
|
+
*/
|
|
23
|
+
export function CommunityUpgradeSheet({ open, onOpenChange, ...options }: CommunityUpgradeSheetProps) {
|
|
24
|
+
const tr = useForgeT();
|
|
25
|
+
const { button, ghostButton, card, muted, errorText, t } = useCommunityStyles();
|
|
26
|
+
const renderPayment = usePaymentRenderer();
|
|
27
|
+
const currency = useSiteConfig().data?.currency;
|
|
28
|
+
const upgrade = useCommunityUpgrade(options);
|
|
29
|
+
const money = (amount: number | null | undefined, code?: string) => formatCurrency(amount ?? 0, code ?? currency);
|
|
30
|
+
|
|
31
|
+
// Re-opened on a different locked item: start on that item's tier.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (open && options.initialTierId && upgrade.selectedTierId !== options.initialTierId) upgrade.select(options.initialTierId);
|
|
34
|
+
if (!open) upgrade.reset();
|
|
35
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
36
|
+
}, [open, options.initialTierId]);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<CommunitySheet
|
|
40
|
+
open={open}
|
|
41
|
+
onOpenChange={onOpenChange}
|
|
42
|
+
testId="community-upgrade-sheet"
|
|
43
|
+
title={tr("forge.community_upgrade_sheet.title")}
|
|
44
|
+
closeLabel={tr("forge.community_upgrade_sheet.aria_close")}
|
|
45
|
+
>
|
|
46
|
+
{upgrade.isLoading ? (
|
|
47
|
+
<Loading />
|
|
48
|
+
) : upgrade.step === "choose" ? (
|
|
49
|
+
upgrade.options.length === 0 ? (
|
|
50
|
+
<p style={muted}>{tr("forge.community_upgrade_sheet.none")}</p>
|
|
51
|
+
) : (
|
|
52
|
+
<div className="fg:flex fg:flex-col fg:gap-3">
|
|
53
|
+
{upgrade.currentTierName && (
|
|
54
|
+
<p style={muted}>{tr("forge.community_upgrade_sheet.current", { tier_name: upgrade.currentTierName })}</p>
|
|
55
|
+
)}
|
|
56
|
+
{upgrade.options.map((option) => (
|
|
57
|
+
<button
|
|
58
|
+
key={option.id}
|
|
59
|
+
type="button"
|
|
60
|
+
data-testid="community-upgrade-option"
|
|
61
|
+
onClick={() => upgrade.select(option.id)}
|
|
62
|
+
className="fg:flex fg:flex-col fg:gap-1 fg:text-left"
|
|
63
|
+
style={{ ...card, padding: 14, cursor: "pointer", font: "inherit" }}
|
|
64
|
+
>
|
|
65
|
+
<span style={{ fontWeight: 700 }}>{option.name}</span>
|
|
66
|
+
<span style={{ fontSize: 13, color: t.muted }}>
|
|
67
|
+
{option.priceMonthly
|
|
68
|
+
? tr("forge.community_upgrade_sheet.price_monthly", { amount: money(option.priceMonthly) })
|
|
69
|
+
: option.priceYearly
|
|
70
|
+
? tr("forge.community_upgrade_sheet.price_yearly", { amount: money(option.priceYearly) })
|
|
71
|
+
: ""}
|
|
72
|
+
</span>
|
|
73
|
+
{!!option.tier?.benefits?.length && (
|
|
74
|
+
<span style={{ fontSize: 13 }}>{option.tier.benefits.slice(0, 3).map((benefit) => benefit.title).join(", ")}</span>
|
|
75
|
+
)}
|
|
76
|
+
</button>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
) : upgrade.step === "confirm" ? (
|
|
81
|
+
<div className="fg:flex fg:flex-col fg:gap-3" data-testid="community-upgrade-confirm">
|
|
82
|
+
<p style={{ fontWeight: 700 }}>
|
|
83
|
+
{tr("forge.community_upgrade_sheet.unlock_with", { tier_name: upgrade.selected?.name ?? "" })}
|
|
84
|
+
</p>
|
|
85
|
+
{upgrade.previewLoading ? (
|
|
86
|
+
<Loading />
|
|
87
|
+
) : upgrade.preview ? (
|
|
88
|
+
<p>
|
|
89
|
+
{upgrade.prorated
|
|
90
|
+
? tr("forge.community_upgrade_sheet.charge_prorated", { amount: money(upgrade.preview.amount, upgrade.preview.currency) })
|
|
91
|
+
: tr("forge.community_upgrade_sheet.charge_now", { amount: money(upgrade.preview.amount, upgrade.preview.currency) })}
|
|
92
|
+
</p>
|
|
93
|
+
) : null}
|
|
94
|
+
{/* What confirming will do to the card is a warning about the action, so it stays visible. */}
|
|
95
|
+
{upgrade.requiresCard && (
|
|
96
|
+
<p role="alert" data-testid="community-upgrade-requires-card" style={{ fontWeight: 600 }}>
|
|
97
|
+
{tr("forge.community_upgrade_sheet.requires_card")}
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
{upgrade.error && <p role="alert" style={errorText}>{upgrade.error}</p>}
|
|
101
|
+
<div className="fg:flex fg:gap-2">
|
|
102
|
+
<button type="button" style={ghostButton} onClick={upgrade.back}>
|
|
103
|
+
{tr("forge.community_upgrade_sheet.back")}
|
|
104
|
+
</button>
|
|
105
|
+
<button type="button" style={{ ...button, flex: 1 }} disabled={upgrade.isSubmitting} onClick={() => void upgrade.upgrade()}>
|
|
106
|
+
{upgrade.requiresCard ? tr("forge.community_upgrade_sheet.continue_to_card") : tr("forge.community_upgrade_sheet.confirm")}
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
) : upgrade.step === "payment" ? (
|
|
111
|
+
<div className="fg:flex fg:flex-col fg:gap-3" data-testid="community-upgrade-payment">
|
|
112
|
+
{upgrade.isPaystack ? (
|
|
113
|
+
<PaystackPayButton onPay={upgrade.openPaystackCheckout} label={tr("forge.community_upgrade_sheet.pay")} />
|
|
114
|
+
) : (
|
|
115
|
+
upgrade.clientSecret &&
|
|
116
|
+
renderPayment({
|
|
117
|
+
clientSecret: upgrade.clientSecret,
|
|
118
|
+
returnUrl: upgrade.returnUrl,
|
|
119
|
+
amount: upgrade.preview?.amount ?? 0,
|
|
120
|
+
currency: upgrade.preview?.currency ?? currency,
|
|
121
|
+
mode: "manual",
|
|
122
|
+
onSucceeded: upgrade.onPaymentSucceeded,
|
|
123
|
+
onFailed: () => {},
|
|
124
|
+
})
|
|
125
|
+
)}
|
|
126
|
+
{upgrade.error && <p role="alert" style={errorText}>{upgrade.error}</p>}
|
|
127
|
+
</div>
|
|
128
|
+
) : upgrade.step === "submitting" ? (
|
|
129
|
+
<Loading />
|
|
130
|
+
) : (
|
|
131
|
+
<p data-testid="community-upgrade-done" style={{ fontWeight: 700 }}>
|
|
132
|
+
{tr("forge.community_upgrade_sheet.done")}
|
|
133
|
+
</p>
|
|
134
|
+
)}
|
|
135
|
+
</CommunitySheet>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { useMyMemberships } from "../../headless/communities/useMyMemberships";
|
|
3
|
+
import { getMembershipStatusMessage } from "../../../utils/membershipAccess";
|
|
4
|
+
import { useForgeLocale, useForgeT } from "../../../i18n";
|
|
5
|
+
import { useAmountFormatter } from "../../format/useFormatCurrency";
|
|
6
|
+
import { Loading } from "../Loading";
|
|
7
|
+
import { Pill, shortDate, useCommunityStyles } from "./shared";
|
|
8
|
+
|
|
9
|
+
export interface MyMembershipsListProps {
|
|
10
|
+
/** SPA navigation adapter for "Open community". Defaults to a full-page visit. */
|
|
11
|
+
navigate?: (href: string) => void;
|
|
12
|
+
/** Format an amount in a currency. Defaults to the runtime currency formatter. */
|
|
13
|
+
formatAmount?: (amount: number, currencyCode?: string) => string;
|
|
14
|
+
className?: string;
|
|
15
|
+
style?: CSSProperties;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Every membership the fan holds with this creator, one card per community,
|
|
20
|
+
* for the account page. Managing one (billing, cancel, downgrade) happens
|
|
21
|
+
* inside its community, so each card links there. Built on `useMyMemberships`.
|
|
22
|
+
*/
|
|
23
|
+
export function MyMembershipsList({ navigate, formatAmount, className, style }: MyMembershipsListProps) {
|
|
24
|
+
const tr = useForgeT();
|
|
25
|
+
const locale = useForgeLocale();
|
|
26
|
+
const { t, card, ghostButton, muted } = useCommunityStyles();
|
|
27
|
+
const fmt = useAmountFormatter(formatAmount);
|
|
28
|
+
const { memberships, isLoading } = useMyMemberships();
|
|
29
|
+
const go = navigate ?? ((href: string) => window.location.assign(href));
|
|
30
|
+
|
|
31
|
+
if (isLoading) return <Loading />;
|
|
32
|
+
if (memberships.length === 0) {
|
|
33
|
+
return (
|
|
34
|
+
<p className={className} data-testid="my-memberships-empty" style={{ ...muted, ...style }}>
|
|
35
|
+
{tr("forge.my_memberships_list.empty")}
|
|
36
|
+
</p>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<ul
|
|
42
|
+
className={className}
|
|
43
|
+
data-testid="my-memberships"
|
|
44
|
+
style={{ listStyle: "none", padding: 0, margin: 0, display: "flex", flexDirection: "column", gap: 12, ...style }}
|
|
45
|
+
>
|
|
46
|
+
{memberships.map((membership) => {
|
|
47
|
+
const message = getMembershipStatusMessage(membership.access);
|
|
48
|
+
const periodEnd = shortDate(membership.periodEnd, locale);
|
|
49
|
+
return (
|
|
50
|
+
<li key={membership.id} data-testid="my-membership" className="fg:flex fg:flex-col fg:gap-2 fg:sm:flex-row fg:sm:items-center fg:sm:justify-between" style={card}>
|
|
51
|
+
<div className="fg:min-w-0">
|
|
52
|
+
<p style={{ fontWeight: 700 }}>{membership.community.name}</p>
|
|
53
|
+
<div className="fg:flex fg:flex-wrap fg:items-center fg:gap-2" style={{ marginTop: 4 }}>
|
|
54
|
+
{membership.tierName && <Pill>{membership.tierName}</Pill>}
|
|
55
|
+
<span style={{ fontSize: 13, fontWeight: message.tone === "warning" ? 700 : 400, color: message.tone === "warning" ? t.primary : t.muted }}>
|
|
56
|
+
{message.label}
|
|
57
|
+
</span>
|
|
58
|
+
</div>
|
|
59
|
+
{!!membership.amount && (
|
|
60
|
+
<p style={{ ...muted, marginTop: 4 }}>
|
|
61
|
+
{tr("forge.my_memberships_list.price", {
|
|
62
|
+
amount: fmt(membership.amount, membership.currency ?? undefined),
|
|
63
|
+
cycle: membership.billingCycle === "year" ? tr("forge.my_memberships_list.cycle_year") : tr("forge.my_memberships_list.cycle_month"),
|
|
64
|
+
})}
|
|
65
|
+
</p>
|
|
66
|
+
)}
|
|
67
|
+
{membership.inTrial && membership.trialEndsAt ? (
|
|
68
|
+
<p data-testid="my-membership-trial" style={{ ...muted, fontWeight: 600 }}>
|
|
69
|
+
{tr("forge.my_memberships_list.trial_until", { date: shortDate(membership.trialEndsAt, locale) })}
|
|
70
|
+
</p>
|
|
71
|
+
) : (
|
|
72
|
+
periodEnd && (
|
|
73
|
+
<p style={muted}>
|
|
74
|
+
{membership.cancelAtPeriodEnd
|
|
75
|
+
? tr("forge.my_memberships_list.ends_on", { date: periodEnd })
|
|
76
|
+
: tr("forge.my_memberships_list.renews_on", { date: periodEnd })}
|
|
77
|
+
</p>
|
|
78
|
+
)
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
<button type="button" style={{ ...ghostButton, flexShrink: 0 }} onClick={() => go(membership.homePath)}>
|
|
82
|
+
{tr("forge.my_memberships_list.open")}
|
|
83
|
+
</button>
|
|
84
|
+
</li>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</ul>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
4
|
+
import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
|
|
5
|
+
import { CommunityAbout } from "../CommunityAbout";
|
|
6
|
+
import { CommunityHome } from "../CommunityHome";
|
|
7
|
+
import { CommunitiesList } from "../CommunitiesList";
|
|
8
|
+
import {
|
|
9
|
+
COMMUNITY,
|
|
10
|
+
account,
|
|
11
|
+
detailRoute,
|
|
12
|
+
get,
|
|
13
|
+
installAdapter,
|
|
14
|
+
meRoute,
|
|
15
|
+
restoreAdapter,
|
|
16
|
+
tier,
|
|
17
|
+
viewer,
|
|
18
|
+
wrapper,
|
|
19
|
+
} from "../../../headless/_tests/communityHarness";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The styled community pages are their headless hooks plus markup. These pin
|
|
23
|
+
* the markup's side of the contract: the About page's one button and the
|
|
24
|
+
* in-place sheet, the home header (tier badge, Upgrade, Membership), and the
|
|
25
|
+
* list linking to About pages.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
afterEach(restoreAdapter);
|
|
29
|
+
|
|
30
|
+
const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
|
|
31
|
+
|
|
32
|
+
function mount(node: React.ReactNode, opts: Parameters<typeof wrapper>[0] = {}) {
|
|
33
|
+
const Wrapper = wrapper(opts);
|
|
34
|
+
return render(
|
|
35
|
+
<Wrapper>
|
|
36
|
+
<ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
|
|
37
|
+
</Wrapper>,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const free = tier({ id: "tier-free", name: "Free", benefits: [{ id: "b1", title: "The feed" }] as never });
|
|
42
|
+
|
|
43
|
+
describe("CommunityAbout", () => {
|
|
44
|
+
it("shows the community, its tiers, and Join free that opens the sheet in place", async () => {
|
|
45
|
+
installAdapter([detailRoute(() => ({ tiers: [free], viewer: viewer({ state: "anonymous" }) }))]);
|
|
46
|
+
mount(<CommunityAbout />);
|
|
47
|
+
expect(await screen.findByRole("heading", { name: "Fan Club" })).toBeTruthy();
|
|
48
|
+
expect((await screen.findAllByTestId("community-about-tier"))[0].textContent).toContain("The feed");
|
|
49
|
+
const join = await screen.findByTestId("community-about-primary");
|
|
50
|
+
await waitFor(() => expect(join.textContent).toBe("Join free"));
|
|
51
|
+
fireEvent.click(join);
|
|
52
|
+
expect(await screen.findByTestId("community-join-sheet")).toBeTruthy();
|
|
53
|
+
expect(window.location.pathname).not.toContain("/i/account");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("opens the sheet straight away on an invite", async () => {
|
|
57
|
+
installAdapter([
|
|
58
|
+
get("/public/communities/fan-club/invite", () => ({ kind: "email", state: "expired", invitedEmail: null, tier: null })),
|
|
59
|
+
detailRoute(() => ({ tiers: [free] })),
|
|
60
|
+
]);
|
|
61
|
+
mount(<CommunityAbout invite="tok" />);
|
|
62
|
+
const sheet = await screen.findByTestId("community-join-sheet");
|
|
63
|
+
expect((await within(sheet).findByTestId("community-join-invite-problem")).textContent).toContain("expired");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("CommunityHome", () => {
|
|
68
|
+
it("renders the header with the tier badge, Upgrade and Membership, then the body", async () => {
|
|
69
|
+
installAdapter([
|
|
70
|
+
meRoute(() =>
|
|
71
|
+
viewer({
|
|
72
|
+
state: "member",
|
|
73
|
+
membership: { id: "m-1", tierId: "tier-free", tierName: "Free", status: "active", cancelAtPeriodEnd: false, currentPeriodEnd: null, pendingDowngradeTierId: null },
|
|
74
|
+
upgradeTiers: [{ id: "tier-gold", name: "Gold", priceMonthly: 20, priceYearly: null }],
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
detailRoute(() => ({ tiers: [free] })),
|
|
78
|
+
]);
|
|
79
|
+
mount(
|
|
80
|
+
<CommunityHome tab="posts">
|
|
81
|
+
<p data-testid="body">feed</p>
|
|
82
|
+
</CommunityHome>,
|
|
83
|
+
{ user: account() },
|
|
84
|
+
);
|
|
85
|
+
expect((await screen.findByTestId("community-home-tier-badge")).textContent).toBe("Free");
|
|
86
|
+
expect(await screen.findByTestId("community-home-upgrade")).toBeTruthy();
|
|
87
|
+
expect(screen.getByTestId("community-home-membership")).toBeTruthy();
|
|
88
|
+
expect(screen.getByTestId("body").textContent).toBe("feed");
|
|
89
|
+
fireEvent.click(screen.getByTestId("community-home-upgrade"));
|
|
90
|
+
expect(await screen.findByTestId("community-upgrade-sheet")).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("CommunitiesList", () => {
|
|
95
|
+
it("links each community to its About page", async () => {
|
|
96
|
+
installAdapter([get("/public/communities", () => [COMMUNITY])]);
|
|
97
|
+
const went: string[] = [];
|
|
98
|
+
mount(<CommunitiesList navigate={(href) => went.push(href)} />, { community: null });
|
|
99
|
+
fireEvent.click(await screen.findByTestId("community-card"));
|
|
100
|
+
expect(went).toEqual(["/i/communities/fan-club"]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Communities styled blocks. Each is a headless hook from
|
|
2
|
+
// `ui/headless/communities` plus markup, and nothing else.
|
|
3
|
+
export { CommunitiesList, type CommunitiesListProps } from "./CommunitiesList";
|
|
4
|
+
export { CommunityAbout, type CommunityAboutProps } from "./CommunityAbout";
|
|
5
|
+
export { CommunityJoinSheet, type CommunityJoinSheetProps } from "./CommunityJoinSheet";
|
|
6
|
+
export { CommunityPending, type CommunityPendingProps } from "./CommunityPending";
|
|
7
|
+
export { CommunityHome, type CommunityHomeProps, type CommunityHomeTab } from "./CommunityHome";
|
|
8
|
+
export { CommunityUpgradeSheet, type CommunityUpgradeSheetProps } from "./CommunityUpgradeSheet";
|
|
9
|
+
export { CommunityMembershipMenu, type CommunityMembershipMenuProps } from "./CommunityMembershipMenu";
|
|
10
|
+
export { MyMembershipsList, type MyMembershipsListProps } from "./MyMembershipsList";
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "../../headless/dialog";
|
|
4
|
+
import { useThemeTokens, type ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
5
|
+
|
|
6
|
+
/** The styles every community block shares, from the theme only. */
|
|
7
|
+
export function useCommunityStyles() {
|
|
8
|
+
const t = useThemeTokens();
|
|
9
|
+
const card: CSSProperties = {
|
|
10
|
+
background: t.surface,
|
|
11
|
+
border: `1px solid ${t.border}`,
|
|
12
|
+
borderRadius: t.cornerRadius,
|
|
13
|
+
padding: 20,
|
|
14
|
+
color: t.text,
|
|
15
|
+
};
|
|
16
|
+
const button: CSSProperties = {
|
|
17
|
+
padding: "10px 16px",
|
|
18
|
+
borderRadius: t.cornerRadius,
|
|
19
|
+
background: t.primary,
|
|
20
|
+
color: t.textPrimary,
|
|
21
|
+
border: "none",
|
|
22
|
+
fontWeight: 600,
|
|
23
|
+
cursor: "pointer",
|
|
24
|
+
fontFamily: t.fontFamily,
|
|
25
|
+
};
|
|
26
|
+
const ghostButton: CSSProperties = {
|
|
27
|
+
...button,
|
|
28
|
+
background: "transparent",
|
|
29
|
+
color: t.text,
|
|
30
|
+
border: `1px solid ${t.border}`,
|
|
31
|
+
};
|
|
32
|
+
const input: CSSProperties = {
|
|
33
|
+
width: "100%",
|
|
34
|
+
height: 40,
|
|
35
|
+
padding: "8px 12px",
|
|
36
|
+
borderRadius: t.cornerRadius,
|
|
37
|
+
border: `1px solid ${t.border}`,
|
|
38
|
+
background: "transparent",
|
|
39
|
+
color: t.text,
|
|
40
|
+
outline: "none",
|
|
41
|
+
boxSizing: "border-box",
|
|
42
|
+
fontFamily: t.fontFamily,
|
|
43
|
+
};
|
|
44
|
+
const muted: CSSProperties = { fontSize: 13, color: t.muted };
|
|
45
|
+
const errorText: CSSProperties = { fontSize: 13, color: "#ef4444", marginTop: 8 };
|
|
46
|
+
return { t, card, button, ghostButton, input, muted, errorText };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A modal sheet on the shared dialog primitives, so it stands down while a
|
|
51
|
+
* Paystack popup is open (see `DialogRoot`). The hook that owns the flow must
|
|
52
|
+
* live in the COMPONENT that renders this, not inside `children`: a Paystack
|
|
53
|
+
* stand-down remounts the content.
|
|
54
|
+
*/
|
|
55
|
+
export function CommunitySheet({
|
|
56
|
+
open,
|
|
57
|
+
onOpenChange,
|
|
58
|
+
title,
|
|
59
|
+
closeLabel,
|
|
60
|
+
children,
|
|
61
|
+
testId,
|
|
62
|
+
}: {
|
|
63
|
+
open: boolean;
|
|
64
|
+
onOpenChange: (open: boolean) => void;
|
|
65
|
+
title: ReactNode;
|
|
66
|
+
closeLabel: string;
|
|
67
|
+
children: ReactNode;
|
|
68
|
+
testId?: string;
|
|
69
|
+
}) {
|
|
70
|
+
const t = useThemeTokens();
|
|
71
|
+
return (
|
|
72
|
+
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
|
73
|
+
<DialogPortal>
|
|
74
|
+
<DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
|
|
75
|
+
<DialogContent
|
|
76
|
+
data-testid={testId}
|
|
77
|
+
aria-describedby={undefined}
|
|
78
|
+
style={sheetStyle(t)}
|
|
79
|
+
>
|
|
80
|
+
<div className="fg:flex fg:items-center fg:justify-between fg:gap-3" style={{ marginBottom: 12 }}>
|
|
81
|
+
<DialogTitle style={{ fontSize: 18, fontWeight: 700, margin: 0, fontFamily: t.headingFontFamily }}>
|
|
82
|
+
{title}
|
|
83
|
+
</DialogTitle>
|
|
84
|
+
<DialogClose asChild>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
aria-label={closeLabel}
|
|
88
|
+
style={{ background: "transparent", border: "none", color: t.text, cursor: "pointer", padding: 4 }}
|
|
89
|
+
>
|
|
90
|
+
<X size={18} />
|
|
91
|
+
</button>
|
|
92
|
+
</DialogClose>
|
|
93
|
+
</div>
|
|
94
|
+
{children}
|
|
95
|
+
</DialogContent>
|
|
96
|
+
</DialogPortal>
|
|
97
|
+
</DialogRoot>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sheetStyle(t: ResolvedThemeTokens): CSSProperties {
|
|
102
|
+
return {
|
|
103
|
+
position: "fixed",
|
|
104
|
+
top: "50%",
|
|
105
|
+
left: "50%",
|
|
106
|
+
transform: "translate(-50%, -50%)",
|
|
107
|
+
width: "min(560px, calc(100vw - 32px))",
|
|
108
|
+
maxHeight: "88vh",
|
|
109
|
+
overflowY: "auto",
|
|
110
|
+
background: t.background,
|
|
111
|
+
color: t.text,
|
|
112
|
+
borderRadius: t.cornerRadius,
|
|
113
|
+
padding: 24,
|
|
114
|
+
zIndex: 51,
|
|
115
|
+
fontFamily: t.fontFamily,
|
|
116
|
+
boxSizing: "border-box",
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A small pill, e.g. the member's tier badge. */
|
|
121
|
+
export function Pill({ children, testId }: { children: ReactNode; testId?: string }) {
|
|
122
|
+
const t = useThemeTokens();
|
|
123
|
+
return (
|
|
124
|
+
<span
|
|
125
|
+
data-testid={testId}
|
|
126
|
+
style={{
|
|
127
|
+
display: "inline-flex",
|
|
128
|
+
alignItems: "center",
|
|
129
|
+
padding: "2px 10px",
|
|
130
|
+
borderRadius: 999,
|
|
131
|
+
fontSize: 12,
|
|
132
|
+
fontWeight: 700,
|
|
133
|
+
border: `1px solid ${t.primary}`,
|
|
134
|
+
color: t.primary,
|
|
135
|
+
whiteSpace: "nowrap",
|
|
136
|
+
}}
|
|
137
|
+
>
|
|
138
|
+
{children}
|
|
139
|
+
</span>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A locale-aware short date. */
|
|
144
|
+
export function shortDate(value: string | null | undefined, locale: string): string {
|
|
145
|
+
if (!value) return "";
|
|
146
|
+
const date = new Date(value);
|
|
147
|
+
if (Number.isNaN(date.getTime())) return "";
|
|
148
|
+
return date.toLocaleDateString(locale, { year: "numeric", month: "short", day: "numeric" });
|
|
149
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { ReactNode } from "react";
|
|
|
7
7
|
import { CommunityComposer, type CommunityComposerProps } from "../CommunityComposer";
|
|
8
8
|
import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
|
|
9
9
|
import { ForgeThemeProvider, defaultForgeTheme } from "../../../theme/ForgeThemeProvider";
|
|
10
|
+
import { InCommunity } from "../../../../_tests/testCommunity";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* The composer's open/close behaviour, rendered for real.
|
|
@@ -40,7 +41,9 @@ function wrap(children: ReactNode) {
|
|
|
40
41
|
return (
|
|
41
42
|
<QueryClientProvider client={client}>
|
|
42
43
|
<ForgeClientProvider apiUrl="http://localhost/api">
|
|
43
|
-
<ForgeThemeProvider theme={defaultForgeTheme}>
|
|
44
|
+
<ForgeThemeProvider theme={defaultForgeTheme}>
|
|
45
|
+
<InCommunity>{children}</InCommunity>
|
|
46
|
+
</ForgeThemeProvider>
|
|
44
47
|
</ForgeClientProvider>
|
|
45
48
|
</QueryClientProvider>
|
|
46
49
|
);
|
|
@@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
6
6
|
import { CommunityPostDetail } from "../CommunityPostDetail";
|
|
7
7
|
import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
|
|
8
8
|
import { ForgeThemeProvider, defaultForgeTheme } from "../../../theme/ForgeThemeProvider";
|
|
9
|
+
import { InCommunity } from "../../../../_tests/testCommunity";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The thread view has TWO composers with opposite requirements, and this file
|
|
@@ -91,7 +92,9 @@ function mountThread({ isLocked = false } = {}) {
|
|
|
91
92
|
<QueryClientProvider client={client}>
|
|
92
93
|
<ForgeClientProvider apiUrl="http://localhost/api">
|
|
93
94
|
<ForgeThemeProvider theme={defaultForgeTheme}>
|
|
94
|
-
<
|
|
95
|
+
<InCommunity>
|
|
96
|
+
<CommunityPostDetail postId="post-1" />
|
|
97
|
+
</InCommunity>
|
|
95
98
|
</ForgeThemeProvider>
|
|
96
99
|
</ForgeClientProvider>
|
|
97
100
|
</QueryClientProvider>,
|
|
@@ -161,6 +161,12 @@
|
|
|
161
161
|
.fg\:ml-2 {
|
|
162
162
|
margin-left: calc(var(--fg-spacing) * 2);
|
|
163
163
|
}
|
|
164
|
+
.fg\:line-clamp-3 {
|
|
165
|
+
overflow: hidden;
|
|
166
|
+
display: -webkit-box;
|
|
167
|
+
-webkit-box-orient: vertical;
|
|
168
|
+
-webkit-line-clamp: 3;
|
|
169
|
+
}
|
|
164
170
|
.fg\:block {
|
|
165
171
|
display: block;
|
|
166
172
|
}
|
|
@@ -366,6 +372,11 @@
|
|
|
366
372
|
.fg\:gap-y-2 {
|
|
367
373
|
row-gap: calc(var(--fg-spacing) * 2);
|
|
368
374
|
}
|
|
375
|
+
.fg\:truncate {
|
|
376
|
+
overflow: hidden;
|
|
377
|
+
text-overflow: ellipsis;
|
|
378
|
+
white-space: nowrap;
|
|
379
|
+
}
|
|
369
380
|
.fg\:overflow-hidden {
|
|
370
381
|
overflow: hidden;
|
|
371
382
|
}
|
|
@@ -523,6 +534,9 @@
|
|
|
523
534
|
.fg\:text-center {
|
|
524
535
|
text-align: center;
|
|
525
536
|
}
|
|
537
|
+
.fg\:text-left {
|
|
538
|
+
text-align: left;
|
|
539
|
+
}
|
|
526
540
|
.fg\:text-right {
|
|
527
541
|
text-align: right;
|
|
528
542
|
}
|
|
@@ -760,6 +774,11 @@
|
|
|
760
774
|
width: 300px;
|
|
761
775
|
}
|
|
762
776
|
}
|
|
777
|
+
.fg\:md\:w-\[320px\] {
|
|
778
|
+
@media (width >= 48rem) {
|
|
779
|
+
width: 320px;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
763
782
|
.fg\:md\:w-\[360px\] {
|
|
764
783
|
@media (width >= 48rem) {
|
|
765
784
|
width: 360px;
|