@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,397 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { LogOut, Menu } from "lucide-react";
|
|
3
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
4
|
+
import { useForgeT } from "../../../i18n";
|
|
5
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
6
|
+
import { usePortalSummary } from "../../headless/portal/usePortal";
|
|
7
|
+
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from "../../headless/dialog";
|
|
8
|
+
import { Loading } from "../Loading";
|
|
9
|
+
import {
|
|
10
|
+
GROUP_LABEL_KEYS,
|
|
11
|
+
PORTAL_ITEMS,
|
|
12
|
+
buildPortalNav,
|
|
13
|
+
type PortalNavItem,
|
|
14
|
+
type PortalSection,
|
|
15
|
+
} from "./portalNav";
|
|
16
|
+
import { PortalContext, defaultPortalDate, type PortalContextValue } from "./shared";
|
|
17
|
+
import { PortalPreviewBanner } from "./PortalPreviewBanner";
|
|
18
|
+
import { AppHomeMark } from "../app/AppHomeMark";
|
|
19
|
+
|
|
20
|
+
/** Tailwind's `lg`: the rail replaces the bottom bar from here up. */
|
|
21
|
+
const BAR_BREAKPOINT = 1024;
|
|
22
|
+
/** The bar's height above the safe area, matching `BarButton`'s min height. */
|
|
23
|
+
const BAR_HEIGHT = 56;
|
|
24
|
+
/**
|
|
25
|
+
* The "More" sheet is modal, so it covers everything pinned to the screen,
|
|
26
|
+
* including the site's chat bubble (`AiAgentWidget` defaults to 9999).
|
|
27
|
+
*/
|
|
28
|
+
const SHEET_Z = 10000;
|
|
29
|
+
|
|
30
|
+
export interface PortalShellProps {
|
|
31
|
+
/** The section being shown. Drives the active item and keeps it drawn when empty. */
|
|
32
|
+
section: PortalSection;
|
|
33
|
+
/** SPA navigation. Pass the host router's; defaults to a full page visit. */
|
|
34
|
+
navigate?: (href: string) => void;
|
|
35
|
+
/** Short-date formatter. Defaults to Forge's work-portal date format. */
|
|
36
|
+
formatDate?: (value: string | number | Date) => string;
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The client portal's frame (`/i/account/*`).
|
|
42
|
+
*
|
|
43
|
+
* A rail on wide screens, grouped into "Your work", "Your library" and
|
|
44
|
+
* "Account". On a phone, a bottom bar holds Home, the person's main section,
|
|
45
|
+
* Activity and a "More" sheet with everything else, because a rail would eat
|
|
46
|
+
* the screen and a scrolling tab strip hides most of what is there.
|
|
47
|
+
*
|
|
48
|
+
* Sections the person has nothing in are not drawn (see `buildPortalNav`). A
|
|
49
|
+
* section with something waiting on them carries a count.
|
|
50
|
+
*
|
|
51
|
+
* The portal's Home is its own overview. The app's Home (the creator's start
|
|
52
|
+
* page, or `/i/app`) is the mark with the app's name, at the top of the rail
|
|
53
|
+
* and above the page on a phone.
|
|
54
|
+
*/
|
|
55
|
+
export function PortalShell({ section, navigate, formatDate, children }: PortalShellProps) {
|
|
56
|
+
const t = useThemeTokens();
|
|
57
|
+
const tr = useForgeT();
|
|
58
|
+
const { user, logout } = usePublicAuth();
|
|
59
|
+
const { data: summary } = usePortalSummary();
|
|
60
|
+
const [moreOpen, setMoreOpen] = useState(false);
|
|
61
|
+
|
|
62
|
+
const go = useMemo(
|
|
63
|
+
() => navigate ?? ((href: string) => typeof window !== "undefined" && window.location.assign(href)),
|
|
64
|
+
[navigate],
|
|
65
|
+
);
|
|
66
|
+
const ctx = useMemo<PortalContextValue>(
|
|
67
|
+
() => ({
|
|
68
|
+
navigate: (href) => {
|
|
69
|
+
setMoreOpen(false);
|
|
70
|
+
go(href);
|
|
71
|
+
},
|
|
72
|
+
formatDate: formatDate ?? defaultPortalDate,
|
|
73
|
+
}),
|
|
74
|
+
[go, formatDate],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const nav = buildPortalNav(summary, section);
|
|
78
|
+
|
|
79
|
+
// Tell anything else pinned to the bottom of the screen (the site's chat
|
|
80
|
+
// bubble) how tall the bar is, so it sits above it rather than on "More".
|
|
81
|
+
// Set on the root because those widgets live outside this tree.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (typeof window === "undefined") return;
|
|
84
|
+
const root = document.documentElement;
|
|
85
|
+
const narrow = window.matchMedia(`(max-width: ${BAR_BREAKPOINT - 1}px)`);
|
|
86
|
+
const apply = () =>
|
|
87
|
+
narrow.matches
|
|
88
|
+
? root.style.setProperty("--forge-bottom-bar-height", `${BAR_HEIGHT}px`)
|
|
89
|
+
: root.style.removeProperty("--forge-bottom-bar-height");
|
|
90
|
+
apply();
|
|
91
|
+
narrow.addEventListener("change", apply);
|
|
92
|
+
return () => {
|
|
93
|
+
narrow.removeEventListener("change", apply);
|
|
94
|
+
root.style.removeProperty("--forge-bottom-bar-height");
|
|
95
|
+
};
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
if (!user) return <Loading fullPage />;
|
|
99
|
+
|
|
100
|
+
const itemLink = (item: PortalNavItem, variant: "rail" | "sheet") => {
|
|
101
|
+
const active = item.key === section;
|
|
102
|
+
const badge = nav.badges[item.key];
|
|
103
|
+
const style: CSSProperties =
|
|
104
|
+
variant === "rail"
|
|
105
|
+
? {
|
|
106
|
+
display: "flex",
|
|
107
|
+
alignItems: "center",
|
|
108
|
+
gap: 10,
|
|
109
|
+
padding: "7px 12px",
|
|
110
|
+
marginLeft: -12,
|
|
111
|
+
borderLeft: `3px solid ${active ? t.primary : "transparent"}`,
|
|
112
|
+
color: active ? t.text : t.muted,
|
|
113
|
+
fontWeight: active ? 600 : 500,
|
|
114
|
+
fontSize: 14,
|
|
115
|
+
textDecoration: "none",
|
|
116
|
+
}
|
|
117
|
+
: {
|
|
118
|
+
display: "flex",
|
|
119
|
+
alignItems: "center",
|
|
120
|
+
gap: 12,
|
|
121
|
+
padding: "12px 4px",
|
|
122
|
+
color: t.text,
|
|
123
|
+
fontWeight: active ? 600 : 500,
|
|
124
|
+
fontSize: 16,
|
|
125
|
+
textDecoration: "none",
|
|
126
|
+
borderBottom: `1px solid ${t.border}`,
|
|
127
|
+
};
|
|
128
|
+
const Icon = item.icon;
|
|
129
|
+
return (
|
|
130
|
+
<a
|
|
131
|
+
key={item.key}
|
|
132
|
+
href={item.href}
|
|
133
|
+
data-testid={`portal-nav-${item.key}`}
|
|
134
|
+
aria-current={active ? "page" : undefined}
|
|
135
|
+
onClick={(event) => {
|
|
136
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
ctx.navigate(item.href);
|
|
139
|
+
}}
|
|
140
|
+
style={style}
|
|
141
|
+
>
|
|
142
|
+
<Icon size={variant === "rail" ? 16 : 18} aria-hidden style={{ flexShrink: 0 }} />
|
|
143
|
+
<span style={{ flex: 1 }}>{tr(item.labelKey)}</span>
|
|
144
|
+
{badge ? <Badge count={badge} label={tr("forge.portal_shell.badge_label", { count: String(badge) })} /> : null}
|
|
145
|
+
</a>
|
|
146
|
+
);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const groupedLinks = (variant: "rail" | "sheet") =>
|
|
150
|
+
nav.groups.map(({ group, items }) => {
|
|
151
|
+
const labelKey = GROUP_LABEL_KEYS[group];
|
|
152
|
+
return (
|
|
153
|
+
<div key={group} style={{ marginTop: group === "start" ? 0 : 22 }}>
|
|
154
|
+
{labelKey ? (
|
|
155
|
+
<p style={{ margin: "0 0 6px", fontSize: 13, color: t.muted, fontWeight: 500 }}>{tr(labelKey)}</p>
|
|
156
|
+
) : null}
|
|
157
|
+
{items.map((item) => itemLink(item, variant))}
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const logOut = (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
onClick={() => logout()}
|
|
166
|
+
className="fg:flex fg:items-center fg:gap-2.5"
|
|
167
|
+
style={{
|
|
168
|
+
marginTop: 22,
|
|
169
|
+
padding: "7px 0",
|
|
170
|
+
background: "transparent",
|
|
171
|
+
border: "none",
|
|
172
|
+
color: t.muted,
|
|
173
|
+
fontSize: 14,
|
|
174
|
+
fontFamily: t.fontFamily,
|
|
175
|
+
cursor: "pointer",
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
<LogOut size={16} aria-hidden /> {tr("forge.portal_shell.log_out")}
|
|
179
|
+
</button>
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// The bottom bar: Home, Messages when the person may message, the lead
|
|
183
|
+
// section, then Activity if there is still room, and More.
|
|
184
|
+
const home = PORTAL_ITEMS.find((i) => i.key === "home")!;
|
|
185
|
+
const activity = PORTAL_ITEMS.find((i) => i.key === "activity")!;
|
|
186
|
+
const messages = nav.groups.flatMap((g) => g.items).find((i) => i.key === "messages") ?? null;
|
|
187
|
+
const barItems = [home, messages, nav.primary, messages ? null : activity].filter(
|
|
188
|
+
(item): item is (typeof PORTAL_ITEMS)[number] => !!item,
|
|
189
|
+
);
|
|
190
|
+
const moreBadge = Object.entries(nav.badges)
|
|
191
|
+
.filter(([key]) => !barItems.some((i) => i.key === key))
|
|
192
|
+
.reduce((sum, [, n]) => sum + (n ?? 0), 0);
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<PortalContext.Provider value={ctx}>
|
|
196
|
+
<div
|
|
197
|
+
data-testid="portal-shell"
|
|
198
|
+
className="fg:flex fg:w-full fg:flex-col fg:lg:flex-row fg:lg:items-start fg:lg:gap-12"
|
|
199
|
+
style={{ color: t.text, fontFamily: t.fontFamily, paddingInline: 16 }}
|
|
200
|
+
>
|
|
201
|
+
<nav
|
|
202
|
+
aria-label={tr("forge.portal_shell.nav_label")}
|
|
203
|
+
className="fg:hidden fg:lg:block fg:lg:sticky fg:lg:top-6 fg:w-52 fg:shrink-0"
|
|
204
|
+
>
|
|
205
|
+
{/* The way back to the app's Home, which is not the portal's own Home. */}
|
|
206
|
+
<div style={{ marginBottom: 14 }}>
|
|
207
|
+
<AppHomeMark go={ctx.navigate} size={32} testId="portal-app-home" withName />
|
|
208
|
+
</div>
|
|
209
|
+
<p
|
|
210
|
+
style={{ margin: "0 0 18px", fontSize: 13, color: t.muted, overflow: "hidden", textOverflow: "ellipsis" }}
|
|
211
|
+
title={user.email}
|
|
212
|
+
>
|
|
213
|
+
{user.email}
|
|
214
|
+
</p>
|
|
215
|
+
{groupedLinks("rail")}
|
|
216
|
+
{logOut}
|
|
217
|
+
</nav>
|
|
218
|
+
|
|
219
|
+
<main className="fg:min-w-0 fg:w-full fg:max-w-3xl fg:flex-1 fg:pb-28 fg:lg:pb-0">
|
|
220
|
+
{/* On a phone the rail is hidden, so the way back to the app's Home sits above the page. */}
|
|
221
|
+
<div className="fg:flex fg:lg:hidden" style={{ marginBottom: 12 }}>
|
|
222
|
+
<AppHomeMark go={ctx.navigate} size={32} testId="portal-app-home-compact" withName />
|
|
223
|
+
</div>
|
|
224
|
+
{/* A creator previewing a client: says whose portal this is, that it is
|
|
225
|
+
read-only and when it ends. Draws nothing in a normal session. */}
|
|
226
|
+
<PortalPreviewBanner />
|
|
227
|
+
{children}
|
|
228
|
+
</main>
|
|
229
|
+
|
|
230
|
+
<nav
|
|
231
|
+
aria-label={tr("forge.portal_shell.nav_label")}
|
|
232
|
+
className="fg:fixed fg:inset-x-0 fg:bottom-0 fg:z-40 fg:flex fg:lg:hidden"
|
|
233
|
+
style={{
|
|
234
|
+
background: t.background,
|
|
235
|
+
borderTop: `1px solid ${t.border}`,
|
|
236
|
+
paddingBottom: "env(safe-area-inset-bottom)",
|
|
237
|
+
}}
|
|
238
|
+
>
|
|
239
|
+
{barItems.map((item) => (
|
|
240
|
+
<BarButton
|
|
241
|
+
key={item.key}
|
|
242
|
+
icon={<item.icon size={20} aria-hidden />}
|
|
243
|
+
label={tr(item.labelKey)}
|
|
244
|
+
active={item.key === section}
|
|
245
|
+
badge={nav.badges[item.key]}
|
|
246
|
+
href={item.href}
|
|
247
|
+
onGo={ctx.navigate}
|
|
248
|
+
testId={`portal-bar-${item.key}`}
|
|
249
|
+
/>
|
|
250
|
+
))}
|
|
251
|
+
<BarButton
|
|
252
|
+
icon={<Menu size={20} aria-hidden />}
|
|
253
|
+
label={tr("forge.portal_shell.more")}
|
|
254
|
+
active={!barItems.some((i) => i.key === section)}
|
|
255
|
+
badge={moreBadge || undefined}
|
|
256
|
+
onPress={() => setMoreOpen(true)}
|
|
257
|
+
testId="portal-bar-more"
|
|
258
|
+
/>
|
|
259
|
+
</nav>
|
|
260
|
+
|
|
261
|
+
<DialogRoot open={moreOpen} onOpenChange={setMoreOpen}>
|
|
262
|
+
<DialogPortal>
|
|
263
|
+
<DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: SHEET_Z }} />
|
|
264
|
+
<DialogContent
|
|
265
|
+
aria-describedby={undefined}
|
|
266
|
+
style={{
|
|
267
|
+
position: "fixed",
|
|
268
|
+
insetInline: 0,
|
|
269
|
+
bottom: 0,
|
|
270
|
+
zIndex: SHEET_Z + 1,
|
|
271
|
+
maxHeight: "85dvh",
|
|
272
|
+
overflowY: "auto",
|
|
273
|
+
background: t.background,
|
|
274
|
+
color: t.text,
|
|
275
|
+
fontFamily: t.fontFamily,
|
|
276
|
+
borderTopLeftRadius: Math.max(t.cornerRadius, 12),
|
|
277
|
+
borderTopRightRadius: Math.max(t.cornerRadius, 12),
|
|
278
|
+
padding: "20px 20px calc(20px + env(safe-area-inset-bottom))",
|
|
279
|
+
}}
|
|
280
|
+
>
|
|
281
|
+
<DialogTitle style={{ margin: "0 0 12px", fontSize: 18, fontWeight: 600, fontFamily: t.headingFontFamily }}>
|
|
282
|
+
{tr("forge.portal_shell.more_title")}
|
|
283
|
+
</DialogTitle>
|
|
284
|
+
{groupedLinks("sheet")}
|
|
285
|
+
{logOut}
|
|
286
|
+
</DialogContent>
|
|
287
|
+
</DialogPortal>
|
|
288
|
+
</DialogRoot>
|
|
289
|
+
</div>
|
|
290
|
+
</PortalContext.Provider>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function Badge({ count, label }: { count: number; label: string }) {
|
|
295
|
+
const t = useThemeTokens();
|
|
296
|
+
return (
|
|
297
|
+
<span
|
|
298
|
+
aria-label={label}
|
|
299
|
+
style={{
|
|
300
|
+
minWidth: 20,
|
|
301
|
+
height: 20,
|
|
302
|
+
padding: "0 6px",
|
|
303
|
+
borderRadius: 10,
|
|
304
|
+
background: t.primary,
|
|
305
|
+
color: t.textPrimary,
|
|
306
|
+
fontSize: 12,
|
|
307
|
+
fontWeight: 700,
|
|
308
|
+
lineHeight: "20px",
|
|
309
|
+
textAlign: "center",
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
{count}
|
|
313
|
+
</span>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function BarButton({
|
|
318
|
+
icon,
|
|
319
|
+
label,
|
|
320
|
+
active,
|
|
321
|
+
badge,
|
|
322
|
+
href,
|
|
323
|
+
onGo,
|
|
324
|
+
onPress,
|
|
325
|
+
testId,
|
|
326
|
+
}: {
|
|
327
|
+
icon: ReactNode;
|
|
328
|
+
label: string;
|
|
329
|
+
active: boolean;
|
|
330
|
+
badge?: number;
|
|
331
|
+
href?: string;
|
|
332
|
+
onGo?: (href: string) => void;
|
|
333
|
+
onPress?: () => void;
|
|
334
|
+
testId: string;
|
|
335
|
+
}) {
|
|
336
|
+
const t = useThemeTokens();
|
|
337
|
+
const style: CSSProperties = {
|
|
338
|
+
position: "relative",
|
|
339
|
+
flex: 1,
|
|
340
|
+
display: "flex",
|
|
341
|
+
flexDirection: "column",
|
|
342
|
+
alignItems: "center",
|
|
343
|
+
gap: 3,
|
|
344
|
+
padding: "9px 4px 8px",
|
|
345
|
+
minHeight: BAR_HEIGHT,
|
|
346
|
+
background: "transparent",
|
|
347
|
+
border: "none",
|
|
348
|
+
color: active ? t.primary : t.muted,
|
|
349
|
+
fontFamily: t.fontFamily,
|
|
350
|
+
fontSize: 11,
|
|
351
|
+
fontWeight: active ? 700 : 500,
|
|
352
|
+
textDecoration: "none",
|
|
353
|
+
cursor: "pointer",
|
|
354
|
+
};
|
|
355
|
+
const content = (
|
|
356
|
+
<>
|
|
357
|
+
{icon}
|
|
358
|
+
<span>{label}</span>
|
|
359
|
+
{badge ? (
|
|
360
|
+
<span
|
|
361
|
+
aria-hidden
|
|
362
|
+
style={{
|
|
363
|
+
position: "absolute",
|
|
364
|
+
top: 6,
|
|
365
|
+
left: "calc(50% + 6px)",
|
|
366
|
+
width: 8,
|
|
367
|
+
height: 8,
|
|
368
|
+
borderRadius: 4,
|
|
369
|
+
background: t.primary,
|
|
370
|
+
}}
|
|
371
|
+
/>
|
|
372
|
+
) : null}
|
|
373
|
+
</>
|
|
374
|
+
);
|
|
375
|
+
if (href && onGo) {
|
|
376
|
+
return (
|
|
377
|
+
<a
|
|
378
|
+
href={href}
|
|
379
|
+
data-testid={testId}
|
|
380
|
+
aria-current={active ? "page" : undefined}
|
|
381
|
+
onClick={(event) => {
|
|
382
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
383
|
+
event.preventDefault();
|
|
384
|
+
onGo(href);
|
|
385
|
+
}}
|
|
386
|
+
style={style}
|
|
387
|
+
>
|
|
388
|
+
{content}
|
|
389
|
+
</a>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return (
|
|
393
|
+
<button type="button" data-testid={testId} onClick={onPress} style={style}>
|
|
394
|
+
{content}
|
|
395
|
+
</button>
|
|
396
|
+
);
|
|
397
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
|
|
6
|
+
import { PortalShell } from "../PortalShell";
|
|
7
|
+
import { account, get, installAdapter, restoreAdapter, wrapper } from "../../../headless/_tests/communityHarness";
|
|
8
|
+
import { siteApp } from "../../../headless/_tests/siteAppHarness";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The Home rule inside the client portal: the portal's own Home is its
|
|
12
|
+
* overview, so the way back to the app's Home is the app mark, at the top of
|
|
13
|
+
* the rail and above the page on a phone. It leads to the creator's start page
|
|
14
|
+
* when they chose one and to `/i/app` otherwise.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// jsdom has no matchMedia; the shell asks it whether the phone bar is showing.
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.stubGlobal("matchMedia", (query: string) => ({
|
|
20
|
+
matches: false,
|
|
21
|
+
media: query,
|
|
22
|
+
addEventListener: () => {},
|
|
23
|
+
removeEventListener: () => {},
|
|
24
|
+
}));
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
restoreAdapter();
|
|
28
|
+
vi.unstubAllGlobals();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
|
|
32
|
+
|
|
33
|
+
function mount(node: ReactNode) {
|
|
34
|
+
const Wrapper = wrapper({ community: null, user: account() });
|
|
35
|
+
return render(
|
|
36
|
+
<Wrapper>
|
|
37
|
+
<ForgeThemeProvider theme={theme}>{node}</ForgeThemeProvider>
|
|
38
|
+
</Wrapper>,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const summary = {
|
|
43
|
+
sections: { projects: 1, invoices: 0, documents: 0, bookings: 0, memberships: 0, courses: 0, films: 0, orders: 0, tickets: 0, waitlist: 0, saved: 0, messages: 0, files: 0 },
|
|
44
|
+
lastActivity: { work: null, library: null },
|
|
45
|
+
attention: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const routes = (app: () => unknown) => [get("/public/websites/app", app), get("/public/portal/summary", () => summary)];
|
|
49
|
+
|
|
50
|
+
describe("PortalShell and the app's Home", () => {
|
|
51
|
+
it("leads both marks to the creator's own start page, named for the app", async () => {
|
|
52
|
+
installAdapter(routes(() => siteApp({ appName: "Northlight", startUrl: "/members", iconUrl: "https://cdn.example/icon.png" })));
|
|
53
|
+
const navigate = vi.fn();
|
|
54
|
+
mount(
|
|
55
|
+
<PortalShell section="home" navigate={navigate}>
|
|
56
|
+
<p>overview</p>
|
|
57
|
+
</PortalShell>,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const rail = await screen.findByTestId("portal-app-home");
|
|
61
|
+
const compact = screen.getByTestId("portal-app-home-compact");
|
|
62
|
+
await waitFor(() => expect(rail.getAttribute("href")).toBe("/members"));
|
|
63
|
+
expect(compact.getAttribute("href")).toBe("/members");
|
|
64
|
+
// Named for the app, so it never reads as a second "Home" beside the portal's own.
|
|
65
|
+
expect(rail.textContent).toBe("Northlight");
|
|
66
|
+
expect(compact.querySelector("img")?.getAttribute("src")).toBe("https://cdn.example/icon.png");
|
|
67
|
+
// The portal's own Home is still the overview.
|
|
68
|
+
expect(screen.getByTestId("portal-bar-home").getAttribute("href")).toBe("/i/account");
|
|
69
|
+
|
|
70
|
+
fireEvent.click(compact, { button: 0 });
|
|
71
|
+
expect(navigate).toHaveBeenCalledWith("/members");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("falls back to /i/app, labelled Home, when the site has no app settings", async () => {
|
|
75
|
+
installAdapter(
|
|
76
|
+
routes(() => {
|
|
77
|
+
throw { status: 404 };
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
mount(
|
|
81
|
+
<PortalShell section="home">
|
|
82
|
+
<p>overview</p>
|
|
83
|
+
</PortalShell>,
|
|
84
|
+
);
|
|
85
|
+
const compact = await screen.findByTestId("portal-app-home-compact");
|
|
86
|
+
expect(compact.getAttribute("href")).toBe("/i/app");
|
|
87
|
+
expect(compact.getAttribute("aria-label")).toBe("Home");
|
|
88
|
+
expect(screen.getByTestId("portal-app-home").getAttribute("href")).toBe("/i/app");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { ForgeClientProvider } from "../../../../provider/ForgeProvider";
|
|
5
|
+
import { PublicAuthContext } from "../../../../contexts/PublicAuthContext";
|
|
6
|
+
import { ForgeThemeProvider } from "../../../theme/ForgeThemeProvider";
|
|
7
|
+
import { portalFilesKey, type PortalFile, type PortalFilesPage } from "../../../headless/portal/usePortalFiles";
|
|
8
|
+
import { PortalFiles, formatFileSize } from "../PortalFiles";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The Files page, rendered through `react-dom/server` with the first page
|
|
12
|
+
* SEEDED into the query cache, so the real `usePortalFiles` (and its key)
|
|
13
|
+
* stays in the path.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const PROFILE_ID = "profile-1";
|
|
17
|
+
const ACCOUNT_ID = "account-1";
|
|
18
|
+
|
|
19
|
+
const member = {
|
|
20
|
+
id: ACCOUNT_ID,
|
|
21
|
+
email: "ada@example.com",
|
|
22
|
+
firstName: "Ada",
|
|
23
|
+
lastName: "Client",
|
|
24
|
+
kind: "fan",
|
|
25
|
+
status: "active",
|
|
26
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
27
|
+
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
28
|
+
membership: null,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const FILES: PortalFile[] = [
|
|
32
|
+
{
|
|
33
|
+
id: "att-1",
|
|
34
|
+
source: "task",
|
|
35
|
+
title: "cover-art.png",
|
|
36
|
+
filename: "cover-art.png",
|
|
37
|
+
mimeType: "image/png",
|
|
38
|
+
size: 1536,
|
|
39
|
+
createdAt: "2026-09-30T10:00:00.000Z",
|
|
40
|
+
projectId: "proj-1",
|
|
41
|
+
projectName: "Album Mix",
|
|
42
|
+
open: { kind: "task_attachment", projectId: "proj-1", taskId: "task-1", url: "https://cdn.test/cover-art.png" },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "doc-1",
|
|
46
|
+
source: "project",
|
|
47
|
+
title: "Mix Notes",
|
|
48
|
+
filename: "mix-notes.pdf",
|
|
49
|
+
mimeType: "application/pdf",
|
|
50
|
+
size: 2 * 1024 * 1024,
|
|
51
|
+
createdAt: "2026-09-29T10:00:00.000Z",
|
|
52
|
+
projectId: "proj-1",
|
|
53
|
+
projectName: "Album Mix",
|
|
54
|
+
open: { kind: "project_document", projectId: "proj-1", documentId: "doc-1" },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "rec-1",
|
|
58
|
+
source: "document",
|
|
59
|
+
title: "Engagement Letter",
|
|
60
|
+
filename: "engagement-letter-signed.pdf",
|
|
61
|
+
mimeType: "application/pdf",
|
|
62
|
+
size: null,
|
|
63
|
+
createdAt: "2026-09-28T10:00:00.000Z",
|
|
64
|
+
projectId: null,
|
|
65
|
+
projectName: null,
|
|
66
|
+
open: { kind: "signed_document", token: "tok-abc" },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "inv-1",
|
|
70
|
+
source: "invoice",
|
|
71
|
+
title: "INV-1042",
|
|
72
|
+
filename: "invoice-INV-1042.pdf",
|
|
73
|
+
mimeType: "application/pdf",
|
|
74
|
+
size: null,
|
|
75
|
+
createdAt: "2026-09-27T10:00:00.000Z",
|
|
76
|
+
projectId: null,
|
|
77
|
+
projectName: null,
|
|
78
|
+
open: { kind: "invoice_pdf", invoiceId: "inv-1" },
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
function render(page: PortalFilesPage | null): string {
|
|
83
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
84
|
+
if (page) {
|
|
85
|
+
queryClient.setQueryData(portalFilesKey(PROFILE_ID, ACCOUNT_ID, "all", 30), { pages: [page], pageParams: [1] });
|
|
86
|
+
}
|
|
87
|
+
return renderToStaticMarkup(
|
|
88
|
+
<QueryClientProvider client={queryClient}>
|
|
89
|
+
<ForgeClientProvider apiUrl="http://api.test/" profileId={PROFILE_ID}>
|
|
90
|
+
<PublicAuthContext.Provider
|
|
91
|
+
value={{ user: member, currencies: null, userSelectedCurrency: "USD", logout: () => {} } as never}
|
|
92
|
+
>
|
|
93
|
+
<ForgeThemeProvider
|
|
94
|
+
theme={{ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 }}
|
|
95
|
+
>
|
|
96
|
+
<PortalFiles />
|
|
97
|
+
</ForgeThemeProvider>
|
|
98
|
+
</PublicAuthContext.Provider>
|
|
99
|
+
</ForgeClientProvider>
|
|
100
|
+
</QueryClientProvider>,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const rows = (html: string) => html.split('data-testid="portal-file"').slice(1);
|
|
105
|
+
|
|
106
|
+
describe("PortalFiles", () => {
|
|
107
|
+
it("renders one row per file, newest first, with its meta and the right action", () => {
|
|
108
|
+
const html = render({ data: FILES, total: 4, page: 1, pageSize: 30 });
|
|
109
|
+
const [task, project, signed, invoice] = rows(html);
|
|
110
|
+
expect(rows(html)).toHaveLength(4);
|
|
111
|
+
|
|
112
|
+
expect(task).toContain("cover-art.png");
|
|
113
|
+
expect(task).toContain("Album Mix");
|
|
114
|
+
expect(task).toContain("1.5 KB");
|
|
115
|
+
expect(task).toContain('href="https://cdn.test/cover-art.png"');
|
|
116
|
+
expect(task).toContain(">Open<");
|
|
117
|
+
|
|
118
|
+
// A shared project file is a button: its address is minted on the click.
|
|
119
|
+
expect(project).toContain("Mix Notes");
|
|
120
|
+
expect(project).toContain("2.0 MB");
|
|
121
|
+
expect(project).toMatch(/<button[^>]*data-testid="portal-file-action"/);
|
|
122
|
+
expect(project).not.toContain("href=");
|
|
123
|
+
|
|
124
|
+
expect(signed).toContain("Engagement Letter");
|
|
125
|
+
expect(signed).toContain('href="http://api.test/public/documents/tok-abc/download"');
|
|
126
|
+
expect(signed).toContain(">Download<");
|
|
127
|
+
|
|
128
|
+
expect(invoice).toContain("Invoice INV-1042");
|
|
129
|
+
expect(invoice).toContain('href="http://api.test/public/invoices/inv-1/pdf"');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("draws the filter tabs, with All selected", () => {
|
|
133
|
+
const html = render({ data: FILES, total: 4, page: 1, pageSize: 30 });
|
|
134
|
+
for (const key of ["all", "project", "task", "document", "invoice"]) {
|
|
135
|
+
expect(html).toContain(`data-testid="portal-files-filter-${key}"`);
|
|
136
|
+
}
|
|
137
|
+
expect(html).toMatch(/aria-selected="true"[^>]*data-testid="portal-files-filter-all"/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("offers more when the server has more", () => {
|
|
141
|
+
expect(render({ data: FILES, total: 40, page: 1, pageSize: 30 })).toContain('data-testid="portal-files-more"');
|
|
142
|
+
expect(render({ data: FILES, total: 4, page: 1, pageSize: 30 })).not.toContain('data-testid="portal-files-more"');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("says what will appear when there are no files at all, without tabs", () => {
|
|
146
|
+
const html = render({ data: [], total: 0, page: 1, pageSize: 30 });
|
|
147
|
+
expect(html).toContain('data-testid="portal-empty"');
|
|
148
|
+
expect(html).toContain("No files yet");
|
|
149
|
+
expect(html).not.toContain("portal-files-filter-all");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("formats sizes", () => {
|
|
153
|
+
expect(formatFileSize(null)).toBeNull();
|
|
154
|
+
expect(formatFileSize(0)).toBeNull();
|
|
155
|
+
expect(formatFileSize(512)).toBe("512 B");
|
|
156
|
+
expect(formatFileSize(1536)).toBe("1.5 KB");
|
|
157
|
+
expect(formatFileSize(50 * 1024 * 1024)).toBe("50 MB");
|
|
158
|
+
});
|
|
159
|
+
});
|