@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,102 @@
|
|
|
1
|
+
import { internalPath } from "../../../runtime/pages";
|
|
2
|
+
import {
|
|
3
|
+
SITE_APP_BAR_KEYS,
|
|
4
|
+
SITE_APP_BAR_MAX_ITEMS,
|
|
5
|
+
SITE_APP_DEFAULT_BAR,
|
|
6
|
+
type SiteAppBarKey,
|
|
7
|
+
} from "../../../data/queries/useSiteApp";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The site app's bottom bar: Home, then the creator's chosen items (at most
|
|
11
|
+
* three), then More, which opens a sheet with every other destination. Kept
|
|
12
|
+
* apart from how it is drawn so the bar, the sheet and a spec can never
|
|
13
|
+
* disagree about what goes where (the same split as `buildMemberHomeBar`).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type AppBarKey = "home" | SiteAppBarKey;
|
|
17
|
+
|
|
18
|
+
export interface AppBarItem {
|
|
19
|
+
key: AppBarKey;
|
|
20
|
+
href: string;
|
|
21
|
+
active: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AppBarModel {
|
|
25
|
+
/** Home first, then the configured items. More is not an item: it is always drawn last. */
|
|
26
|
+
bar: AppBarItem[];
|
|
27
|
+
/** Every destination not in the bar, in the canonical order. */
|
|
28
|
+
more: AppBarItem[];
|
|
29
|
+
/** The page being shown lives in the sheet, so More is the highlighted button. */
|
|
30
|
+
moreActive: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Where each destination goes. Inbox is the member's notifications. */
|
|
34
|
+
export const APP_BAR_HREFS: Readonly<Record<SiteAppBarKey, string>> = {
|
|
35
|
+
communities: internalPath("communities"),
|
|
36
|
+
portal: internalPath("account"),
|
|
37
|
+
inbox: internalPath("account/notifications"),
|
|
38
|
+
events: internalPath("events"),
|
|
39
|
+
courses: internalPath("courses"),
|
|
40
|
+
shop: internalPath("store"),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The configured keys made safe: unknown keys and repeats dropped, at most
|
|
45
|
+
* three. A missing or empty list is the contract's default.
|
|
46
|
+
*/
|
|
47
|
+
export function normaliseBarItems(items: readonly string[] | null | undefined): SiteAppBarKey[] {
|
|
48
|
+
if (!items || items.length === 0) return [...SITE_APP_DEFAULT_BAR];
|
|
49
|
+
const known = new Set<string>(SITE_APP_BAR_KEYS);
|
|
50
|
+
const out: SiteAppBarKey[] = [];
|
|
51
|
+
for (const key of items) {
|
|
52
|
+
if (!known.has(key) || out.includes(key as SiteAppBarKey)) continue;
|
|
53
|
+
out.push(key as SiteAppBarKey);
|
|
54
|
+
if (out.length === SITE_APP_BAR_MAX_ITEMS) break;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Strip a query and hash, and a trailing slash, so `/i/events/` and `/i/events?x=1` compare equal. */
|
|
60
|
+
function clean(path: string): string {
|
|
61
|
+
const bare = path.split(/[?#]/)[0] ?? "";
|
|
62
|
+
return bare.length > 1 ? bare.replace(/\/+$/, "") : bare;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Is `currentPath` this destination? Home matches only itself; the others match
|
|
67
|
+
* their own page and anything below it, except that the portal does not claim
|
|
68
|
+
* the inbox, which lives under it.
|
|
69
|
+
*/
|
|
70
|
+
export function isAppBarItemActive(key: AppBarKey, href: string, currentPath: string | null | undefined): boolean {
|
|
71
|
+
if (!currentPath) return false;
|
|
72
|
+
const here = clean(currentPath);
|
|
73
|
+
const target = clean(href);
|
|
74
|
+
if (key === "home") return here === target;
|
|
75
|
+
if (key === "portal" && isAppBarItemActive("inbox", APP_BAR_HREFS.inbox, here)) return false;
|
|
76
|
+
return here === target || here.startsWith(`${target}/`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide what sits in the bar and what goes behind More. Pure.
|
|
81
|
+
*
|
|
82
|
+
* Every destination appears exactly once, in the bar or in the sheet.
|
|
83
|
+
*/
|
|
84
|
+
export function buildAppBar({
|
|
85
|
+
barItems,
|
|
86
|
+
homeHref,
|
|
87
|
+
currentPath,
|
|
88
|
+
}: {
|
|
89
|
+
barItems: readonly string[] | null | undefined;
|
|
90
|
+
homeHref: string;
|
|
91
|
+
currentPath?: string | null;
|
|
92
|
+
}): AppBarModel {
|
|
93
|
+
const chosen = normaliseBarItems(barItems);
|
|
94
|
+
const item = (key: AppBarKey, href: string): AppBarItem => ({
|
|
95
|
+
key,
|
|
96
|
+
href,
|
|
97
|
+
active: isAppBarItemActive(key, href, currentPath),
|
|
98
|
+
});
|
|
99
|
+
const bar = [item("home", homeHref), ...chosen.map((key) => item(key, APP_BAR_HREFS[key]))];
|
|
100
|
+
const more = SITE_APP_BAR_KEYS.filter((key) => !chosen.includes(key)).map((key) => item(key, APP_BAR_HREFS[key]));
|
|
101
|
+
return { bar, more, moreActive: more.some((entry) => entry.active) };
|
|
102
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { internalPath } from "../../../runtime/pages";
|
|
2
|
+
import { bookingCallWindow } from "../../media/bookingWindow";
|
|
3
|
+
import { SITE_APP_SECTION_KEYS, type SiteAppHomeSection, type SiteAppSectionKey } from "../../../data/queries/useSiteApp";
|
|
4
|
+
import type { PortalAttentionItem, PortalSectionKey, PortalSummary } from "../portal/usePortal";
|
|
5
|
+
import type { MyBooking } from "../../../data/queries/useMyBookings";
|
|
6
|
+
import type { MyTicket } from "../../../data/queries/useMyTickets";
|
|
7
|
+
import type { MyCourse } from "../../../data/queries/useMyCourses";
|
|
8
|
+
import type { MyMembershipCall } from "../../../data/queries/useMembershipCalls";
|
|
9
|
+
import type { CourseAccessData, IEvent, IPublicProduct, PublicCourse } from "../../../types/models";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The site app's home (`/i/app`): the sections the creator configured, in their
|
|
13
|
+
* order, each drawn only when it has something to show. The portal is the one
|
|
14
|
+
* exception: it is always drawn, because it is where everything a member owns
|
|
15
|
+
* lives. Everything here is pure, so the order, the visibility and the
|
|
16
|
+
* empty-skip are decided in one place a spec can read.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ---- Portal tiles -------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export type AppPortalTileKey = "projects" | "bookings" | "invoices" | "documents" | "courses" | "orders" | "tickets" | "saved" | "settings";
|
|
22
|
+
|
|
23
|
+
export interface AppPortalTile {
|
|
24
|
+
key: AppPortalTileKey;
|
|
25
|
+
href: string;
|
|
26
|
+
/** How many things in this section need the person (the tile's badge). 0 draws none. */
|
|
27
|
+
attention: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The tiles, in the order the grid draws them, each with the summary count that decides it. */
|
|
31
|
+
export const APP_PORTAL_TILES: ReadonlyArray<{ key: Exclude<AppPortalTileKey, "settings">; countKey: PortalSectionKey; href: string }> = [
|
|
32
|
+
{ key: "projects", countKey: "projects", href: internalPath("account/projects") },
|
|
33
|
+
{ key: "bookings", countKey: "bookings", href: internalPath("account/bookings") },
|
|
34
|
+
{ key: "invoices", countKey: "invoices", href: internalPath("account/invoices") },
|
|
35
|
+
{ key: "documents", countKey: "documents", href: internalPath("account/documents") },
|
|
36
|
+
{ key: "courses", countKey: "courses", href: internalPath("account/courses") },
|
|
37
|
+
{ key: "orders", countKey: "orders", href: internalPath("account/orders") },
|
|
38
|
+
{ key: "tickets", countKey: "tickets", href: internalPath("account/tickets") },
|
|
39
|
+
{ key: "saved", countKey: "saved", href: internalPath("account/saved") },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const ATTENTION_TILE: Record<PortalAttentionItem["kind"], AppPortalTileKey | null> = {
|
|
43
|
+
invoice_due: "invoices",
|
|
44
|
+
invoice_overdue: "invoices",
|
|
45
|
+
document_to_sign: "documents",
|
|
46
|
+
approval_requested: "projects",
|
|
47
|
+
membership_payment_failed: null,
|
|
48
|
+
message_unread: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The portal grid: a tile for each section the person has anything in, then
|
|
53
|
+
* Settings so a new fan's grid is never empty and a full one is exactly eight.
|
|
54
|
+
*/
|
|
55
|
+
export function buildPortalTiles(summary: PortalSummary | null | undefined): AppPortalTile[] {
|
|
56
|
+
const attention: Partial<Record<AppPortalTileKey, number>> = {};
|
|
57
|
+
for (const item of summary?.attention ?? []) {
|
|
58
|
+
const tile = ATTENTION_TILE[item.kind];
|
|
59
|
+
if (tile) attention[tile] = (attention[tile] ?? 0) + 1;
|
|
60
|
+
}
|
|
61
|
+
const tiles: AppPortalTile[] = APP_PORTAL_TILES.filter((tile) => (summary?.sections?.[tile.countKey] ?? 0) > 0).map(
|
|
62
|
+
(tile) => ({ key: tile.key, href: tile.href, attention: attention[tile.key] ?? 0 }),
|
|
63
|
+
);
|
|
64
|
+
if (tiles.length < APP_PORTAL_TILES.length) {
|
|
65
|
+
tiles.push({ key: "settings", href: internalPath("account/settings"), attention: 0 });
|
|
66
|
+
}
|
|
67
|
+
return tiles;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---- Needs you ----------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
export interface AppAttentionAction {
|
|
73
|
+
item: PortalAttentionItem;
|
|
74
|
+
/** Which verb the button says. */
|
|
75
|
+
action: "pay" | "review" | "reply" | "update";
|
|
76
|
+
href: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Where each portal attention item is acted on: the exact thing, one tap away. */
|
|
80
|
+
export function attentionAction(item: PortalAttentionItem): AppAttentionAction {
|
|
81
|
+
switch (item.kind) {
|
|
82
|
+
case "invoice_due":
|
|
83
|
+
case "invoice_overdue":
|
|
84
|
+
return { item, action: "pay", href: internalPath("invoice/$invoiceId", { invoiceId: item.id }) };
|
|
85
|
+
case "approval_requested":
|
|
86
|
+
return {
|
|
87
|
+
item,
|
|
88
|
+
action: "review",
|
|
89
|
+
href: `${internalPath("account/projects/$projectId", { projectId: item.projectId })}#approvals`,
|
|
90
|
+
};
|
|
91
|
+
case "document_to_sign":
|
|
92
|
+
return { item, action: "review", href: internalPath("documents/$token", { token: item.token }) };
|
|
93
|
+
case "message_unread":
|
|
94
|
+
return { item, action: "reply", href: internalPath("account/messages") };
|
|
95
|
+
case "membership_payment_failed":
|
|
96
|
+
return { item, action: "update", href: internalPath("communities/$slug/home", { slug: item.communitySlug }) };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- Coming up ----------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
export interface AppNextCall {
|
|
103
|
+
id: string;
|
|
104
|
+
title: string;
|
|
105
|
+
startsAt: string;
|
|
106
|
+
/** The call is open right now. */
|
|
107
|
+
joinable: boolean;
|
|
108
|
+
/** The room when joinable, else where the session is listed. */
|
|
109
|
+
href: string;
|
|
110
|
+
/** Who it is with: the audience label of a member call, or null for a booked session. */
|
|
111
|
+
context: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface AppNextTicket {
|
|
115
|
+
id: string;
|
|
116
|
+
title: string;
|
|
117
|
+
startsAt: string;
|
|
118
|
+
/** Passes on the order, at least 1. */
|
|
119
|
+
count: number;
|
|
120
|
+
href: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The next call: a member call or a booked session, whichever comes first and
|
|
125
|
+
* has not ended. An open one is joinable.
|
|
126
|
+
*/
|
|
127
|
+
export function pickNextCall(
|
|
128
|
+
calls: readonly MyMembershipCall[] | null | undefined,
|
|
129
|
+
bookings: readonly MyBooking[] | null | undefined,
|
|
130
|
+
now: Date,
|
|
131
|
+
): AppNextCall | null {
|
|
132
|
+
const candidates: AppNextCall[] = [];
|
|
133
|
+
for (const call of calls ?? []) {
|
|
134
|
+
if (Date.parse(call.endsAt) < now.getTime()) continue;
|
|
135
|
+
const joinable = call.isOpen && !!call.callPublicId;
|
|
136
|
+
candidates.push({
|
|
137
|
+
id: `call:${call.id}`,
|
|
138
|
+
title: call.title,
|
|
139
|
+
startsAt: call.startsAt,
|
|
140
|
+
joinable,
|
|
141
|
+
// The call page itself says when the room opens, so it is the link either way.
|
|
142
|
+
href: call.callPublicId ? internalPath("video-calls/$callId", { callId: call.callPublicId }) : internalPath("account"),
|
|
143
|
+
context: call.audiences?.[0]?.label ?? null,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
for (const booking of bookings ?? []) {
|
|
147
|
+
if (booking.status !== "confirmed") continue;
|
|
148
|
+
if (Date.parse(booking.sessionEndTime) < now.getTime()) continue;
|
|
149
|
+
const joinable = bookingCallWindow(booking, now).isOpen && !!booking.videoCallId;
|
|
150
|
+
candidates.push({
|
|
151
|
+
id: `booking:${booking.id}`,
|
|
152
|
+
title: booking.coachingProductTitle,
|
|
153
|
+
startsAt: booking.sessionStartTime,
|
|
154
|
+
joinable,
|
|
155
|
+
href: joinable ? internalPath("video-calls/$callId", { callId: booking.videoCallId! }) : internalPath("account/bookings"),
|
|
156
|
+
context: null,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
candidates.sort((a, b) => Date.parse(a.startsAt) - Date.parse(b.startsAt));
|
|
160
|
+
return candidates[0] ?? null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The next event the person holds a ticket for. Cancelled and refunded orders do not count. */
|
|
164
|
+
export function pickNextTicket(tickets: readonly MyTicket[] | null | undefined, now: Date): AppNextTicket | null {
|
|
165
|
+
const upcoming = (tickets ?? [])
|
|
166
|
+
.filter((ticket) => !ticket.selfCancelledAt && ticket.status !== "cancelled" && ticket.status !== "refunded")
|
|
167
|
+
.filter((ticket) => Date.parse(ticket.eventEndDateTime ?? ticket.eventDateTime) >= now.getTime())
|
|
168
|
+
.sort((a, b) => Date.parse(a.eventDateTime) - Date.parse(b.eventDateTime));
|
|
169
|
+
const next = upcoming[0];
|
|
170
|
+
if (!next) return null;
|
|
171
|
+
const count = (next.items ?? []).reduce((sum, item) => sum + (Number(item.quantity) || 0), 0);
|
|
172
|
+
return {
|
|
173
|
+
id: next.id,
|
|
174
|
+
title: next.eventTitle,
|
|
175
|
+
startsAt: next.eventDateTime,
|
|
176
|
+
count: Math.max(1, count),
|
|
177
|
+
href: internalPath("account/tickets"),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- Continue learning -------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/** The course to carry on with: one started and not finished, else the newest not finished. */
|
|
184
|
+
export function pickLearningCourse(courses: readonly MyCourse[] | null | undefined): MyCourse | null {
|
|
185
|
+
const open = (courses ?? []).filter((course) => !course.completedAt);
|
|
186
|
+
return open.find((course) => !!course.currentLessonId) ?? open[0] ?? null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface AppCourseProgress {
|
|
190
|
+
done: number;
|
|
191
|
+
total: number;
|
|
192
|
+
/** 0 to 1. */
|
|
193
|
+
ratio: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Lessons done out of lessons released, from the classroom read. Null when there are no lessons. */
|
|
197
|
+
export function courseProgress(access: CourseAccessData | null | undefined): AppCourseProgress | null {
|
|
198
|
+
if (!access) return null;
|
|
199
|
+
const lessons = (access.course?.modules ?? []).flatMap((module) => module.lessons ?? []);
|
|
200
|
+
if (lessons.length === 0) return null;
|
|
201
|
+
const completed = new Set((access.progress ?? []).filter((row) => row.isCompleted).map((row) => row.lessonId));
|
|
202
|
+
const done = lessons.filter((lesson) => completed.has(lesson.id) || lesson.progress?.isCompleted).length;
|
|
203
|
+
return { done, total: lessons.length, ratio: done / lessons.length };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface AppLearningCourse {
|
|
207
|
+
accessId: string;
|
|
208
|
+
title: string;
|
|
209
|
+
href: string;
|
|
210
|
+
progress: AppCourseProgress | null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- Communities ---------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export interface AppCommunityCard {
|
|
216
|
+
id: string;
|
|
217
|
+
name: string;
|
|
218
|
+
slug: string;
|
|
219
|
+
logoUrl: string | null;
|
|
220
|
+
coverUrl: string | null;
|
|
221
|
+
tierName: string | null;
|
|
222
|
+
memberCount: number | null;
|
|
223
|
+
href: string;
|
|
224
|
+
/** Explore only: how a visitor gets in, which names the button. */
|
|
225
|
+
joinMode?: "open" | "application" | "invite";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- The sections ---------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
/** Everything the home can show, already fetched and shaped. Missing means none. */
|
|
231
|
+
export interface AppHomeData {
|
|
232
|
+
attention?: readonly PortalAttentionItem[];
|
|
233
|
+
portalTiles?: readonly AppPortalTile[];
|
|
234
|
+
communities?: readonly AppCommunityCard[];
|
|
235
|
+
nextCall?: AppNextCall | null;
|
|
236
|
+
nextTicket?: AppNextTicket | null;
|
|
237
|
+
learning?: AppLearningCourse | null;
|
|
238
|
+
explore?: readonly AppCommunityCard[];
|
|
239
|
+
events?: readonly IEvent[];
|
|
240
|
+
courses?: readonly PublicCourse[];
|
|
241
|
+
products?: readonly IPublicProduct[];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export type AppHomeSectionView =
|
|
245
|
+
| { kind: "needs_you"; key: "needs_you"; actions: AppAttentionAction[] }
|
|
246
|
+
| { kind: "portal"; key: "portal"; tiles: AppPortalTile[] }
|
|
247
|
+
| { kind: "communities"; key: "communities"; communities: AppCommunityCard[] }
|
|
248
|
+
| { kind: "coming_up"; key: "coming_up"; call: AppNextCall | null; ticket: AppNextTicket | null }
|
|
249
|
+
| { kind: "learning"; key: "learning"; course: AppLearningCourse }
|
|
250
|
+
| { kind: "explore_communities"; key: "explore_communities"; communities: AppCommunityCard[] }
|
|
251
|
+
| { kind: "events"; key: "events"; events: IEvent[] }
|
|
252
|
+
| { kind: "courses"; key: "courses"; courses: PublicCourse[] }
|
|
253
|
+
| { kind: "shop"; key: "shop"; products: IPublicProduct[] }
|
|
254
|
+
| { kind: "link"; key: string; label: string; href: string };
|
|
255
|
+
|
|
256
|
+
/** How many items a list section shows before "See all". */
|
|
257
|
+
export const APP_HOME_LIST_LIMIT = { explore: 3, events: 4, courses: 3, products: 4 } as const;
|
|
258
|
+
|
|
259
|
+
const KNOWN = new Set<string>(SITE_APP_SECTION_KEYS);
|
|
260
|
+
|
|
261
|
+
/** A link section's address: a site path, or an absolute http(s) URL. Anything else is not drawn. */
|
|
262
|
+
export function isSafeLinkHref(href: string | null | undefined): boolean {
|
|
263
|
+
const value = href?.trim() ?? "";
|
|
264
|
+
if (value.startsWith("/")) return !value.startsWith("//") && !value.startsWith("/\\");
|
|
265
|
+
return /^https?:\/\/[^\s]+$/i.test(value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The sections to draw, in the configured order. Pure.
|
|
270
|
+
*
|
|
271
|
+
* Hidden entries, unknown keys, repeats and broken links are skipped. A
|
|
272
|
+
* section with nothing to show is skipped too, except the portal.
|
|
273
|
+
*/
|
|
274
|
+
export function buildAppHomeSections(
|
|
275
|
+
config: readonly SiteAppHomeSection[] | null | undefined,
|
|
276
|
+
data: AppHomeData,
|
|
277
|
+
): AppHomeSectionView[] {
|
|
278
|
+
const out: AppHomeSectionView[] = [];
|
|
279
|
+
const seen = new Set<string>();
|
|
280
|
+
for (const entry of config ?? []) {
|
|
281
|
+
if (!entry || entry.visible === false) continue;
|
|
282
|
+
if (entry.type === "link") {
|
|
283
|
+
const label = entry.label?.trim();
|
|
284
|
+
if (!label || !isSafeLinkHref(entry.href) || seen.has(`link:${entry.id}`)) continue;
|
|
285
|
+
seen.add(`link:${entry.id}`);
|
|
286
|
+
out.push({ kind: "link", key: entry.id, label, href: entry.href.trim() });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (entry.type !== "builtin" || !KNOWN.has(entry.key) || seen.has(entry.key)) continue;
|
|
290
|
+
seen.add(entry.key);
|
|
291
|
+
const view = builtinView(entry.key, data);
|
|
292
|
+
if (view) out.push(view);
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function builtinView(key: SiteAppSectionKey, data: AppHomeData): AppHomeSectionView | null {
|
|
298
|
+
switch (key) {
|
|
299
|
+
case "needs_you": {
|
|
300
|
+
const actions = (data.attention ?? []).map(attentionAction);
|
|
301
|
+
return actions.length ? { kind: key, key, actions } : null;
|
|
302
|
+
}
|
|
303
|
+
case "portal":
|
|
304
|
+
// Always drawn: it is where everything the member owns lives.
|
|
305
|
+
return { kind: key, key, tiles: [...(data.portalTiles ?? buildPortalTiles(null))] };
|
|
306
|
+
case "communities":
|
|
307
|
+
return data.communities?.length ? { kind: key, key, communities: [...data.communities] } : null;
|
|
308
|
+
case "coming_up":
|
|
309
|
+
return data.nextCall || data.nextTicket
|
|
310
|
+
? { kind: key, key, call: data.nextCall ?? null, ticket: data.nextTicket ?? null }
|
|
311
|
+
: null;
|
|
312
|
+
case "learning":
|
|
313
|
+
return data.learning ? { kind: key, key, course: data.learning } : null;
|
|
314
|
+
case "explore_communities":
|
|
315
|
+
return data.explore?.length
|
|
316
|
+
? { kind: key, key, communities: data.explore.slice(0, APP_HOME_LIST_LIMIT.explore) }
|
|
317
|
+
: null;
|
|
318
|
+
case "events":
|
|
319
|
+
return data.events?.length ? { kind: key, key, events: data.events.slice(0, APP_HOME_LIST_LIMIT.events) } : null;
|
|
320
|
+
case "courses":
|
|
321
|
+
return data.courses?.length
|
|
322
|
+
? { kind: key, key, courses: data.courses.slice(0, APP_HOME_LIST_LIMIT.courses) }
|
|
323
|
+
: null;
|
|
324
|
+
case "shop":
|
|
325
|
+
return data.products?.length
|
|
326
|
+
? { kind: key, key, products: data.products.slice(0, APP_HOME_LIST_LIMIT.products) }
|
|
327
|
+
: null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Events still to come, soonest first. Cancelled, completed and archived ones are left out. */
|
|
332
|
+
export function upcomingEvents(events: readonly IEvent[] | null | undefined, now: Date): IEvent[] {
|
|
333
|
+
return (events ?? [])
|
|
334
|
+
.filter((event) => event.status !== "cancelled" && event.status !== "completed" && event.status !== "draft")
|
|
335
|
+
.filter((event) => !event.cancelledAt && !event.archivedAt)
|
|
336
|
+
.filter((event) => Date.parse(event.endDateTime ?? event.dateTime) >= now.getTime())
|
|
337
|
+
.sort((a, b) => Date.parse(a.dateTime) - Date.parse(b.dateTime));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The time-of-day part of the greeting. */
|
|
341
|
+
export function greetingPart(now: Date): "morning" | "afternoon" | "evening" {
|
|
342
|
+
const hour = now.getHours();
|
|
343
|
+
return hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening";
|
|
344
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When the site app asks to be installed, and when it offers notifications.
|
|
3
|
+
* Contract: docs/proposals/site-app-and-website-menu.md, section 8.
|
|
4
|
+
*
|
|
5
|
+
* Install is asked at a moment the person just got something worth coming back
|
|
6
|
+
* to (they joined, bought, booked, accepted an invite), worded for that moment.
|
|
7
|
+
* Notifications are offered once, on the first launch of the installed app.
|
|
8
|
+
*
|
|
9
|
+
* The decisions are pure functions; the storage around them is guarded,
|
|
10
|
+
* because private mode and blocked storage throw on access.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { shareRuntimeValue } from "../../../runtime/registry";
|
|
14
|
+
|
|
15
|
+
// ---- Install requests --------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export const APP_INSTALL_CONTEXTS = ["joined_community", "purchase", "booking", "invite_accepted"] as const;
|
|
18
|
+
|
|
19
|
+
export type AppInstallContext = (typeof APP_INSTALL_CONTEXTS)[number];
|
|
20
|
+
|
|
21
|
+
export interface AppInstallRequest {
|
|
22
|
+
context: AppInstallContext;
|
|
23
|
+
/** Epoch ms. */
|
|
24
|
+
at: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Remembered "Not now": the prompt stays away this long. */
|
|
28
|
+
export const APP_INSTALL_SNOOZE_MS = 14 * 24 * 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* How long a request waits for a page that can show it. A success usually
|
|
32
|
+
* navigates (into the community, back from a payment), so the request rides
|
|
33
|
+
* the session to the next page; a stale one is not shown out of nowhere.
|
|
34
|
+
*/
|
|
35
|
+
export const APP_INSTALL_REQUEST_TTL_MS = 30 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export const APP_INSTALL_DISMISSED_KEY = "forge-app-install-dismissed-at";
|
|
38
|
+
export const APP_INSTALL_PENDING_KEY = "forge-app-install-pending";
|
|
39
|
+
export const APP_PUSH_ANSWER_KEY = "forge-app-push-answer";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The waiting request and who is listening, shared through the runtime
|
|
43
|
+
* registry: the success that asks (a hosted page, or the site's own checkout)
|
|
44
|
+
* and the prompt that shows it may sit in different copies of Forge.
|
|
45
|
+
*/
|
|
46
|
+
const shared = () =>
|
|
47
|
+
shareRuntimeValue<{ pending: AppInstallRequest | null; listeners: Set<() => void> }>("state:app-install-request", () => ({
|
|
48
|
+
pending: null,
|
|
49
|
+
listeners: new Set(),
|
|
50
|
+
}));
|
|
51
|
+
|
|
52
|
+
function isContext(value: unknown): value is AppInstallContext {
|
|
53
|
+
return typeof value === "string" && (APP_INSTALL_CONTEXTS as readonly string[]).includes(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readStorage(kind: "local" | "session", key: string): string | null {
|
|
57
|
+
try {
|
|
58
|
+
const store = kind === "local" ? window.localStorage : window.sessionStorage;
|
|
59
|
+
return store.getItem(key);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function writeStorage(kind: "local" | "session", key: string, value: string | null): void {
|
|
66
|
+
try {
|
|
67
|
+
const store = kind === "local" ? window.localStorage : window.sessionStorage;
|
|
68
|
+
if (value === null) store.removeItem(key);
|
|
69
|
+
else store.setItem(key, value);
|
|
70
|
+
} catch {
|
|
71
|
+
/* private mode or blocked storage: the prompt is best effort */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ask the site app to offer itself, worded for `context`. Call it from a
|
|
77
|
+
* success: a community joined, a purchase confirmed, a booking made, an invite
|
|
78
|
+
* accepted. Safe to call anywhere, any number of times; the app decides whether
|
|
79
|
+
* a prompt is shown (never when installed, never when the creator turned it
|
|
80
|
+
* off, never within 14 days of "Not now").
|
|
81
|
+
*/
|
|
82
|
+
export function requestAppInstall(context: AppInstallContext): void {
|
|
83
|
+
if (typeof window === "undefined" || !isContext(context)) return;
|
|
84
|
+
const request: AppInstallRequest = { context, at: Date.now() };
|
|
85
|
+
shared().pending = request;
|
|
86
|
+
writeStorage("session", APP_INSTALL_PENDING_KEY, JSON.stringify(request));
|
|
87
|
+
shared().listeners.forEach((listener) => listener());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The request waiting to be shown, if one is fresh. */
|
|
91
|
+
export function readPendingAppInstall(now: number = Date.now()): AppInstallRequest | null {
|
|
92
|
+
let request = shared().pending;
|
|
93
|
+
if (!request && typeof window !== "undefined") {
|
|
94
|
+
const raw = readStorage("session", APP_INSTALL_PENDING_KEY);
|
|
95
|
+
if (raw) {
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(raw) as Partial<AppInstallRequest>;
|
|
98
|
+
if (isContext(parsed.context) && typeof parsed.at === "number") request = { context: parsed.context, at: parsed.at };
|
|
99
|
+
} catch {
|
|
100
|
+
request = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!request || now - request.at > APP_INSTALL_REQUEST_TTL_MS || request.at > now + 60_000) return null;
|
|
105
|
+
return request;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The request was shown or turned down: forget it. */
|
|
109
|
+
export function clearPendingAppInstall(): void {
|
|
110
|
+
shared().pending = null;
|
|
111
|
+
if (typeof window !== "undefined") writeStorage("session", APP_INSTALL_PENDING_KEY, null);
|
|
112
|
+
shared().listeners.forEach((listener) => listener());
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Be told when a request arrives or is cleared. */
|
|
116
|
+
export function subscribeAppInstallRequests(listener: () => void): () => void {
|
|
117
|
+
shared().listeners.add(listener);
|
|
118
|
+
return () => {
|
|
119
|
+
shared().listeners.delete(listener);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** When "Not now" was last pressed, or null. */
|
|
124
|
+
export function readInstallDismissedAt(): number | null {
|
|
125
|
+
if (typeof window === "undefined") return null;
|
|
126
|
+
const raw = readStorage("local", APP_INSTALL_DISMISSED_KEY);
|
|
127
|
+
const value = raw ? Number(raw) : NaN;
|
|
128
|
+
return Number.isFinite(value) ? value : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function rememberInstallDismissed(now: number = Date.now()): void {
|
|
132
|
+
if (typeof window !== "undefined") writeStorage("local", APP_INSTALL_DISMISSED_KEY, String(now));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type AppInstallMode = "native" | "ios";
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether to offer the install, and how. Pure.
|
|
139
|
+
*
|
|
140
|
+
* - `installPrompt` is the website's setting; anything but `true` (off, or not
|
|
141
|
+
* known yet) is no.
|
|
142
|
+
* - Running installed is no: there is nothing to install.
|
|
143
|
+
* - "Not now" within the last 14 days is no.
|
|
144
|
+
* - A held native prompt (Android, desktop Chromium) is `native`; iPhone and
|
|
145
|
+
* iPad Safari, which have none, get the two `ios` steps. Anything else is no.
|
|
146
|
+
*/
|
|
147
|
+
export function decideAppInstall(input: {
|
|
148
|
+
installPrompt: boolean | null | undefined;
|
|
149
|
+
isStandalone: boolean;
|
|
150
|
+
isIOS: boolean;
|
|
151
|
+
hasNativePrompt: boolean;
|
|
152
|
+
dismissedAt: number | null;
|
|
153
|
+
now: number;
|
|
154
|
+
}): AppInstallMode | null {
|
|
155
|
+
if (input.installPrompt !== true) return null;
|
|
156
|
+
if (input.isStandalone) return null;
|
|
157
|
+
if (input.dismissedAt !== null && input.now - input.dismissedAt < APP_INSTALL_SNOOZE_MS) return null;
|
|
158
|
+
if (input.hasNativePrompt) return "native";
|
|
159
|
+
if (input.isIOS) return "ios";
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- Notifications on first launch ------------------------------------------------
|
|
164
|
+
|
|
165
|
+
export type AppPushAnswer = "accepted" | "declined";
|
|
166
|
+
|
|
167
|
+
export function readPushAnswer(): AppPushAnswer | null {
|
|
168
|
+
if (typeof window === "undefined") return null;
|
|
169
|
+
const raw = readStorage("local", APP_PUSH_ANSWER_KEY);
|
|
170
|
+
return raw === "accepted" || raw === "declined" ? raw : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function rememberPushAnswer(answer: AppPushAnswer): void {
|
|
174
|
+
if (typeof window !== "undefined") writeStorage("local", APP_PUSH_ANSWER_KEY, answer);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether to offer notifications now. Pure.
|
|
179
|
+
*
|
|
180
|
+
* Only in the installed app (standalone), only when the website's `pushPrompt`
|
|
181
|
+
* is on, only to someone signed in (a subscription belongs to an account), only
|
|
182
|
+
* where the browser can do push, only while the browser has not been asked
|
|
183
|
+
* (`default`: granted needs no asking, denied cannot be asked again), and only
|
|
184
|
+
* once: any earlier answer here ends it.
|
|
185
|
+
*/
|
|
186
|
+
export function decideAppPush(input: {
|
|
187
|
+
pushPrompt: boolean | null | undefined;
|
|
188
|
+
isStandalone: boolean;
|
|
189
|
+
isSignedIn: boolean;
|
|
190
|
+
isSupported: boolean;
|
|
191
|
+
permission: NotificationPermission | null;
|
|
192
|
+
answer: AppPushAnswer | null;
|
|
193
|
+
}): boolean {
|
|
194
|
+
return (
|
|
195
|
+
input.pushPrompt === true &&
|
|
196
|
+
input.isStandalone &&
|
|
197
|
+
input.isSignedIn &&
|
|
198
|
+
input.isSupported &&
|
|
199
|
+
input.permission === "default" &&
|
|
200
|
+
input.answer === null
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The push categories offered on first launch, each the notification types it
|
|
206
|
+
* switches (`<type>.push` in the member's preference matrix). A category is
|
|
207
|
+
* offered only when the server's matrix knows at least one of its types.
|
|
208
|
+
*/
|
|
209
|
+
export const APP_PUSH_CATEGORIES = [
|
|
210
|
+
{ key: "messages", types: ["dm", "mention", "reply", "chat_message", "client_message"] },
|
|
211
|
+
{ key: "community", types: ["new_post", "new_community_post", "reaction", "mod"] },
|
|
212
|
+
{ key: "events", types: ["event_reminder", "stream_live"] },
|
|
213
|
+
] as const;
|
|
214
|
+
|
|
215
|
+
export type AppPushCategoryKey = (typeof APP_PUSH_CATEGORIES)[number]["key"];
|
|
216
|
+
|
|
217
|
+
/** The preference patch for the chosen categories: every known type of each offered category, on or off. */
|
|
218
|
+
export function pushPreferencePatch(
|
|
219
|
+
chosen: Readonly<Record<AppPushCategoryKey, boolean>>,
|
|
220
|
+
knownTypes: ReadonlySet<string>,
|
|
221
|
+
): Record<string, boolean> {
|
|
222
|
+
const patch: Record<string, boolean> = {};
|
|
223
|
+
for (const category of APP_PUSH_CATEGORIES) {
|
|
224
|
+
for (const type of category.types) {
|
|
225
|
+
if (knownTypes.has(type)) patch[`${type}.push`] = chosen[category.key];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return patch;
|
|
229
|
+
}
|