@tribe-nest/forge 3.60.0 → 3.63.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 +5 -2
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +10 -1
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +2 -0
- package/src/data/queries/useCommunity.ts +362 -18
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +6 -1
- package/src/i18n/de.json +746 -1
- package/src/i18n/en.json +746 -1
- package/src/index.ts +20 -0
- package/src/runtime/pages.spec.ts +5 -0
- package/src/runtime/pages.ts +32 -6
- package/src/seo/_tests/seo.spec.ts +326 -0
- package/src/seo/applySeoToHead.ts +98 -0
- package/src/seo/index.ts +9 -0
- package/src/seo/resolve.ts +211 -0
- package/src/seo/seoHead.ts +61 -0
- package/src/seo/siteSeoHead.ts +97 -0
- package/src/seo/types.ts +99 -0
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityHarness.tsx +10 -3
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +20 -1
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/index.ts +41 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +3 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
- package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +13 -0
- package/src/ui/headless/memberHome/index.ts +10 -0
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +712 -352
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +134 -11
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +9 -0
- package/src/ui/styled/communities/shared.tsx +39 -11
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +234 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
- package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/landing.spec.ts +39 -0
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
- package/src/utils/landing.ts +36 -10
- package/src/utils/metaEvents.ts +302 -0
- package/src/utils/structuredData.ts +339 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { communityRequestStatus } from "../../../data/queries/useCommunityClassroom";
|
|
3
|
+
import {
|
|
4
|
+
useCommunityCalendarItems,
|
|
5
|
+
useCommunityCallRsvp,
|
|
6
|
+
type CommunityCalendarCall,
|
|
7
|
+
type CommunityCalendarItem,
|
|
8
|
+
type CommunityRsvpResponse,
|
|
9
|
+
} from "../../../data/queries/useCommunityCalendar";
|
|
10
|
+
|
|
11
|
+
// ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/** One day of the agenda, in the viewer's own time zone. */
|
|
14
|
+
export interface CommunityCalendarDay {
|
|
15
|
+
/** `YYYY-MM-DD`, local. */
|
|
16
|
+
key: string;
|
|
17
|
+
/** Local midnight of the day. */
|
|
18
|
+
date: Date;
|
|
19
|
+
items: CommunityCalendarItem[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
23
|
+
|
|
24
|
+
/** The local day an instant falls on, as `YYYY-MM-DD`. */
|
|
25
|
+
export function calendarDayKey(value: Date | string): string {
|
|
26
|
+
const date = typeof value === "string" ? new Date(value) : value;
|
|
27
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Local midnight on the first of the month `offset` months from `now`. */
|
|
31
|
+
export function calendarMonthStart(now: Date, offset = 0): Date {
|
|
32
|
+
return new Date(now.getFullYear(), now.getMonth() + offset, 1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The window one month reads: its first local midnight up to the next month's. */
|
|
36
|
+
export function calendarMonthWindow(monthStart: Date): { from: Date; to: Date } {
|
|
37
|
+
return {
|
|
38
|
+
from: new Date(monthStart.getFullYear(), monthStart.getMonth(), 1),
|
|
39
|
+
to: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** When an item is over: its end, or its start when it has no end. */
|
|
44
|
+
export function calendarItemIsOver(item: Pick<CommunityCalendarItem, "startsAt" | "endsAt">, now: Date): boolean {
|
|
45
|
+
return new Date(item.endsAt ?? item.startsAt).getTime() < now.getTime();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The agenda, grouped by the local day each item starts on and split in two:
|
|
50
|
+
* `upcoming` (anything not yet over, soonest first) and `past` (most recent
|
|
51
|
+
* day first), so what a member can still act on is always at the top. Items
|
|
52
|
+
* keep their time order inside a day.
|
|
53
|
+
*/
|
|
54
|
+
export function groupCalendarItems(
|
|
55
|
+
items: CommunityCalendarItem[] | undefined,
|
|
56
|
+
now: Date,
|
|
57
|
+
): { upcoming: CommunityCalendarDay[]; past: CommunityCalendarDay[] } {
|
|
58
|
+
const sorted = [...(Array.isArray(items) ? items : [])].sort((a, b) => a.startsAt.localeCompare(b.startsAt));
|
|
59
|
+
const group = (list: CommunityCalendarItem[]) => {
|
|
60
|
+
const days = new Map<string, CommunityCalendarDay>();
|
|
61
|
+
for (const item of list) {
|
|
62
|
+
const key = calendarDayKey(item.startsAt);
|
|
63
|
+
let day = days.get(key);
|
|
64
|
+
if (!day) {
|
|
65
|
+
const start = new Date(item.startsAt);
|
|
66
|
+
day = { key, date: new Date(start.getFullYear(), start.getMonth(), start.getDate()), items: [] };
|
|
67
|
+
days.set(key, day);
|
|
68
|
+
}
|
|
69
|
+
day.items.push(item);
|
|
70
|
+
}
|
|
71
|
+
return [...days.values()];
|
|
72
|
+
};
|
|
73
|
+
const upcoming = group(sorted.filter((item) => !calendarItemIsOver(item, now)));
|
|
74
|
+
const past = group(sorted.filter((item) => calendarItemIsOver(item, now))).reverse();
|
|
75
|
+
return { upcoming, past };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What a failed going or not going means for the member. */
|
|
79
|
+
export type CommunityRsvpErrorCode = "closed" | "not_found" | "failed";
|
|
80
|
+
|
|
81
|
+
export function rsvpErrorCode(error: unknown): CommunityRsvpErrorCode {
|
|
82
|
+
const status = communityRequestStatus(error);
|
|
83
|
+
if (status === 409) return "closed";
|
|
84
|
+
if (status === 404) return "not_found";
|
|
85
|
+
return "failed";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Pressing the answer a member already gave clears it; any other answer replaces it. */
|
|
89
|
+
export function nextRsvp(
|
|
90
|
+
current: CommunityRsvpResponse | null,
|
|
91
|
+
pressed: CommunityRsvpResponse,
|
|
92
|
+
): CommunityRsvpResponse | null {
|
|
93
|
+
return current === pressed ? null : pressed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Join is offered only while the server says the room is open and there is a room to join. */
|
|
97
|
+
export function callJoinable(call: Pick<CommunityCalendarCall, "isOpen" | "callPublicId">): boolean {
|
|
98
|
+
return call.isOpen && !!call.callPublicId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Hook ─────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export interface UseCommunityCalendarOptions {
|
|
104
|
+
/** The clock, for tests. Defaults to the time of the first render. */
|
|
105
|
+
now?: Date;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Headless community calendar: one month at a time (this month first, arrows
|
|
110
|
+
* either way), grouped by day with what is still ahead on top, and going or
|
|
111
|
+
* not going on a call. Answering the answer already given clears it. A refused
|
|
112
|
+
* answer (the call is over or was cancelled, 409) is kept per call for the
|
|
113
|
+
* row to show.
|
|
114
|
+
*/
|
|
115
|
+
export function useCommunityCalendar(opts: UseCommunityCalendarOptions = {}) {
|
|
116
|
+
const [initialNow] = useState(() => opts.now ?? new Date());
|
|
117
|
+
const now = opts.now ?? initialNow;
|
|
118
|
+
const [offset, setOffset] = useState(0);
|
|
119
|
+
const monthStart = useMemo(() => calendarMonthStart(now, offset), [now, offset]);
|
|
120
|
+
const range = useMemo(() => calendarMonthWindow(monthStart), [monthStart]);
|
|
121
|
+
const query = useCommunityCalendarItems(range);
|
|
122
|
+
const groups = useMemo(() => groupCalendarItems(query.data, now), [query.data, now]);
|
|
123
|
+
const rsvpMutation = useCommunityCallRsvp();
|
|
124
|
+
const [rsvpError, setRsvpError] = useState<{ callId: string; code: CommunityRsvpErrorCode } | null>(null);
|
|
125
|
+
const status = communityRequestStatus(query.error);
|
|
126
|
+
|
|
127
|
+
const answer = (call: CommunityCalendarCall, pressed: CommunityRsvpResponse) => {
|
|
128
|
+
setRsvpError(null);
|
|
129
|
+
rsvpMutation.mutate(
|
|
130
|
+
{ callId: call.id, response: nextRsvp(call.rsvp.mine, pressed) },
|
|
131
|
+
{ onError: (error) => setRsvpError({ callId: call.id, code: rsvpErrorCode(error) }) },
|
|
132
|
+
);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
monthStart,
|
|
137
|
+
isCurrentMonth: offset === 0,
|
|
138
|
+
goToPreviousMonth: () => setOffset((o) => o - 1),
|
|
139
|
+
goToNextMonth: () => setOffset((o) => o + 1),
|
|
140
|
+
goToToday: () => setOffset(0),
|
|
141
|
+
items: Array.isArray(query.data) ? query.data : [],
|
|
142
|
+
upcoming: groups.upcoming,
|
|
143
|
+
past: groups.past,
|
|
144
|
+
now,
|
|
145
|
+
isLoading: query.isLoading,
|
|
146
|
+
/** Signed out, or not a member of this community. */
|
|
147
|
+
forbidden: status === 401 || status === 403,
|
|
148
|
+
isError: query.isError && status !== 401 && status !== 403,
|
|
149
|
+
/** Answer going or not going; pressing the current answer clears it. */
|
|
150
|
+
answer,
|
|
151
|
+
/** The call whose answer is being saved. */
|
|
152
|
+
pendingCallId: rsvpMutation.isPending ? (rsvpMutation.variables?.callId ?? null) : null,
|
|
153
|
+
/** Why a call's last answer was refused. */
|
|
154
|
+
rsvpErrorFor: (callId: string) => (rsvpError?.callId === callId ? rsvpError.code : null),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { useCommunityUpgradeOpener, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
2
|
+
import {
|
|
3
|
+
communityRequestStatus,
|
|
4
|
+
useCommunityClassroomCourses,
|
|
5
|
+
type CommunityClassroomCourse,
|
|
6
|
+
type CommunityClassroomProgress,
|
|
7
|
+
} from "../../../data/queries/useCommunityClassroom";
|
|
8
|
+
import { internalPath } from "../../../runtime/pages";
|
|
9
|
+
import { communityAboutPath } from "./paths";
|
|
10
|
+
import { wikiUnlockParts } from "./useCommunityWiki";
|
|
11
|
+
|
|
12
|
+
// ── Pure helpers (unit-tested; no React) ─────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/** Where a member stands with one course card. */
|
|
15
|
+
export type ClassroomCourseState = "locked" | "not_started" | "in_progress" | "completed";
|
|
16
|
+
|
|
17
|
+
/** How far through a course the member is, 0 to 100. */
|
|
18
|
+
export function classroomProgressPercent(progress: CommunityClassroomProgress | null | undefined): number {
|
|
19
|
+
if (!progress) return 0;
|
|
20
|
+
if (progress.completedAt) return 100;
|
|
21
|
+
const total = Number(progress.total) || 0;
|
|
22
|
+
if (total <= 0) return 0;
|
|
23
|
+
return Math.max(0, Math.min(100, Math.round(((Number(progress.completed) || 0) / total) * 100)));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function classroomCourseState(course: Pick<CommunityClassroomCourse, "open" | "progress">): ClassroomCourseState {
|
|
27
|
+
if (!course.open) return "locked";
|
|
28
|
+
const progress = course.progress;
|
|
29
|
+
if (!progress) return "not_started";
|
|
30
|
+
if (progress.completedAt || (progress.total > 0 && progress.completed >= progress.total)) return "completed";
|
|
31
|
+
return progress.completed > 0 ? "in_progress" : "not_started";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What opens a locked course: the level the member has not reached (null when
|
|
36
|
+
* the level is not what is missing) and the names of the tiers that include
|
|
37
|
+
* it (empty when none are named). The same reading as a locked wiki page.
|
|
38
|
+
*/
|
|
39
|
+
export function classroomUnlockParts(course: Pick<CommunityClassroomCourse, "unlockLevel" | "unlockTiers">) {
|
|
40
|
+
return wikiUnlockParts(course);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Where an open card leads: the course player, addressed by the member's
|
|
45
|
+
* access id. Null without one. The course's own page is the sales page, a dead
|
|
46
|
+
* end for someone who already holds the course, so it is never the fallback.
|
|
47
|
+
*/
|
|
48
|
+
export function classroomCourseHref(course: Pick<CommunityClassroomCourse, "open" | "accessId">): string | null {
|
|
49
|
+
return course.open && course.accessId ? internalPath("course-access/$accessId", { accessId: course.accessId }) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Hook ─────────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Headless classroom for the surrounding community: the course cards, open
|
|
56
|
+
* ones first as the server orders them, whether the viewer may see the tab at
|
|
57
|
+
* all (`forbidden` for a non-member), and how to unlock a locked card. `unlock`
|
|
58
|
+
* opens the community's upgrade sheet on the first tier that includes the
|
|
59
|
+
* course when a community home provides one; otherwise `unlockHref` is the
|
|
60
|
+
* About page with that tier chosen. A card only a level holds back has nothing
|
|
61
|
+
* to buy, so it offers no upgrade.
|
|
62
|
+
*/
|
|
63
|
+
export function useCommunityClassroom(opts: { enabled?: boolean } = {}) {
|
|
64
|
+
const community = useOptionalCommunityContext();
|
|
65
|
+
const opener = useCommunityUpgradeOpener();
|
|
66
|
+
const query = useCommunityClassroomCourses(opts);
|
|
67
|
+
const status = communityRequestStatus(query.error);
|
|
68
|
+
const courses = Array.isArray(query.data) ? query.data : [];
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
courses,
|
|
72
|
+
isLoading: query.isLoading,
|
|
73
|
+
/** Signed out, or not a member of this community. */
|
|
74
|
+
forbidden: status === 401 || status === 403,
|
|
75
|
+
notFound: status === 404,
|
|
76
|
+
isError: query.isError && status !== 401 && status !== 403 && status !== 404,
|
|
77
|
+
refetch: query.refetch,
|
|
78
|
+
/** A locked card offers an upgrade only when a tier includes it. */
|
|
79
|
+
canUpgrade: (course: CommunityClassroomCourse) => !course.open && course.unlockTiers.length > 0,
|
|
80
|
+
unlock: opener ? (course: CommunityClassroomCourse) => opener.openUpgrade(course.unlockTiers[0]?.id ?? null) : null,
|
|
81
|
+
unlockHref: (course: CommunityClassroomCourse) =>
|
|
82
|
+
community?.slug ? communityAboutPath(community.slug, { tier: course.unlockTiers[0]?.id }) : null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { MissingCommunityProviderError, useOptionalCommunityContext } from "../../../contexts/CommunityContext";
|
|
3
3
|
import { useConfirmLatestSubscription } from "../../../data/queries/useSubscriptions";
|
|
4
|
+
import { requestAppInstall } from "../siteApp/appPrompts";
|
|
4
5
|
import { useInvalidateCommunities } from "../../../data/queries/useCommunities";
|
|
5
6
|
import { useCommunity } from "./useCommunity";
|
|
6
7
|
import { communityAboutPath } from "./paths";
|
|
@@ -46,6 +47,8 @@ export function useCommunityHome(opts: UseCommunityHomeOptions = {}) {
|
|
|
46
47
|
setConfirming(true);
|
|
47
48
|
confirmLatest
|
|
48
49
|
.mutateAsync({ communityId })
|
|
50
|
+
// Back from paying to join: the same moment as a free join to offer the app.
|
|
51
|
+
.then(() => requestAppInstall("joined_community"))
|
|
49
52
|
.catch(() => undefined)
|
|
50
53
|
.finally(() => {
|
|
51
54
|
setConfirming(false);
|
|
@@ -5,17 +5,23 @@ import { useInvalidateCommunities, type PublicTier } from "../../../data/queries
|
|
|
5
5
|
import { useMembershipCheckout } from "../membership/useMembershipCheckout";
|
|
6
6
|
import { useSignupForm } from "../auth/useSignupForm";
|
|
7
7
|
import { useLoginFlow } from "../auth/useLoginFlow";
|
|
8
|
+
import { useEmailCodeSignIn } from "../auth/useEmailCodeSignIn";
|
|
8
9
|
import { offeredCycles } from "../../format/membershipPwyw";
|
|
9
10
|
import { useCommunity } from "./useCommunity";
|
|
10
11
|
import { useCommunityInvite } from "./useCommunityInvite";
|
|
11
12
|
import { communityHomePath } from "./paths";
|
|
13
|
+
import { requestAppInstall } from "../siteApp/appPrompts";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Where the join sheet is. Steps that do not apply are skipped, never shown:
|
|
15
17
|
*
|
|
16
|
-
* email who the fan is:
|
|
17
|
-
*
|
|
18
|
+
* email who the fan is: their email address, nothing else. Skipped
|
|
19
|
+
* when signed in. (`switchToPassword()` swaps in the email and
|
|
20
|
+
* password sign-in for someone who prefers it.)
|
|
18
21
|
* code the 6-digit code emailed to them. Skipped when signed in.
|
|
22
|
+
* Right for an address with an account here: signed in.
|
|
23
|
+
* profile right, but nobody has that address here yet: first name, last
|
|
24
|
+
* name and the terms. Only for someone new.
|
|
19
25
|
* tier which tier, and on a tier with a choice, the billing cycle or
|
|
20
26
|
* the pay-what-you-want amount. Skipped when there is one tier
|
|
21
27
|
* and nothing to choose.
|
|
@@ -26,12 +32,14 @@ import { communityHomePath } from "./paths";
|
|
|
26
32
|
* joined in. The sheet lands the fan on the community home.
|
|
27
33
|
* pending an application was lodged: the fan lands inside, pending.
|
|
28
34
|
*/
|
|
29
|
-
export type CommunityJoinStep = "email" | "code" | "tier" | "questions" | "payment" | "submitting" | "joined" | "pending";
|
|
35
|
+
export type CommunityJoinStep = "email" | "code" | "profile" | "tier" | "questions" | "payment" | "submitting" | "joined" | "pending";
|
|
30
36
|
|
|
31
37
|
/** The facts the step is derived from. Exported so the order can be tested without a provider tree. */
|
|
32
38
|
export interface CommunityJoinStepInput {
|
|
33
39
|
isAuthenticated: boolean;
|
|
34
40
|
awaitingCode: boolean;
|
|
41
|
+
/** The code was right and the address is new here: the name step. */
|
|
42
|
+
needsProfile?: boolean;
|
|
35
43
|
/** A per-email invite for another address, while signed in: the sheet offers "Sign in as". */
|
|
36
44
|
emailMismatch: boolean;
|
|
37
45
|
joined: boolean;
|
|
@@ -51,7 +59,7 @@ export interface CommunityJoinStepInput {
|
|
|
51
59
|
export function communityJoinStep(input: CommunityJoinStepInput): CommunityJoinStep {
|
|
52
60
|
if (input.joined || (input.alreadyMember && input.isAuthenticated)) return "joined";
|
|
53
61
|
if (input.pending) return "pending";
|
|
54
|
-
if (!input.isAuthenticated) return input.awaitingCode ? "code" : "email";
|
|
62
|
+
if (!input.isAuthenticated) return input.needsProfile ? "profile" : input.awaitingCode ? "code" : "email";
|
|
55
63
|
if (input.emailMismatch) return "email";
|
|
56
64
|
if (input.isProcessing) return "submitting";
|
|
57
65
|
if (input.hasPaymentStep) return "payment";
|
|
@@ -93,14 +101,18 @@ export interface UseCommunityJoinOptions {
|
|
|
93
101
|
/**
|
|
94
102
|
* Join a community from its About page, in place: Skool-simple.
|
|
95
103
|
*
|
|
96
|
-
* email -> code -> tier -> questions -> payment -> inside
|
|
104
|
+
* email -> code -> (profile) -> tier -> questions -> payment -> inside
|
|
97
105
|
*
|
|
98
|
-
* with every step that does not apply skipped.
|
|
106
|
+
* with every step that does not apply skipped. Identity is email first: the
|
|
107
|
+
* code decides whether the fan already has an account (signed in) or is new
|
|
108
|
+
* (asked their name), so nobody picks between "Create account" and "Sign in". A free tier with nothing to
|
|
99
109
|
* choose is joined the moment the code is accepted. Every success lands INSIDE
|
|
100
110
|
* the community (`/i/communities/<slug>/home`), never on the account page.
|
|
101
111
|
*
|
|
102
|
-
* Composes, rather than re-implements: the email-
|
|
103
|
-
*
|
|
112
|
+
* Composes, rather than re-implements: the email-first sign-in
|
|
113
|
+
* (`useEmailCodeSignIn`), the password sign-in (`useLoginFlow`) and the
|
|
114
|
+
* password sign-up (`useSignupForm`, kept for a custom sheet that sets
|
|
115
|
+
* `mode` to `signup`), the membership checkout (`useMembershipCheckout`:
|
|
104
116
|
* pricing, pay-what-you-want, questions, trials, free and paid subscribe,
|
|
105
117
|
* applications, Stripe and Paystack), the community read (`useCommunity`) and
|
|
106
118
|
* the invite read (`useCommunityInvite`).
|
|
@@ -153,6 +165,8 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
153
165
|
const land = useCallback(
|
|
154
166
|
(result: { pending: boolean; requiresConfirmation: boolean }) => {
|
|
155
167
|
void invalidate();
|
|
168
|
+
// In, with nothing left to pay: the moment the site app offers itself.
|
|
169
|
+
if (!result.pending && !result.requiresConfirmation) requestAppInstall("joined_community");
|
|
156
170
|
const path = result.pending ? home() : result.requiresConfirmation ? paidReturnPath : home({ welcome: 1 });
|
|
157
171
|
if (opts.onJoined) return opts.onJoined({ ...result, path });
|
|
158
172
|
if (opts.navigate) return opts.navigate(path);
|
|
@@ -188,7 +202,13 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
188
202
|
}, [presetTierId, tiers]);
|
|
189
203
|
|
|
190
204
|
// ---- Identity (email + code) -------------------------------------------------------
|
|
191
|
-
|
|
205
|
+
/**
|
|
206
|
+
* `email` (the default): email first, the code decides. `signin`: the email
|
|
207
|
+
* and password sign-in, for someone who prefers their password. `signup`: the
|
|
208
|
+
* password sign-up, which the styled sheet no longer offers.
|
|
209
|
+
*/
|
|
210
|
+
const [mode, setMode] = useState<"email" | "signup" | "signin">("email");
|
|
211
|
+
const emailFlow = useEmailCodeSignIn({ requireAcceptance: opts.requireAcceptance });
|
|
192
212
|
const signup = useSignupForm({
|
|
193
213
|
membershipTierId: checkout.selectedTierId,
|
|
194
214
|
requireAcceptance: opts.requireAcceptance,
|
|
@@ -199,9 +219,12 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
199
219
|
if (!lockedEmail) return;
|
|
200
220
|
if (signup.email !== lockedEmail) signup.setEmail(lockedEmail);
|
|
201
221
|
if (login.email !== lockedEmail) login.setEmail(lockedEmail);
|
|
222
|
+
if (emailFlow.email !== lockedEmail) emailFlow.setEmail(lockedEmail);
|
|
202
223
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
203
|
-
}, [lockedEmail, signup.email, login.email]);
|
|
204
|
-
const awaitingCode =
|
|
224
|
+
}, [lockedEmail, signup.email, login.email, emailFlow.email]);
|
|
225
|
+
const awaitingCode =
|
|
226
|
+
mode === "email" ? emailFlow.step === "code" : mode === "signup" ? signup.awaitingCode : login.step === "code";
|
|
227
|
+
const needsProfile = mode === "email" && emailFlow.step === "profile";
|
|
205
228
|
|
|
206
229
|
// ---- Tier and question confirmation ------------------------------------------------
|
|
207
230
|
const [tierConfirmed, setTierConfirmed] = useState(false);
|
|
@@ -230,6 +253,7 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
230
253
|
const step = communityJoinStep({
|
|
231
254
|
isAuthenticated,
|
|
232
255
|
awaitingCode,
|
|
256
|
+
needsProfile,
|
|
233
257
|
emailMismatch: isAuthenticated && invite.emailMismatch,
|
|
234
258
|
joined,
|
|
235
259
|
pending,
|
|
@@ -287,20 +311,26 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
287
311
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
288
312
|
}, [step, alreadyMember, joined, slug]);
|
|
289
313
|
|
|
290
|
-
const identityError = mode === "signup" ? signup.error : login.error;
|
|
291
|
-
const identitySubmitting =
|
|
314
|
+
const identityError = mode === "email" ? emailFlow.error : mode === "signup" ? signup.error : login.error;
|
|
315
|
+
const identitySubmitting =
|
|
316
|
+
mode === "email" ? emailFlow.isSubmitting : mode === "signup" ? signup.isSubmitting : login.isSubmitting;
|
|
292
317
|
|
|
293
318
|
/** Advance from the current step. */
|
|
294
319
|
const next = async () => {
|
|
295
320
|
switch (step) {
|
|
296
321
|
case "email":
|
|
297
|
-
if (mode === "
|
|
322
|
+
if (mode === "email") await emailFlow.submitEmail();
|
|
323
|
+
else if (mode === "signup") await signup.submit();
|
|
298
324
|
else await login.submitCredentials();
|
|
299
325
|
return;
|
|
300
326
|
case "code":
|
|
301
|
-
if (mode === "
|
|
327
|
+
if (mode === "email") await emailFlow.submitCode();
|
|
328
|
+
else if (mode === "signup") await signup.submitCode();
|
|
302
329
|
else await login.submitCode();
|
|
303
330
|
return;
|
|
331
|
+
case "profile":
|
|
332
|
+
await emailFlow.submitProfile();
|
|
333
|
+
return;
|
|
304
334
|
case "tier":
|
|
305
335
|
if (!selectedTier) return;
|
|
306
336
|
setTierConfirmed(true);
|
|
@@ -323,7 +353,9 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
323
353
|
const back = () => {
|
|
324
354
|
switch (step) {
|
|
325
355
|
case "code":
|
|
326
|
-
|
|
356
|
+
case "profile":
|
|
357
|
+
if (mode === "email") emailFlow.changeEmail();
|
|
358
|
+
else if (mode === "signup") signup.editDetails();
|
|
327
359
|
else login.back();
|
|
328
360
|
return;
|
|
329
361
|
case "questions":
|
|
@@ -351,9 +383,22 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
351
383
|
alreadyMember,
|
|
352
384
|
|
|
353
385
|
// ---- email + code ----
|
|
354
|
-
/** `
|
|
386
|
+
/** `email` (email first, the default), `signin` (email and password) or `signup` (password sign-up). */
|
|
355
387
|
mode,
|
|
356
388
|
setMode,
|
|
389
|
+
/** The email-first fields and steps (email, code, then a name for someone new). */
|
|
390
|
+
emailFlow,
|
|
391
|
+
/** "Use your password instead": the email and password sign-in, carrying the typed address over. */
|
|
392
|
+
switchToPassword: () => {
|
|
393
|
+
if (!lockedEmail && emailFlow.email && !login.email) login.setEmail(emailFlow.email);
|
|
394
|
+
setMode("signin");
|
|
395
|
+
},
|
|
396
|
+
/** Back from the password sign-in to email first. */
|
|
397
|
+
switchToEmailCode: () => {
|
|
398
|
+
if (!lockedEmail && login.email && !emailFlow.email) emailFlow.setEmail(login.email);
|
|
399
|
+
login.back();
|
|
400
|
+
setMode("email");
|
|
401
|
+
},
|
|
357
402
|
/** The sign-up fields (name, email, password, terms). */
|
|
358
403
|
signup,
|
|
359
404
|
/** The sign-in fields (email, password, then code). */
|
|
@@ -362,9 +407,13 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
362
407
|
emailLocked: !!lockedEmail,
|
|
363
408
|
lockedEmail,
|
|
364
409
|
/** The code the fan types on the `code` step, whichever mode. */
|
|
365
|
-
code: mode === "signup" ? signup.code : login.code,
|
|
366
|
-
setCode: (value: string) =>
|
|
367
|
-
|
|
410
|
+
code: mode === "email" ? emailFlow.code : mode === "signup" ? signup.code : login.code,
|
|
411
|
+
setCode: (value: string) =>
|
|
412
|
+
mode === "email" ? emailFlow.setCode(value) : mode === "signup" ? signup.setCode(value) : login.setCode(value),
|
|
413
|
+
resendCode: () =>
|
|
414
|
+
mode === "email" ? emailFlow.resendCode() : mode === "signup" ? signup.resendCode() : login.resend(),
|
|
415
|
+
/** The address the code went to, whichever mode. */
|
|
416
|
+
codeEmail: mode === "email" ? emailFlow.email : mode === "signup" ? signup.email : login.email,
|
|
368
417
|
|
|
369
418
|
// ---- invites ----
|
|
370
419
|
invite,
|
|
@@ -372,10 +421,11 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
372
421
|
inviteProblem: invite.problem,
|
|
373
422
|
/** Signed in as another address than a per-email invite's. */
|
|
374
423
|
emailMismatch: isAuthenticated && invite.emailMismatch ? { invitedEmail: invite.invitedEmail ?? "", signedInEmail: user?.email ?? "" } : null,
|
|
375
|
-
/** "Sign in as {email}": signs the current account out and
|
|
424
|
+
/** "Sign in as {email}": signs the current account out and starts email first with the invited address locked. */
|
|
376
425
|
signInAsInvited: async () => {
|
|
377
426
|
await auth?.logout();
|
|
378
|
-
|
|
427
|
+
emailFlow.changeEmail();
|
|
428
|
+
setMode("email");
|
|
379
429
|
},
|
|
380
430
|
|
|
381
431
|
// ---- tier ----
|
|
@@ -433,7 +483,7 @@ export function useCommunityJoin(opts: UseCommunityJoinOptions = {}) {
|
|
|
433
483
|
applicationOutcome: checkout.applicationOutcome,
|
|
434
484
|
/** Whether a join was attempted in this sheet. */
|
|
435
485
|
attempted,
|
|
436
|
-
error: step === "email" || step === "code" ? identityError : checkout.error,
|
|
486
|
+
error: step === "email" || step === "code" || step === "profile" ? identityError : checkout.error,
|
|
437
487
|
isSubmitting: identitySubmitting || checkout.isProcessing,
|
|
438
488
|
|
|
439
489
|
next,
|
|
@@ -3,11 +3,12 @@ import { MissingCommunityProviderError, useOptionalCommunityContext } from "../.
|
|
|
3
3
|
import {
|
|
4
4
|
useCancelCommunityMembership,
|
|
5
5
|
useDowngradeCommunityMembership,
|
|
6
|
+
useMyCommunityMemberships,
|
|
6
7
|
useUndoCommunityDowngrade,
|
|
7
8
|
type PublicTier,
|
|
8
9
|
} from "../../../data/queries/useCommunities";
|
|
9
10
|
import { useOpenBillingPortal } from "../../../data/queries/useSubscriptions";
|
|
10
|
-
import { cycleFloor, defaultCycle } from "../../format/membershipPwyw";
|
|
11
|
+
import { cycleFloor, cycleIsFree, defaultCycle, type BillingCycle } from "../../format/membershipPwyw";
|
|
11
12
|
import { useCommunity } from "./useCommunity";
|
|
12
13
|
|
|
13
14
|
const errMessage = (e: unknown) =>
|
|
@@ -37,6 +38,39 @@ export function communityDowngradeTiers(tiers: PublicTier[], currentTierId: stri
|
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
/** What the member pays, as far as the client can tell. */
|
|
42
|
+
export interface CommunityMembershipBilling {
|
|
43
|
+
/** True when the membership is paid for (a subscription with a price). */
|
|
44
|
+
isPaid: boolean;
|
|
45
|
+
/** Per period, in `currency`. Null on a free membership, or while unknown. */
|
|
46
|
+
amount: number | null;
|
|
47
|
+
currency: string | null;
|
|
48
|
+
cycle: BillingCycle | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Is this membership paid for, and what does it cost? Pure.
|
|
53
|
+
*
|
|
54
|
+
* The answer comes from the member's own row in `/public/memberships/mine`
|
|
55
|
+
* (`row`), which carries the subscription's real amount and cycle: a
|
|
56
|
+
* pay-what-you-want member pays what they chose, and a member on a tier sold
|
|
57
|
+
* both ways pays the cycle they picked. Until that row arrives, the tier's own
|
|
58
|
+
* price stands in, so a free tier never flashes "Update payment method".
|
|
59
|
+
*/
|
|
60
|
+
export function communityMembershipBilling(
|
|
61
|
+
row: { amount: number | null; currency: string | null; billingCycle: string | null } | null | undefined,
|
|
62
|
+
tier: PublicTier | null | undefined,
|
|
63
|
+
): CommunityMembershipBilling {
|
|
64
|
+
if (row) {
|
|
65
|
+
const amount = typeof row.amount === "number" && row.amount > 0 ? row.amount : null;
|
|
66
|
+
const cycle = row.billingCycle === "year" ? "year" : row.billingCycle === "month" ? "month" : null;
|
|
67
|
+
return { isPaid: amount !== null, amount, currency: amount !== null ? row.currency : null, cycle: amount !== null ? cycle : null };
|
|
68
|
+
}
|
|
69
|
+
if (!tier) return { isPaid: false, amount: null, currency: null, cycle: null };
|
|
70
|
+
const cycle = defaultCycle(tier);
|
|
71
|
+
return { isPaid: !cycleIsFree(tier, cycle), amount: null, currency: null, cycle: null };
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
/**
|
|
41
75
|
* The Membership menu inside a community: current tier, renewal date, a pending
|
|
42
76
|
* downgrade, and the actions. Cancel keeps the paid period; a downgrade takes
|
|
@@ -55,6 +89,7 @@ export function useCommunityMembership() {
|
|
|
55
89
|
const downgrade = useDowngradeCommunityMembership(ref);
|
|
56
90
|
const undo = useUndoCommunityDowngrade(ref);
|
|
57
91
|
const billingPortal = useOpenBillingPortal();
|
|
92
|
+
const mine = useMyCommunityMemberships({ enabled: !!community.membership });
|
|
58
93
|
|
|
59
94
|
const [cancelRequested, setCancelRequested] = useState(false);
|
|
60
95
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -73,11 +108,17 @@ export function useCommunityMembership() {
|
|
|
73
108
|
})
|
|
74
109
|
: null;
|
|
75
110
|
|
|
111
|
+
const mineRow = membership ? mine.data?.find((row) => row.id === membership.id) : undefined;
|
|
112
|
+
const billing = communityMembershipBilling(
|
|
113
|
+
mineRow ? { amount: mineRow.subscriptionAmount ?? null, currency: mineRow.subscriptionCurrency ?? null, billingCycle: mineRow.billingCycle ?? null } : null,
|
|
114
|
+
community.currentTier,
|
|
115
|
+
);
|
|
116
|
+
|
|
76
117
|
const run = async (action: () => Promise<unknown>) => {
|
|
77
118
|
setError(null);
|
|
78
119
|
try {
|
|
79
120
|
await action();
|
|
80
|
-
await community.refetch();
|
|
121
|
+
await Promise.all([community.refetch(), membership ? mine.refetch() : Promise.resolve()]);
|
|
81
122
|
return true;
|
|
82
123
|
} catch (e) {
|
|
83
124
|
setError(errMessage(e));
|
|
@@ -97,6 +138,11 @@ export function useCommunityMembership() {
|
|
|
97
138
|
pendingDowngradeTier,
|
|
98
139
|
downgradeTiers,
|
|
99
140
|
isPastDue: community.viewer?.state === "past_due",
|
|
141
|
+
/** Paid or free, and the price per period when the server has said. A free membership has no card to update. */
|
|
142
|
+
billing,
|
|
143
|
+
isPaid: billing.isPaid,
|
|
144
|
+
/** A higher tier exists, so the sheet offers Upgrade. */
|
|
145
|
+
hasUpgrade: community.hasUpgrade,
|
|
100
146
|
error,
|
|
101
147
|
isWorking: cancel.isPending || downgrade.isPending || undo.isPending || billingPortal.isPending,
|
|
102
148
|
|