@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
|
@@ -51,7 +51,19 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
51
51
|
};
|
|
52
52
|
|
|
53
53
|
return (
|
|
54
|
-
|
|
54
|
+
// Lifted above any fixed bottom bar a page draws (the client portal's on a
|
|
55
|
+
// phone), which publishes its height as `--forge-bottom-bar-height`.
|
|
56
|
+
// Otherwise the bubble sits on top of that bar's last button. A page with
|
|
57
|
+
// its own message box pinned above the bar (a community chat on a phone)
|
|
58
|
+
// publishes `--forge-floating-lift` so the bubble clears its Send button too.
|
|
59
|
+
<div
|
|
60
|
+
style={{
|
|
61
|
+
position: "fixed",
|
|
62
|
+
bottom: "calc(20px + var(--forge-bottom-bar-height, 0px) + var(--forge-floating-lift, 0px))",
|
|
63
|
+
zIndex,
|
|
64
|
+
...side,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
55
67
|
{open && (
|
|
56
68
|
<div
|
|
57
69
|
style={{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { Fragment, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { Lock } from "lucide-react";
|
|
3
3
|
import type { ChatChannel, ChatMessage } from "../../data/queries/useChat";
|
|
4
4
|
import { useForgeT, type ForgeT } from "../../i18n";
|
|
@@ -10,6 +10,14 @@ import { MessageBubble } from "./chat/MessageBubble";
|
|
|
10
10
|
import { ChatComposer } from "./chat/ChatComposer";
|
|
11
11
|
import { ChatThreadPanel } from "./chat/ChatThreadPanel";
|
|
12
12
|
import { NewDmDialog } from "./chat/NewDmDialog";
|
|
13
|
+
import { isCommunityAgentDm, useCommunityAgent } from "../../data/queries/useCommunityAgent";
|
|
14
|
+
import { useCommunityAgentAnswerRatings } from "../headless/community/useCommunityAgent";
|
|
15
|
+
import {
|
|
16
|
+
CommunityAgentAvatar,
|
|
17
|
+
CommunityAgentName,
|
|
18
|
+
CommunityAgentThinking,
|
|
19
|
+
type CommunityAgentIdentity,
|
|
20
|
+
} from "./community/CommunityAgent";
|
|
13
21
|
|
|
14
22
|
export interface ChatRoomProps {}
|
|
15
23
|
|
|
@@ -29,8 +37,22 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
29
37
|
const t = useForgeT();
|
|
30
38
|
const theme = useForgeTheme();
|
|
31
39
|
const room = useChatRoom();
|
|
40
|
+
// The community AI: its DM entry point, and its name and avatar on that DM.
|
|
41
|
+
const { agent } = useCommunityAgent();
|
|
32
42
|
const [showSidebar, setShowSidebar] = useState(true);
|
|
33
43
|
const [showNewDm, setShowNewDm] = useState(false);
|
|
44
|
+
const [agentDmFailed, setAgentDmFailed] = useState(false);
|
|
45
|
+
const hasAgentDm = room.channels.some((c) => isCommunityAgentDm(c));
|
|
46
|
+
|
|
47
|
+
const openAgentDm = async () => {
|
|
48
|
+
setAgentDmFailed(false);
|
|
49
|
+
try {
|
|
50
|
+
await room.startAgentDm();
|
|
51
|
+
setShowSidebar(false);
|
|
52
|
+
} catch {
|
|
53
|
+
setAgentDmFailed(true);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
34
56
|
|
|
35
57
|
const border = `1px solid ${theme.colors.text}1a`;
|
|
36
58
|
|
|
@@ -69,10 +91,19 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
69
91
|
fontWeight: active ? 700 : 500,
|
|
70
92
|
}}
|
|
71
93
|
>
|
|
72
|
-
{
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
94
|
+
{isCommunityAgentDm(c) ? (
|
|
95
|
+
<>
|
|
96
|
+
<CommunityAgentAvatar agent={agentIdentity(c, agent)} size={20} />
|
|
97
|
+
<CommunityAgentName agent={agentIdentity(c, agent)} fontWeight={active ? 700 : 500} />
|
|
98
|
+
</>
|
|
99
|
+
) : (
|
|
100
|
+
<>
|
|
101
|
+
{icon}
|
|
102
|
+
<span style={{ whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
|
103
|
+
{icon ? (c.name ?? t("forge.chat_room.channel_fallback")) : channelLabel(c, t)}
|
|
104
|
+
</span>
|
|
105
|
+
</>
|
|
106
|
+
)}
|
|
76
107
|
</span>
|
|
77
108
|
{unread > 0 && (
|
|
78
109
|
<span
|
|
@@ -116,7 +147,9 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
116
147
|
<div
|
|
117
148
|
style={{
|
|
118
149
|
display: "flex",
|
|
119
|
-
|
|
150
|
+
// Inside a member home on a phone the bottom bar takes the last strip of
|
|
151
|
+
// the screen, so the composer ends above it rather than behind it.
|
|
152
|
+
height: "calc(100dvh - 200px - var(--forge-bottom-bar-height, 0px) - env(safe-area-inset-bottom))",
|
|
120
153
|
border,
|
|
121
154
|
borderRadius: theme.cornerRadius,
|
|
122
155
|
overflow: "hidden",
|
|
@@ -168,7 +201,42 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
168
201
|
t("forge.chat_room.group_dms"),
|
|
169
202
|
room.channels.filter((c) => c.type === "dm"),
|
|
170
203
|
)}
|
|
171
|
-
{
|
|
204
|
+
{agent?.dmEnabled && !hasAgentDm && (
|
|
205
|
+
<div style={{ padding: "8px 16px" }}>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
onClick={openAgentDm}
|
|
209
|
+
disabled={room.isStartingAgentDm}
|
|
210
|
+
data-testid="chat-agent-dm-open"
|
|
211
|
+
style={{
|
|
212
|
+
display: "flex",
|
|
213
|
+
alignItems: "center",
|
|
214
|
+
gap: 8,
|
|
215
|
+
width: "100%",
|
|
216
|
+
background: "none",
|
|
217
|
+
border,
|
|
218
|
+
borderRadius: theme.cornerRadius,
|
|
219
|
+
padding: "8px 10px",
|
|
220
|
+
cursor: room.isStartingAgentDm ? "wait" : "pointer",
|
|
221
|
+
color: theme.colors.text,
|
|
222
|
+
fontSize: 13,
|
|
223
|
+
fontWeight: 600,
|
|
224
|
+
textAlign: "left",
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<CommunityAgentAvatar agent={agent} size={22} />
|
|
228
|
+
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
229
|
+
{t("forge.chat_room.message_agent", { agent_name: agent.name })}
|
|
230
|
+
</span>
|
|
231
|
+
</button>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
{agentDmFailed && (
|
|
235
|
+
<div role="alert" style={{ padding: "0 16px 8px", fontSize: 12 }}>
|
|
236
|
+
{t("forge.chat_room.agent_dm_failed")}
|
|
237
|
+
</div>
|
|
238
|
+
)}
|
|
239
|
+
{room.channels.length === 0 && !agent?.dmEnabled && (
|
|
172
240
|
<div style={{ padding: 16, opacity: 0.5, fontSize: 13 }}>{t("forge.chat_room.empty_title")}</div>
|
|
173
241
|
)}
|
|
174
242
|
</aside>
|
|
@@ -176,7 +244,7 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
176
244
|
{/* Message panel */}
|
|
177
245
|
<section className={`${showSidebar ? "fg:hidden" : "fg:flex"} fg:md:flex fg:flex-1 fg:flex-col fg:min-w-0`}>
|
|
178
246
|
{room.selectedChannel ? (
|
|
179
|
-
<MessagePanel room={room} onBack={() => setShowSidebar(true)} border={border} />
|
|
247
|
+
<MessagePanel room={room} onBack={() => setShowSidebar(true)} border={border} agent={agent} />
|
|
180
248
|
) : (
|
|
181
249
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", opacity: 0.5 }}>
|
|
182
250
|
{t("forge.chat_room.select_channel")}
|
|
@@ -193,6 +261,8 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
193
261
|
onClose={room.closeThread}
|
|
194
262
|
onMarkAsRead={room.markAsRead}
|
|
195
263
|
border={border}
|
|
264
|
+
agentThinking={room.isAgentThinking(room.activeThreadParent.channelId, room.activeThreadParent.id)}
|
|
265
|
+
agentName={agent?.name}
|
|
196
266
|
/>
|
|
197
267
|
)}
|
|
198
268
|
|
|
@@ -212,14 +282,24 @@ export function ChatRoom(_props: ChatRoomProps) {
|
|
|
212
282
|
);
|
|
213
283
|
}
|
|
214
284
|
|
|
285
|
+
/** The agent as drawn on its DM: the live agent when known, else the channel's own name. */
|
|
286
|
+
function agentIdentity(
|
|
287
|
+
channel: ChatChannel,
|
|
288
|
+
agent: { name: string; avatarUrl: string | null } | null,
|
|
289
|
+
): CommunityAgentIdentity {
|
|
290
|
+
return { name: agent?.name ?? channel.name ?? "", avatarUrl: agent?.avatarUrl ?? null };
|
|
291
|
+
}
|
|
292
|
+
|
|
215
293
|
function MessagePanel({
|
|
216
294
|
room,
|
|
217
295
|
onBack,
|
|
218
296
|
border,
|
|
297
|
+
agent,
|
|
219
298
|
}: {
|
|
220
299
|
room: ReturnType<typeof useChatRoom>;
|
|
221
300
|
onBack: () => void;
|
|
222
301
|
border: string;
|
|
302
|
+
agent: { name: string; avatarUrl: string | null } | null;
|
|
223
303
|
}) {
|
|
224
304
|
const t = useForgeT();
|
|
225
305
|
const theme = useForgeTheme();
|
|
@@ -228,13 +308,20 @@ function MessagePanel({
|
|
|
228
308
|
// Mark visible messages as read when they change.
|
|
229
309
|
useEffect(() => {
|
|
230
310
|
const unreadIds = room.messages
|
|
231
|
-
.filter((m: ChatMessage) => m.senderAccountId !== room.currentAccountId)
|
|
311
|
+
.filter((m: ChatMessage) => !m.senderAccountId || m.senderAccountId !== room.currentAccountId)
|
|
232
312
|
.map((m) => m.id);
|
|
233
313
|
if (unreadIds.length) room.markAsRead(unreadIds);
|
|
234
314
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
235
315
|
}, [room.messages.length, room.selectedChannel?.id]);
|
|
236
316
|
|
|
237
317
|
const title = room.selectedChannel ? channelLabel(room.selectedChannel, t) : t("forge.chat_room.channel_fallback");
|
|
318
|
+
const inAgentDm = isCommunityAgentDm(room.selectedChannel);
|
|
319
|
+
const channelId = room.selectedChannel?.id;
|
|
320
|
+
// The viewer's own ratings on the AI answers in this channel (its DM answers, mostly).
|
|
321
|
+
const ratings = useCommunityAgentAnswerRatings(
|
|
322
|
+
"chat_message",
|
|
323
|
+
room.messages.filter((m) => m.senderAgentId).map((m) => m.id),
|
|
324
|
+
);
|
|
238
325
|
|
|
239
326
|
return (
|
|
240
327
|
<>
|
|
@@ -245,7 +332,14 @@ function MessagePanel({
|
|
|
245
332
|
>
|
|
246
333
|
←
|
|
247
334
|
</button>
|
|
248
|
-
|
|
335
|
+
{inAgentDm && room.selectedChannel ? (
|
|
336
|
+
<span style={{ display: "inline-flex", alignItems: "center", gap: 8, minWidth: 0 }}>
|
|
337
|
+
<CommunityAgentAvatar agent={agentIdentity(room.selectedChannel, agent)} size={24} />
|
|
338
|
+
<CommunityAgentName agent={agentIdentity(room.selectedChannel, agent)} fontWeight={700} />
|
|
339
|
+
</span>
|
|
340
|
+
) : (
|
|
341
|
+
<strong>{title}</strong>
|
|
342
|
+
)}
|
|
249
343
|
</header>
|
|
250
344
|
|
|
251
345
|
{room.pinnedMessages.length > 0 && (
|
|
@@ -255,13 +349,29 @@ function MessagePanel({
|
|
|
255
349
|
)}
|
|
256
350
|
|
|
257
351
|
<div ref={listRef} style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column-reverse", gap: 10 }}>
|
|
352
|
+
{/* The list runs bottom-up, so the DM's thinking line comes first. */}
|
|
353
|
+
{inAgentDm && room.isAgentThinking(channelId, null) && (
|
|
354
|
+
<div style={{ alignSelf: "flex-start" }}>
|
|
355
|
+
<CommunityAgentThinking name={agent?.name ?? room.selectedChannel?.name} />
|
|
356
|
+
</div>
|
|
357
|
+
)}
|
|
258
358
|
{room.messages.map((m: ChatMessage) => (
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
359
|
+
<Fragment key={m.id}>
|
|
360
|
+
{/* Answering in this message's thread: shown under the message. */}
|
|
361
|
+
{!inAgentDm && room.isAgentThinking(channelId, m.id) && (
|
|
362
|
+
<div style={{ alignSelf: "flex-start" }}>
|
|
363
|
+
<CommunityAgentThinking name={agent?.name} />
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
<MessageBubble
|
|
367
|
+
message={m}
|
|
368
|
+
mine={!!m.senderAccountId && m.senderAccountId === room.currentAccountId}
|
|
369
|
+
onOpenThread={room.openThread}
|
|
370
|
+
showThreadControls={!inAgentDm}
|
|
371
|
+
showAgentFeedback={!!m.senderAgentId && ratings.canRate(m.id)}
|
|
372
|
+
savedAgentRating={ratings.ratingOf(m.id)}
|
|
373
|
+
/>
|
|
374
|
+
</Fragment>
|
|
265
375
|
))}
|
|
266
376
|
{room.hasMore && (
|
|
267
377
|
<button
|
|
@@ -18,6 +18,7 @@ import { Loading } from "./Loading";
|
|
|
18
18
|
import { CartLineOptions } from "./CartLineOptions";
|
|
19
19
|
import { HoldNotice } from "./HoldNotice";
|
|
20
20
|
import { PaystackPayButton } from "./PaystackPayButton";
|
|
21
|
+
import { useMetaInitiateCheckout } from "../analytics/useMetaEvents";
|
|
21
22
|
|
|
22
23
|
export interface CheckoutProps {
|
|
23
24
|
/** Path used to build the post-payment return URL. Defaults to `/checkout/finalise`. */
|
|
@@ -76,6 +77,13 @@ export function Checkout({
|
|
|
76
77
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
77
78
|
|
|
78
79
|
const c = useCheckout({ finalisePath });
|
|
80
|
+
// Meta InitiateCheckout, once per mount with a non-empty cart.
|
|
81
|
+
useMetaInitiateCheckout({
|
|
82
|
+
ready: c.isCartReady,
|
|
83
|
+
cartItems: c.cartItems,
|
|
84
|
+
ticketItems: c.ticketItems,
|
|
85
|
+
subTotal: c.subTotal,
|
|
86
|
+
});
|
|
79
87
|
const taxSummary = summarizeTaxQuote(c.taxQuote, formatCurrency);
|
|
80
88
|
|
|
81
89
|
const go = (path: string) => {
|
|
@@ -22,6 +22,8 @@ import { usePublicAuth } from "../../contexts/PublicAuthContext";
|
|
|
22
22
|
import { OrderStatus, ProductDeliveryType, type IPublicOrder } from "../../types/models";
|
|
23
23
|
import { CartLineOptions } from "./CartLineOptions";
|
|
24
24
|
import { useForgeT } from "../../i18n";
|
|
25
|
+
import { useMetaPurchase } from "../analytics/useMetaEvents";
|
|
26
|
+
import { useAppInstallRequest } from "../headless/siteApp/useAppInstallRequest";
|
|
25
27
|
|
|
26
28
|
export interface CheckoutConfirmationProps {
|
|
27
29
|
orderId?: string;
|
|
@@ -155,6 +157,12 @@ function OrderConfirmation({
|
|
|
155
157
|
return () => clearTimeout(t);
|
|
156
158
|
}, [isPending]);
|
|
157
159
|
|
|
160
|
+
// The browser copy of the server's Purchase: same id (the order id), value
|
|
161
|
+
// and currency, so Meta keeps one.
|
|
162
|
+
useMetaPurchase({ recordId: order?.id, paid: isPaid, value: order?.totalAmount, currency: order?.currency });
|
|
163
|
+
// A confirmed purchase is a moment the site app offers itself.
|
|
164
|
+
useAppInstallRequest(isPaid, "purchase");
|
|
165
|
+
|
|
158
166
|
// Clear the cart once the order is confirmed paid.
|
|
159
167
|
useEffect(() => {
|
|
160
168
|
if (isPaid && cartItems.length > 0) clearCart();
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
} from "./Confirmation";
|
|
16
16
|
import { useForgeT } from "../../i18n";
|
|
17
17
|
import { useCoachingBookingFinalize } from "../../data/queries/useFinalize";
|
|
18
|
+
import { useMetaPurchase } from "../analytics/useMetaEvents";
|
|
19
|
+
import { useAppInstallRequest } from "../headless/siteApp/useAppInstallRequest";
|
|
18
20
|
import { useBookingSecret } from "../headless/booking/useBookingSecret";
|
|
19
21
|
import { AddToCalendar } from "./AddToCalendar";
|
|
20
22
|
|
|
@@ -62,6 +64,12 @@ export function CoachingConfirmation({
|
|
|
62
64
|
secret ?? undefined,
|
|
63
65
|
);
|
|
64
66
|
|
|
67
|
+
// The browser copy of the server's Purchase, keyed on the booking id. A free
|
|
68
|
+
// booking has no value, so it fires nothing here (it is a Schedule).
|
|
69
|
+
useMetaPurchase({ recordId: data?.id, paid: !error && data?.status === "confirmed", value: data?.totalAmount });
|
|
70
|
+
// A confirmed booking is a moment the site app offers itself.
|
|
71
|
+
useAppInstallRequest(!error && data?.status === "confirmed", "booking");
|
|
72
|
+
|
|
65
73
|
const primary = theme.colors.primary;
|
|
66
74
|
const text = theme.colors.text;
|
|
67
75
|
|
|
@@ -10,6 +10,7 @@ import { Loading } from "./Loading";
|
|
|
10
10
|
import { CoachingBooking } from "./CoachingBooking";
|
|
11
11
|
import { CancellationTerms } from "./CancellationTerms";
|
|
12
12
|
import { ReviewsSection } from "./ReviewsSection";
|
|
13
|
+
import { useMetaViewContent } from "../analytics/useMetaEvents";
|
|
13
14
|
|
|
14
15
|
export interface CoachingDetailProps {
|
|
15
16
|
/** Coaching product slug (resolves the product). */
|
|
@@ -42,6 +43,7 @@ export function CoachingDetail({
|
|
|
42
43
|
const fmt = useAmountFormatter(formatAmount);
|
|
43
44
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
44
45
|
const { data: product, isLoading } = useGetCoachingProduct(slug, { initialData: initialCoaching });
|
|
46
|
+
useMetaViewContent(product ? { id: product.id, name: product.title, value: product.price } : null);
|
|
45
47
|
|
|
46
48
|
if (isLoading) return <Loading fullPage />;
|
|
47
49
|
if (!product) return <p>{t("forge.coaching_detail.not_found")}</p>;
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
} from "./Confirmation";
|
|
16
16
|
import { useForgeT } from "../../i18n";
|
|
17
17
|
import { useCourseBookingFinalize } from "../../data/queries/useFinalize";
|
|
18
|
+
import { useMetaPurchase } from "../analytics/useMetaEvents";
|
|
19
|
+
import { useAppInstallRequest } from "../headless/siteApp/useAppInstallRequest";
|
|
18
20
|
import { useGetCourse } from "../../data/queries/useCourses";
|
|
19
21
|
import { useBookingSecret } from "../headless/booking/useBookingSecret";
|
|
20
22
|
|
|
@@ -52,6 +54,9 @@ export function CourseConfirmation({
|
|
|
52
54
|
secret ?? undefined,
|
|
53
55
|
);
|
|
54
56
|
const { data: course } = useGetCourse(slug);
|
|
57
|
+
// The browser copy of the server's Purchase, keyed on the booking id.
|
|
58
|
+
useMetaPurchase({ recordId: data?.id, paid: !error && data?.status === "confirmed", value: data?.totalAmount });
|
|
59
|
+
useAppInstallRequest(!error && data?.status === "confirmed", "purchase");
|
|
55
60
|
|
|
56
61
|
const text = theme.colors.text;
|
|
57
62
|
const primary = theme.colors.primary;
|
|
@@ -9,6 +9,7 @@ import { useForgeT } from "../../i18n";
|
|
|
9
9
|
import { Loading } from "./Loading";
|
|
10
10
|
import { CourseCheckout } from "./CourseCheckout";
|
|
11
11
|
import { ReviewsSection } from "./ReviewsSection";
|
|
12
|
+
import { useMetaViewContent } from "../analytics/useMetaEvents";
|
|
12
13
|
|
|
13
14
|
export interface CourseDetailProps {
|
|
14
15
|
/** Course slug (resolves the course). */
|
|
@@ -47,6 +48,7 @@ export function CourseDetail({
|
|
|
47
48
|
const fmt = useAmountFormatter(formatAmount);
|
|
48
49
|
const pricesIncludeTax = usePricesIncludeTax();
|
|
49
50
|
const { data: course, isLoading } = useGetCourse(slug, { initialData: initialCourse });
|
|
51
|
+
useMetaViewContent(course ? { id: course.id, name: course.title, value: course.price } : null);
|
|
50
52
|
|
|
51
53
|
if (isLoading) return <Loading fullPage />;
|
|
52
54
|
if (!course) return <p>{t("forge.course_detail.not_found")}</p>;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page a digital delivery email links to (`/i/downloads/$token`).
|
|
3
|
+
*
|
|
4
|
+
* The link finds the order; it does not open it. A signed-in owner downloads
|
|
5
|
+
* straight away. Anyone else is shown what they bought and asked for a code we
|
|
6
|
+
* email to the recipient, because an email link is forwarded, archived and
|
|
7
|
+
* indexed, and a link that alone handed over the files would be a permanent
|
|
8
|
+
* public address. Each file is a short-lived signed read, fetched on the click.
|
|
9
|
+
*/
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { Download, FileArchive, Lock, PackageOpen } from "lucide-react";
|
|
12
|
+
import { useForgeT } from "../../i18n";
|
|
13
|
+
import { internalPath } from "../../runtime/pages";
|
|
14
|
+
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
15
|
+
import { useDownloadPage, useOtpCodeInput, OTP_LENGTH, type DownloadFileInfo } from "../headless";
|
|
16
|
+
import { Loading } from "./Loading";
|
|
17
|
+
import { usePortalButtons } from "./portal/shared";
|
|
18
|
+
import { formatFileSize } from "./portal/PortalFiles";
|
|
19
|
+
|
|
20
|
+
export function DownloadPage({ token }: { token: string }) {
|
|
21
|
+
const tr = useForgeT();
|
|
22
|
+
const t = useThemeTokens();
|
|
23
|
+
const page = useDownloadPage(token);
|
|
24
|
+
|
|
25
|
+
if (page.status === "loading") return <Loading fullPage />;
|
|
26
|
+
|
|
27
|
+
if (page.status === "missing" || !page.info) {
|
|
28
|
+
return (
|
|
29
|
+
<Shell>
|
|
30
|
+
<div data-testid="download-missing" className="fg:py-10 fg:text-center">
|
|
31
|
+
<PackageOpen size={36} aria-hidden style={{ color: t.muted, margin: "0 auto 12px" }} />
|
|
32
|
+
<h1 style={{ margin: "0 0 8px", fontSize: 22, fontWeight: 700 }}>{tr("forge.download_page.missing_title")}</h1>
|
|
33
|
+
<p style={{ margin: 0, color: t.muted }}>{tr("forge.download_page.missing_body")}</p>
|
|
34
|
+
</div>
|
|
35
|
+
</Shell>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const { info } = page;
|
|
40
|
+
const cover = info.items.find((item) => item.coverImage)?.coverImage ?? null;
|
|
41
|
+
const unlocked = page.status === "unlocked";
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Shell>
|
|
45
|
+
<header className="fg:flex fg:items-center fg:gap-4" style={{ marginBottom: 28 }}>
|
|
46
|
+
{cover ? (
|
|
47
|
+
<img
|
|
48
|
+
src={cover}
|
|
49
|
+
alt=""
|
|
50
|
+
className="fg:h-16 fg:w-16 fg:shrink-0"
|
|
51
|
+
style={{ borderRadius: t.cornerRadius, objectFit: "cover" }}
|
|
52
|
+
/>
|
|
53
|
+
) : null}
|
|
54
|
+
<div className="fg:min-w-0">
|
|
55
|
+
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 700, lineHeight: 1.25 }}>
|
|
56
|
+
{info.items.map((item) => item.title).join(", ") || tr("forge.download_page.title")}
|
|
57
|
+
</h1>
|
|
58
|
+
<p style={{ margin: "4px 0 0", color: t.muted, fontSize: 14 }}>
|
|
59
|
+
{info.isGift ? tr("forge.download_page.subtitle_gift") : tr("forge.download_page.subtitle")}
|
|
60
|
+
</p>
|
|
61
|
+
</div>
|
|
62
|
+
</header>
|
|
63
|
+
|
|
64
|
+
<ul data-testid="download-files" style={{ listStyle: "none", margin: 0, padding: 0, borderTop: `1px solid ${t.border}` }}>
|
|
65
|
+
{info.files.map((file) => (
|
|
66
|
+
<FileRow key={file.id} file={file} unlocked={unlocked} urlFor={page.urlFor} />
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
|
|
70
|
+
{!unlocked ? <CodeGate page={page} token={token} /> : null}
|
|
71
|
+
</Shell>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function Shell({ children }: { children: React.ReactNode }) {
|
|
76
|
+
const t = useThemeTokens();
|
|
77
|
+
return (
|
|
78
|
+
<main
|
|
79
|
+
data-testid="download-page"
|
|
80
|
+
className="fg:mx-auto fg:w-full fg:max-w-2xl fg:px-4 fg:py-10 fg:md:py-16"
|
|
81
|
+
style={{ color: t.text, fontFamily: t.fontFamily }}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</main>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function FileRow({
|
|
89
|
+
file,
|
|
90
|
+
unlocked,
|
|
91
|
+
urlFor,
|
|
92
|
+
}: {
|
|
93
|
+
file: DownloadFileInfo;
|
|
94
|
+
unlocked: boolean;
|
|
95
|
+
urlFor: (fileId: string) => Promise<string | null>;
|
|
96
|
+
}) {
|
|
97
|
+
const tr = useForgeT();
|
|
98
|
+
const t = useThemeTokens();
|
|
99
|
+
const buttons = usePortalButtons();
|
|
100
|
+
const [busy, setBusy] = useState(false);
|
|
101
|
+
const [failed, setFailed] = useState(false);
|
|
102
|
+
const size = formatFileSize(file.size);
|
|
103
|
+
|
|
104
|
+
const download = async () => {
|
|
105
|
+
setBusy(true);
|
|
106
|
+
setFailed(false);
|
|
107
|
+
try {
|
|
108
|
+
const url = await urlFor(file.id);
|
|
109
|
+
if (!url) throw new Error("no url");
|
|
110
|
+
// Same tab: the signed read answers with `attachment`, so the browser
|
|
111
|
+
// saves the file and the page stays where it is.
|
|
112
|
+
window.location.assign(url);
|
|
113
|
+
} catch {
|
|
114
|
+
setFailed(true);
|
|
115
|
+
} finally {
|
|
116
|
+
setBusy(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<li
|
|
122
|
+
data-testid="download-file"
|
|
123
|
+
className="fg:flex fg:items-center fg:gap-3"
|
|
124
|
+
style={{ padding: "14px 0", borderBottom: `1px solid ${t.border}` }}
|
|
125
|
+
>
|
|
126
|
+
<FileArchive size={18} aria-hidden style={{ color: t.muted, flexShrink: 0 }} />
|
|
127
|
+
<div className="fg:min-w-0 fg:flex-1">
|
|
128
|
+
<p className="fg:truncate" style={{ margin: 0, fontWeight: 600 }} title={file.filename}>
|
|
129
|
+
{file.filename}
|
|
130
|
+
</p>
|
|
131
|
+
{size ? <p style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>{size}</p> : null}
|
|
132
|
+
{failed ? (
|
|
133
|
+
<p role="alert" style={{ margin: "4px 0 0", fontSize: 13, fontWeight: 600 }}>
|
|
134
|
+
{tr("forge.download_page.download_failed")}
|
|
135
|
+
</p>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
{unlocked ? (
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
data-testid="download-file-button"
|
|
142
|
+
onClick={() => void download()}
|
|
143
|
+
disabled={busy}
|
|
144
|
+
style={{ ...buttons.primary, opacity: busy ? 0.7 : 1 }}
|
|
145
|
+
>
|
|
146
|
+
<Download size={15} aria-hidden />
|
|
147
|
+
{tr("forge.download_page.download")}
|
|
148
|
+
</button>
|
|
149
|
+
) : (
|
|
150
|
+
<Lock size={16} aria-label={tr("forge.download_page.locked")} style={{ color: t.muted, flexShrink: 0 }} />
|
|
151
|
+
)}
|
|
152
|
+
</li>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function CodeGate({ page, token }: { page: ReturnType<typeof useDownloadPage>; token: string }) {
|
|
157
|
+
const tr = useForgeT();
|
|
158
|
+
const t = useThemeTokens();
|
|
159
|
+
const buttons = usePortalButtons();
|
|
160
|
+
const otp = useOtpCodeInput({
|
|
161
|
+
disabled: page.isUnlocking,
|
|
162
|
+
onComplete: (code) => page.submitCode(code),
|
|
163
|
+
});
|
|
164
|
+
const sentTo = page.codeSentTo ?? page.info?.sentTo ?? "";
|
|
165
|
+
const backHere = internalPath("downloads/$token", { token });
|
|
166
|
+
const loginHref = `${internalPath("login")}?redirect=${encodeURIComponent(backHere)}`;
|
|
167
|
+
const error = page.unlockError ?? page.sendCodeError;
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<section
|
|
171
|
+
data-testid="download-gate"
|
|
172
|
+
aria-labelledby="download-gate-title"
|
|
173
|
+
style={{ marginTop: 28, padding: 20, border: `1px solid ${t.border}`, borderRadius: t.cornerRadius }}
|
|
174
|
+
>
|
|
175
|
+
<h2 id="download-gate-title" style={{ margin: "0 0 6px", fontSize: 17, fontWeight: 700 }}>
|
|
176
|
+
{tr("forge.download_page.gate_title")}
|
|
177
|
+
</h2>
|
|
178
|
+
{!page.codeSentTo ? (
|
|
179
|
+
<>
|
|
180
|
+
<p style={{ margin: "0 0 16px", color: t.muted, fontSize: 14 }}>
|
|
181
|
+
{tr("forge.download_page.gate_body", { email: sentTo })}
|
|
182
|
+
</p>
|
|
183
|
+
<div className="fg:flex fg:flex-wrap fg:items-center fg:gap-4">
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
data-testid="download-send-code"
|
|
187
|
+
onClick={page.requestCode}
|
|
188
|
+
disabled={page.isSendingCode}
|
|
189
|
+
style={{ ...buttons.primary, opacity: page.isSendingCode ? 0.7 : 1 }}
|
|
190
|
+
>
|
|
191
|
+
{page.isSendingCode ? tr("forge.download_page.sending") : tr("forge.download_page.send_code")}
|
|
192
|
+
</button>
|
|
193
|
+
<a href={loginHref} data-testid="download-sign-in" style={buttons.text}>
|
|
194
|
+
{tr("forge.download_page.sign_in")}
|
|
195
|
+
</a>
|
|
196
|
+
</div>
|
|
197
|
+
</>
|
|
198
|
+
) : (
|
|
199
|
+
<>
|
|
200
|
+
<p style={{ margin: "0 0 16px", color: t.muted, fontSize: 14 }}>
|
|
201
|
+
{tr("forge.download_page.code_sent", { email: sentTo })}
|
|
202
|
+
</p>
|
|
203
|
+
<form
|
|
204
|
+
onSubmit={(event) => {
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
if (otp.code.length === OTP_LENGTH) page.submitCode(otp.code);
|
|
207
|
+
}}
|
|
208
|
+
>
|
|
209
|
+
<fieldset style={{ border: "none", margin: 0, padding: 0 }}>
|
|
210
|
+
<legend className="fg:sr-only">{tr("forge.download_page.code_label")}</legend>
|
|
211
|
+
<div className="fg:flex fg:gap-2" style={{ marginBottom: 16 }}>
|
|
212
|
+
{otp.digits.map((_, index) => (
|
|
213
|
+
<input
|
|
214
|
+
key={index}
|
|
215
|
+
{...otp.getInputProps(index)}
|
|
216
|
+
data-testid="download-code-digit"
|
|
217
|
+
aria-label={tr("forge.download_page.code_digit", { n: index + 1 })}
|
|
218
|
+
className="fg:h-12 fg:w-11 fg:text-center"
|
|
219
|
+
style={{
|
|
220
|
+
fontSize: 20,
|
|
221
|
+
fontWeight: 600,
|
|
222
|
+
color: t.text,
|
|
223
|
+
background: "transparent",
|
|
224
|
+
border: `1px solid ${t.border}`,
|
|
225
|
+
borderRadius: t.cornerRadius,
|
|
226
|
+
fontFamily: t.fontFamily,
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
))}
|
|
230
|
+
</div>
|
|
231
|
+
</fieldset>
|
|
232
|
+
<div className="fg:flex fg:flex-wrap fg:items-center fg:gap-4">
|
|
233
|
+
<button
|
|
234
|
+
type="submit"
|
|
235
|
+
data-testid="download-unlock"
|
|
236
|
+
disabled={page.isUnlocking || !otp.isComplete}
|
|
237
|
+
style={{ ...buttons.primary, opacity: page.isUnlocking || !otp.isComplete ? 0.6 : 1 }}
|
|
238
|
+
>
|
|
239
|
+
{page.isUnlocking ? tr("forge.download_page.unlocking") : tr("forge.download_page.unlock")}
|
|
240
|
+
</button>
|
|
241
|
+
<button
|
|
242
|
+
type="button"
|
|
243
|
+
onClick={() => {
|
|
244
|
+
otp.reset();
|
|
245
|
+
page.requestCode();
|
|
246
|
+
}}
|
|
247
|
+
disabled={page.isSendingCode}
|
|
248
|
+
style={buttons.text}
|
|
249
|
+
>
|
|
250
|
+
{tr("forge.download_page.resend")}
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</form>
|
|
254
|
+
</>
|
|
255
|
+
)}
|
|
256
|
+
{error ? (
|
|
257
|
+
<p role="alert" data-testid="download-error" style={{ margin: "12px 0 0", fontSize: 14, fontWeight: 600 }}>
|
|
258
|
+
{error}
|
|
259
|
+
</p>
|
|
260
|
+
) : null}
|
|
261
|
+
</section>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
@@ -8,6 +8,8 @@ import { useForgeT } from "../../i18n";
|
|
|
8
8
|
import { useEventOrderFinalize } from "../../data/queries/useFinalize";
|
|
9
9
|
import { useEvent } from "../../data/queries/useEvents";
|
|
10
10
|
import { AddToCalendar } from "./AddToCalendar";
|
|
11
|
+
import { useMetaPurchase } from "../analytics/useMetaEvents";
|
|
12
|
+
import { useAppInstallRequest } from "../headless/siteApp/useAppInstallRequest";
|
|
11
13
|
import {
|
|
12
14
|
getTicketOrderOutcome,
|
|
13
15
|
ticketRequestFailureKind,
|
|
@@ -59,6 +61,14 @@ export function EventConfirmation({
|
|
|
59
61
|
const formatAmt = useAmountFormatter(formatAmount);
|
|
60
62
|
const { data: order, isLoading } = useEventOrderFinalize(slug, orderId);
|
|
61
63
|
const { data: event } = useEvent(slug);
|
|
64
|
+
// The browser copy of the server's Purchase, keyed on the ticket order id.
|
|
65
|
+
useMetaPurchase({
|
|
66
|
+
recordId: order?.id,
|
|
67
|
+
paid: !!order && getTicketOrderOutcome(order) === "paid",
|
|
68
|
+
value: order?.totalAmount,
|
|
69
|
+
currency: order?.currency,
|
|
70
|
+
});
|
|
71
|
+
useAppInstallRequest(!!order && getTicketOrderOutcome(order) === "paid", "purchase");
|
|
62
72
|
|
|
63
73
|
const text = theme.colors.text;
|
|
64
74
|
const primary = theme.colors.primary;
|