@tribe-nest/forge 3.60.0 → 3.61.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 +2 -1
- 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 +17 -0
- package/src/runtime/pages.spec.ts +5 -0
- package/src/runtime/pages.ts +32 -6
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -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/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/metaEvents.spec.ts +264 -0
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CalendarCheck,
|
|
3
|
+
CheckCircle2,
|
|
4
|
+
FileCheck2,
|
|
5
|
+
FileSignature,
|
|
6
|
+
MessageSquare,
|
|
7
|
+
Receipt,
|
|
8
|
+
ShoppingBag,
|
|
9
|
+
Stamp,
|
|
10
|
+
type LucideIcon,
|
|
11
|
+
} from "lucide-react";
|
|
12
|
+
import { useForgeLocale, useForgeT, type ForgeT } from "../../../i18n";
|
|
13
|
+
import { internalPath } from "../../../runtime/pages";
|
|
14
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
15
|
+
import { usePortalActivity, type PortalActivityItem, type PortalActivityKind } from "../../headless/portal/usePortal";
|
|
16
|
+
import { Loading } from "../Loading";
|
|
17
|
+
import { PortalEmpty, PortalHeading, PortalLink, PortalList, usePortalButtons } from "./shared";
|
|
18
|
+
|
|
19
|
+
const ICONS: Record<PortalActivityKind, LucideIcon> = {
|
|
20
|
+
invoice_issued: Receipt,
|
|
21
|
+
invoice_paid: CheckCircle2,
|
|
22
|
+
document_sent: FileSignature,
|
|
23
|
+
document_signed: FileCheck2,
|
|
24
|
+
milestone_completed: CheckCircle2,
|
|
25
|
+
comment_added: MessageSquare,
|
|
26
|
+
approval_requested: Stamp,
|
|
27
|
+
approval_decided: Stamp,
|
|
28
|
+
order_placed: ShoppingBag,
|
|
29
|
+
booking_confirmed: CalendarCheck,
|
|
30
|
+
message_received: MessageSquare,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** One sentence per event, written for the person reading it. */
|
|
34
|
+
export function activitySentence(item: PortalActivityItem, tr: ForgeT): string {
|
|
35
|
+
const vars = { title: item.title };
|
|
36
|
+
switch (item.kind) {
|
|
37
|
+
case "invoice_issued":
|
|
38
|
+
return tr("forge.portal_activity.invoice_issued", vars);
|
|
39
|
+
case "invoice_paid":
|
|
40
|
+
return tr("forge.portal_activity.invoice_paid", vars);
|
|
41
|
+
case "document_sent":
|
|
42
|
+
return tr("forge.portal_activity.document_sent", vars);
|
|
43
|
+
case "document_signed":
|
|
44
|
+
return tr("forge.portal_activity.document_signed", vars);
|
|
45
|
+
case "milestone_completed":
|
|
46
|
+
return tr("forge.portal_activity.milestone_completed", vars);
|
|
47
|
+
case "comment_added":
|
|
48
|
+
return tr("forge.portal_activity.comment_added", vars);
|
|
49
|
+
case "approval_requested":
|
|
50
|
+
return tr("forge.portal_activity.approval_requested", vars);
|
|
51
|
+
case "approval_decided":
|
|
52
|
+
return item.detail === "changes_requested"
|
|
53
|
+
? tr("forge.portal_activity.approval_changes", vars)
|
|
54
|
+
: tr("forge.portal_activity.approval_approved", vars);
|
|
55
|
+
case "order_placed":
|
|
56
|
+
return tr("forge.portal_activity.order_placed", vars);
|
|
57
|
+
case "booking_confirmed":
|
|
58
|
+
return tr("forge.portal_activity.booking_confirmed", vars);
|
|
59
|
+
case "message_received":
|
|
60
|
+
return tr("forge.portal_activity.message_received", vars);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Where an event leads: the thing it happened to. */
|
|
65
|
+
export function activityHref(item: PortalActivityItem): string {
|
|
66
|
+
switch (item.kind) {
|
|
67
|
+
case "invoice_issued":
|
|
68
|
+
case "invoice_paid":
|
|
69
|
+
return internalPath("invoice/$invoiceId", { invoiceId: item.entityId });
|
|
70
|
+
case "document_sent":
|
|
71
|
+
case "document_signed":
|
|
72
|
+
return internalPath("account/documents");
|
|
73
|
+
case "order_placed":
|
|
74
|
+
return internalPath("account/orders");
|
|
75
|
+
case "booking_confirmed":
|
|
76
|
+
return internalPath("account/bookings");
|
|
77
|
+
case "message_received":
|
|
78
|
+
return internalPath("account/messages");
|
|
79
|
+
default:
|
|
80
|
+
return item.projectId
|
|
81
|
+
? internalPath("account/projects/$projectId", { projectId: item.projectId })
|
|
82
|
+
: internalPath("account/projects");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** "3 hours ago", "yesterday", in the site's language. */
|
|
87
|
+
export function useRelativeTime() {
|
|
88
|
+
const locale = useForgeLocale();
|
|
89
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
90
|
+
return (iso: string) => {
|
|
91
|
+
const seconds = Math.round((Date.parse(iso) - Date.now()) / 1000);
|
|
92
|
+
const abs = Math.abs(seconds);
|
|
93
|
+
if (abs < 60) return rtf.format(0, "minute");
|
|
94
|
+
if (abs < 3600) return rtf.format(Math.round(seconds / 60), "minute");
|
|
95
|
+
if (abs < 86400) return rtf.format(Math.round(seconds / 3600), "hour");
|
|
96
|
+
if (abs < 86400 * 30) return rtf.format(Math.round(seconds / 86400), "day");
|
|
97
|
+
return new Date(iso).toLocaleDateString(locale, { day: "numeric", month: "short", year: "numeric" });
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function ActivityRows({ items }: { items: PortalActivityItem[] }) {
|
|
102
|
+
const t = useThemeTokens();
|
|
103
|
+
const tr = useForgeT();
|
|
104
|
+
const ago = useRelativeTime();
|
|
105
|
+
return (
|
|
106
|
+
<PortalList>
|
|
107
|
+
{items.map((item) => {
|
|
108
|
+
const Icon = ICONS[item.kind];
|
|
109
|
+
return (
|
|
110
|
+
<li key={item.id} data-testid="portal-activity-item" style={{ borderBottom: `1px solid ${t.border}` }}>
|
|
111
|
+
<PortalLink
|
|
112
|
+
href={activityHref(item)}
|
|
113
|
+
className="fg:flex fg:items-start fg:gap-3 fg:py-3"
|
|
114
|
+
style={{ color: t.text, textDecoration: "none" }}
|
|
115
|
+
>
|
|
116
|
+
<Icon size={16} aria-hidden style={{ marginTop: 3, color: t.muted, flexShrink: 0 }} />
|
|
117
|
+
<span className="fg:min-w-0 fg:flex-1">
|
|
118
|
+
<span style={{ display: "block", fontSize: 15, lineHeight: 1.45 }}>{activitySentence(item, tr)}</span>
|
|
119
|
+
{item.projectName ? (
|
|
120
|
+
<span style={{ display: "block", fontSize: 13, color: t.muted }}>{item.projectName}</span>
|
|
121
|
+
) : null}
|
|
122
|
+
</span>
|
|
123
|
+
<time dateTime={item.occurredAt} style={{ fontSize: 13, color: t.muted, whiteSpace: "nowrap" }}>
|
|
124
|
+
{ago(item.occurredAt)}
|
|
125
|
+
</time>
|
|
126
|
+
</PortalLink>
|
|
127
|
+
</li>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</PortalList>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The whole timeline (`/i/account/activity`). */
|
|
135
|
+
export function PortalActivity() {
|
|
136
|
+
const tr = useForgeT();
|
|
137
|
+
const buttons = usePortalButtons();
|
|
138
|
+
const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = usePortalActivity(30);
|
|
139
|
+
const items = data?.pages.flatMap((p) => p.items) ?? [];
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div data-testid="portal-activity">
|
|
143
|
+
<PortalHeading title={tr("forge.portal_activity.title")} />
|
|
144
|
+
{isLoading ? (
|
|
145
|
+
<Loading />
|
|
146
|
+
) : items.length === 0 ? (
|
|
147
|
+
<PortalEmpty title={tr("forge.portal_activity.empty_title")} body={tr("forge.portal_activity.empty_body")} />
|
|
148
|
+
) : (
|
|
149
|
+
<>
|
|
150
|
+
<ActivityRows items={items} />
|
|
151
|
+
{hasNextPage ? (
|
|
152
|
+
<div style={{ marginTop: 20 }}>
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
style={buttons.quiet}
|
|
156
|
+
disabled={isFetchingNextPage}
|
|
157
|
+
onClick={() => void fetchNextPage()}
|
|
158
|
+
>
|
|
159
|
+
{isFetchingNextPage ? tr("forge.portal_activity.loading") : tr("forge.portal_activity.load_more")}
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
) : null}
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useForgeT } from "../../../i18n";
|
|
3
|
+
import { internalPath } from "../../../runtime/pages";
|
|
4
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
5
|
+
import { usePortalDocuments, usePortalInvoices, type PortalInvoice } from "../../headless/portal/usePortal";
|
|
6
|
+
import { Loading } from "../Loading";
|
|
7
|
+
import { PortalEmpty, PortalHeading, PortalLink, PortalList, PortalRow, portalMoney, usePortal, usePortalButtons } from "./shared";
|
|
8
|
+
|
|
9
|
+
/** An invoice still owed. Paid, cancelled and refunded ones are history. */
|
|
10
|
+
const OPEN_STATUSES = new Set(["sent", "overdue", "initiated_payment", "payment_failed", "viewed"]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Every invoice the creator has sent this person (`/i/account/invoices`).
|
|
14
|
+
*
|
|
15
|
+
* Owed invoices first, with the amount and the due date on the row and a Pay
|
|
16
|
+
* button; settled ones below, read-only. Each opens the hosted invoice page,
|
|
17
|
+
* which is where paying and the PDF already live.
|
|
18
|
+
*/
|
|
19
|
+
export function PortalInvoices() {
|
|
20
|
+
const tr = useForgeT();
|
|
21
|
+
const t = useThemeTokens();
|
|
22
|
+
const [filter, setFilter] = useState<"open" | "all">("open");
|
|
23
|
+
const { data, isLoading } = usePortalInvoices("all");
|
|
24
|
+
const invoices = data?.data ?? [];
|
|
25
|
+
const open = invoices.filter((inv) => OPEN_STATUSES.has(inv.status));
|
|
26
|
+
const shown = filter === "open" ? open : invoices;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div data-testid="portal-invoices">
|
|
30
|
+
<PortalHeading
|
|
31
|
+
title={tr("forge.portal_billing.invoices_title")}
|
|
32
|
+
lead={
|
|
33
|
+
open.length > 0
|
|
34
|
+
? tr("forge.portal_billing.invoices_lead_open", { count: open.length })
|
|
35
|
+
: invoices.length > 0
|
|
36
|
+
? tr("forge.portal_billing.invoices_lead_settled")
|
|
37
|
+
: undefined
|
|
38
|
+
}
|
|
39
|
+
/>
|
|
40
|
+
{isLoading ? (
|
|
41
|
+
<Loading />
|
|
42
|
+
) : invoices.length === 0 ? (
|
|
43
|
+
<PortalEmpty title={tr("forge.portal_billing.invoices_empty_title")} body={tr("forge.portal_billing.invoices_empty_body")} />
|
|
44
|
+
) : (
|
|
45
|
+
<>
|
|
46
|
+
<div role="tablist" aria-label={tr("forge.portal_billing.filter_label")} className="fg:mb-4 fg:flex fg:gap-5">
|
|
47
|
+
{(["open", "all"] as const).map((key) => (
|
|
48
|
+
<button
|
|
49
|
+
key={key}
|
|
50
|
+
type="button"
|
|
51
|
+
role="tab"
|
|
52
|
+
aria-selected={filter === key}
|
|
53
|
+
onClick={() => setFilter(key)}
|
|
54
|
+
style={{
|
|
55
|
+
padding: "4px 0",
|
|
56
|
+
background: "transparent",
|
|
57
|
+
border: "none",
|
|
58
|
+
borderBottom: `2px solid ${filter === key ? t.primary : "transparent"}`,
|
|
59
|
+
color: filter === key ? t.text : t.muted,
|
|
60
|
+
fontWeight: filter === key ? 600 : 500,
|
|
61
|
+
fontFamily: t.fontFamily,
|
|
62
|
+
fontSize: 14,
|
|
63
|
+
cursor: "pointer",
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{key === "open"
|
|
67
|
+
? tr("forge.portal_billing.filter_open", { count: open.length })
|
|
68
|
+
: tr("forge.portal_billing.filter_all", { count: invoices.length })}
|
|
69
|
+
</button>
|
|
70
|
+
))}
|
|
71
|
+
</div>
|
|
72
|
+
{shown.length === 0 ? (
|
|
73
|
+
<PortalEmpty title={tr("forge.portal_billing.nothing_owed")} />
|
|
74
|
+
) : (
|
|
75
|
+
<PortalList>
|
|
76
|
+
{shown.map((inv) => (
|
|
77
|
+
<InvoiceRow key={inv.id} invoice={inv} />
|
|
78
|
+
))}
|
|
79
|
+
</PortalList>
|
|
80
|
+
)}
|
|
81
|
+
</>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function InvoiceRow({ invoice }: { invoice: PortalInvoice }) {
|
|
88
|
+
const t = useThemeTokens();
|
|
89
|
+
const tr = useForgeT();
|
|
90
|
+
const { formatDate } = usePortal();
|
|
91
|
+
const buttons = usePortalButtons();
|
|
92
|
+
const owed = OPEN_STATUSES.has(invoice.status);
|
|
93
|
+
const overdue = invoice.status === "overdue";
|
|
94
|
+
// Literal keys, so the translation spec can see every one of them.
|
|
95
|
+
const statusLabel: Record<string, string> = {
|
|
96
|
+
sent: tr("forge.portal_billing.status_sent"),
|
|
97
|
+
viewed: tr("forge.portal_billing.status_sent"),
|
|
98
|
+
overdue: tr("forge.portal_billing.status_overdue"),
|
|
99
|
+
initiated_payment: tr("forge.portal_billing.status_processing"),
|
|
100
|
+
payment_failed: tr("forge.portal_billing.status_failed"),
|
|
101
|
+
paid: tr("forge.portal_billing.status_paid"),
|
|
102
|
+
cancelled: tr("forge.portal_billing.status_cancelled"),
|
|
103
|
+
refunded: tr("forge.portal_billing.status_refunded"),
|
|
104
|
+
};
|
|
105
|
+
const href = internalPath("invoice/$invoiceId", { invoiceId: invoice.id });
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<PortalRow testId="portal-invoice">
|
|
109
|
+
<div className="fg:min-w-0 fg:flex-1">
|
|
110
|
+
<p style={{ margin: 0, fontWeight: 600 }}>{invoice.invoiceNumber}</p>
|
|
111
|
+
<p className="fg:flex fg:flex-wrap fg:gap-x-3" style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>
|
|
112
|
+
<span style={overdue || invoice.status === "payment_failed" ? { color: t.text, fontWeight: 600 } : undefined}>
|
|
113
|
+
{statusLabel[invoice.status] ?? invoice.status}
|
|
114
|
+
</span>
|
|
115
|
+
{owed && invoice.dueDate ? (
|
|
116
|
+
<span>{tr("forge.portal_billing.due_on", { date: formatDate(invoice.dueDate) })}</span>
|
|
117
|
+
) : null}
|
|
118
|
+
{invoice.paidAt ? <span>{tr("forge.portal_billing.paid_on", { date: formatDate(invoice.paidAt) })}</span> : null}
|
|
119
|
+
{invoice.projectName ? <span>{invoice.projectName}</span> : null}
|
|
120
|
+
</p>
|
|
121
|
+
</div>
|
|
122
|
+
<span style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
|
|
123
|
+
{portalMoney(invoice.total, invoice.currency)}
|
|
124
|
+
</span>
|
|
125
|
+
<PortalLink href={href} style={owed ? buttons.primary : buttons.quiet}>
|
|
126
|
+
{owed ? tr("forge.portal_billing.pay") : tr("forge.portal_billing.view")}
|
|
127
|
+
</PortalLink>
|
|
128
|
+
</PortalRow>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Documents sent to this person (`/i/account/documents`): the ones waiting for
|
|
134
|
+
* their signature first, then everything already signed or closed. Each opens
|
|
135
|
+
* the signing page, which keeps its own email check.
|
|
136
|
+
*/
|
|
137
|
+
export function PortalDocuments() {
|
|
138
|
+
const tr = useForgeT();
|
|
139
|
+
const t = useThemeTokens();
|
|
140
|
+
const { formatDate } = usePortal();
|
|
141
|
+
const buttons = usePortalButtons();
|
|
142
|
+
const { data: documents, isLoading } = usePortalDocuments();
|
|
143
|
+
const waiting = (documents ?? []).filter((d) => d.needsAction);
|
|
144
|
+
const rest = (documents ?? []).filter((d) => !d.needsAction);
|
|
145
|
+
|
|
146
|
+
const row = (doc: NonNullable<typeof documents>[number]) => (
|
|
147
|
+
<PortalRow key={doc.documentId} testId="portal-document">
|
|
148
|
+
<div className="fg:min-w-0 fg:flex-1">
|
|
149
|
+
<p style={{ margin: 0, fontWeight: 600 }}>{doc.title}</p>
|
|
150
|
+
<p className="fg:flex fg:flex-wrap fg:gap-x-3" style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>
|
|
151
|
+
{doc.completedAt ? (
|
|
152
|
+
<span>{tr("forge.portal_billing.signed_on", { date: formatDate(doc.completedAt) })}</span>
|
|
153
|
+
) : (
|
|
154
|
+
<span>{tr("forge.portal_billing.sent_on", { date: formatDate(doc.sentAt) })}</span>
|
|
155
|
+
)}
|
|
156
|
+
{doc.needsAction && doc.expiresAt ? (
|
|
157
|
+
<span style={{ color: t.text }}>{tr("forge.portal_billing.expires_on", { date: formatDate(doc.expiresAt) })}</span>
|
|
158
|
+
) : null}
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
<PortalLink
|
|
162
|
+
href={internalPath("documents/$token", { token: doc.token })}
|
|
163
|
+
style={doc.needsAction ? buttons.primary : buttons.quiet}
|
|
164
|
+
>
|
|
165
|
+
{doc.needsAction ? tr("forge.portal_billing.review_and_sign") : tr("forge.portal_billing.open")}
|
|
166
|
+
</PortalLink>
|
|
167
|
+
</PortalRow>
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<div data-testid="portal-documents">
|
|
172
|
+
<PortalHeading
|
|
173
|
+
title={tr("forge.portal_billing.documents_title")}
|
|
174
|
+
lead={waiting.length > 0 ? tr("forge.portal_billing.documents_lead_waiting", { count: waiting.length }) : undefined}
|
|
175
|
+
/>
|
|
176
|
+
{isLoading ? (
|
|
177
|
+
<Loading />
|
|
178
|
+
) : !documents || documents.length === 0 ? (
|
|
179
|
+
<PortalEmpty title={tr("forge.portal_billing.documents_empty_title")} body={tr("forge.portal_billing.documents_empty_body")} />
|
|
180
|
+
) : (
|
|
181
|
+
<>
|
|
182
|
+
{waiting.length > 0 ? <PortalList style={{ marginBottom: 32 }}>{waiting.map(row)}</PortalList> : null}
|
|
183
|
+
{rest.length > 0 ? (
|
|
184
|
+
<>
|
|
185
|
+
{waiting.length > 0 ? (
|
|
186
|
+
<h2 style={{ margin: "0 0 8px", fontSize: 18, fontWeight: 600, fontFamily: t.headingFontFamily }}>
|
|
187
|
+
{tr("forge.portal_billing.documents_done")}
|
|
188
|
+
</h2>
|
|
189
|
+
) : null}
|
|
190
|
+
<PortalList>{rest.map(row)}</PortalList>
|
|
191
|
+
</>
|
|
192
|
+
) : null}
|
|
193
|
+
</>
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
import { FileSignature, FileText, Paperclip, Receipt, ShoppingBag } from "lucide-react";
|
|
3
|
+
import { useForgeT } from "../../../i18n";
|
|
4
|
+
import { useForge } from "../../../provider/ForgeProvider";
|
|
5
|
+
import { internalPath } from "../../../runtime/pages";
|
|
6
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
7
|
+
import { useWorkOpenSharedDocument } from "../../headless/work/useWorkPortal";
|
|
8
|
+
import {
|
|
9
|
+
usePortalFiles,
|
|
10
|
+
type PortalFile,
|
|
11
|
+
type PortalFileSource,
|
|
12
|
+
type PortalFileSourceFilter,
|
|
13
|
+
} from "../../headless/portal/usePortalFiles";
|
|
14
|
+
import { Loading } from "../Loading";
|
|
15
|
+
import { PortalEmpty, PortalHeading, PortalLink, PortalList, PortalRow, usePortal, usePortalButtons } from "./shared";
|
|
16
|
+
|
|
17
|
+
const FILTERS: PortalFileSourceFilter[] = ["all", "purchase", "project", "task", "document", "invoice"];
|
|
18
|
+
|
|
19
|
+
const ICONS: Record<PortalFileSource, typeof FileText> = {
|
|
20
|
+
project: FileText,
|
|
21
|
+
task: Paperclip,
|
|
22
|
+
document: FileSignature,
|
|
23
|
+
invoice: Receipt,
|
|
24
|
+
purchase: ShoppingBag,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** "1.2 MB". Sizes are bytes; nothing here is worth a locale-aware unit. */
|
|
28
|
+
export function formatFileSize(bytes: number | null): string | null {
|
|
29
|
+
if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return null;
|
|
30
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
31
|
+
let value = bytes;
|
|
32
|
+
let unit = 0;
|
|
33
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
34
|
+
value /= 1024;
|
|
35
|
+
unit += 1;
|
|
36
|
+
}
|
|
37
|
+
return `${unit === 0 ? value : value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every file this person has with the creator (`/i/account/files`): files
|
|
42
|
+
* shared on their projects, attachments on their tasks, signed documents,
|
|
43
|
+
* invoice PDFs and the downloads they paid for, newest first, with a filter
|
|
44
|
+
* per kind.
|
|
45
|
+
*
|
|
46
|
+
* Nothing on the page holds an address it was not given for that purpose: a
|
|
47
|
+
* shared project file asks for a 60-second link on the click, and a signed
|
|
48
|
+
* document or invoice downloads through the endpoint that checks it.
|
|
49
|
+
*/
|
|
50
|
+
export function PortalFiles() {
|
|
51
|
+
const tr = useForgeT();
|
|
52
|
+
const t = useThemeTokens();
|
|
53
|
+
const buttons = usePortalButtons();
|
|
54
|
+
const [filter, setFilter] = useState<PortalFileSourceFilter>("all");
|
|
55
|
+
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } = usePortalFiles(filter);
|
|
56
|
+
const files = data?.pages.flatMap((p) => p.data) ?? [];
|
|
57
|
+
|
|
58
|
+
// Literal keys, so the translation spec can see every one of them.
|
|
59
|
+
const filterLabel: Record<PortalFileSourceFilter, string> = {
|
|
60
|
+
all: tr("forge.portal_files.filter_all"),
|
|
61
|
+
project: tr("forge.portal_files.filter_project"),
|
|
62
|
+
task: tr("forge.portal_files.filter_task"),
|
|
63
|
+
document: tr("forge.portal_files.filter_document"),
|
|
64
|
+
invoice: tr("forge.portal_files.filter_invoice"),
|
|
65
|
+
purchase: tr("forge.portal_files.filter_purchase"),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// With nothing at all, the tabs would only filter an empty list.
|
|
69
|
+
const nothingAtAll = !isLoading && filter === "all" && files.length === 0;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div data-testid="portal-files">
|
|
73
|
+
<PortalHeading title={tr("forge.portal_files.title")} />
|
|
74
|
+
{nothingAtAll ? (
|
|
75
|
+
<PortalEmpty title={tr("forge.portal_files.empty_title")} body={tr("forge.portal_files.empty_body")} />
|
|
76
|
+
) : (
|
|
77
|
+
<>
|
|
78
|
+
<div
|
|
79
|
+
role="tablist"
|
|
80
|
+
aria-label={tr("forge.portal_files.filter_label")}
|
|
81
|
+
className="fg:mb-4 fg:flex fg:flex-wrap fg:gap-x-5 fg:gap-y-2"
|
|
82
|
+
>
|
|
83
|
+
{FILTERS.map((key) => (
|
|
84
|
+
<button
|
|
85
|
+
key={key}
|
|
86
|
+
type="button"
|
|
87
|
+
role="tab"
|
|
88
|
+
aria-selected={filter === key}
|
|
89
|
+
data-testid={`portal-files-filter-${key}`}
|
|
90
|
+
onClick={() => setFilter(key)}
|
|
91
|
+
style={{
|
|
92
|
+
padding: "4px 0",
|
|
93
|
+
background: "transparent",
|
|
94
|
+
border: "none",
|
|
95
|
+
borderBottom: `2px solid ${filter === key ? t.primary : "transparent"}`,
|
|
96
|
+
color: filter === key ? t.text : t.muted,
|
|
97
|
+
fontWeight: filter === key ? 600 : 500,
|
|
98
|
+
fontFamily: t.fontFamily,
|
|
99
|
+
fontSize: 14,
|
|
100
|
+
cursor: "pointer",
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{filterLabel[key]}
|
|
104
|
+
</button>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
{isLoading ? (
|
|
108
|
+
<Loading />
|
|
109
|
+
) : files.length === 0 ? (
|
|
110
|
+
<PortalEmpty title={tr("forge.portal_files.filter_empty")} />
|
|
111
|
+
) : (
|
|
112
|
+
<>
|
|
113
|
+
<PortalList>
|
|
114
|
+
{files.map((file) => (
|
|
115
|
+
<FileRow key={`${file.source}:${file.id}`} file={file} />
|
|
116
|
+
))}
|
|
117
|
+
</PortalList>
|
|
118
|
+
{hasNextPage ? (
|
|
119
|
+
<div className="fg:mt-6 fg:flex fg:justify-center">
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
data-testid="portal-files-more"
|
|
123
|
+
onClick={() => void fetchNextPage()}
|
|
124
|
+
disabled={isFetchingNextPage}
|
|
125
|
+
style={{ ...buttons.quiet, opacity: isFetchingNextPage ? 0.6 : 1 }}
|
|
126
|
+
>
|
|
127
|
+
{tr("forge.portal_files.show_more")}
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
) : null}
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function FileRow({ file }: { file: PortalFile }) {
|
|
140
|
+
const t = useThemeTokens();
|
|
141
|
+
const tr = useForgeT();
|
|
142
|
+
const { formatDate } = usePortal();
|
|
143
|
+
const [error, setError] = useState<string | null>(null);
|
|
144
|
+
const Icon = ICONS[file.source];
|
|
145
|
+
const size = formatFileSize(file.size);
|
|
146
|
+
const title = file.source === "invoice" ? tr("forge.portal_files.invoice_title", { number: file.title }) : file.title;
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<PortalRow testId="portal-file">
|
|
150
|
+
<span
|
|
151
|
+
aria-hidden
|
|
152
|
+
className="fg:flex fg:h-9 fg:w-9 fg:shrink-0 fg:items-center fg:justify-center"
|
|
153
|
+
style={{ borderRadius: t.cornerRadius, border: `1px solid ${t.border}`, color: t.muted }}
|
|
154
|
+
>
|
|
155
|
+
<Icon size={16} />
|
|
156
|
+
</span>
|
|
157
|
+
<div className="fg:min-w-0 fg:flex-1">
|
|
158
|
+
<p className="fg:truncate" style={{ margin: 0, fontWeight: 600 }} title={file.filename ?? undefined}>
|
|
159
|
+
{title}
|
|
160
|
+
</p>
|
|
161
|
+
<p className="fg:flex fg:flex-wrap fg:gap-x-3" style={{ margin: "2px 0 0", fontSize: 13, color: t.muted }}>
|
|
162
|
+
{file.projectName ? <span>{file.projectName}</span> : null}
|
|
163
|
+
<span>{formatDate(file.createdAt)}</span>
|
|
164
|
+
{size ? <span>{size}</span> : null}
|
|
165
|
+
</p>
|
|
166
|
+
{error ? (
|
|
167
|
+
<p role="alert" style={{ margin: "4px 0 0", fontSize: 13, color: t.text, fontWeight: 600 }}>
|
|
168
|
+
{error}
|
|
169
|
+
</p>
|
|
170
|
+
) : null}
|
|
171
|
+
</div>
|
|
172
|
+
<FileAction file={file} onError={setError} />
|
|
173
|
+
</PortalRow>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function FileAction({ file, onError }: { file: PortalFile; onError: (message: string | null) => void }) {
|
|
178
|
+
const tr = useForgeT();
|
|
179
|
+
const { apiUrl } = useForge();
|
|
180
|
+
const buttons = usePortalButtons();
|
|
181
|
+
const api = (apiUrl ?? "").replace(/\/$/, "");
|
|
182
|
+
const external = (href: string, label: ReactNode, download = false) => (
|
|
183
|
+
<a
|
|
184
|
+
href={href}
|
|
185
|
+
target="_blank"
|
|
186
|
+
rel="noopener noreferrer"
|
|
187
|
+
download={download || undefined}
|
|
188
|
+
data-testid="portal-file-action"
|
|
189
|
+
style={buttons.quiet}
|
|
190
|
+
>
|
|
191
|
+
{label}
|
|
192
|
+
</a>
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
switch (file.open.kind) {
|
|
196
|
+
case "project_document":
|
|
197
|
+
return (
|
|
198
|
+
<ProjectDocumentAction projectId={file.open.projectId} documentId={file.open.documentId} onError={onError} />
|
|
199
|
+
);
|
|
200
|
+
case "task_attachment":
|
|
201
|
+
return external(file.open.url, tr("forge.portal_files.open"));
|
|
202
|
+
case "signed_document":
|
|
203
|
+
return api ? (
|
|
204
|
+
external(
|
|
205
|
+
`${api}/public/documents/${encodeURIComponent(file.open.token)}/download`,
|
|
206
|
+
tr("forge.portal_files.download"),
|
|
207
|
+
true,
|
|
208
|
+
)
|
|
209
|
+
) : (
|
|
210
|
+
<PortalLink href={internalPath("documents/$token", { token: file.open.token })} style={buttons.quiet}>
|
|
211
|
+
{tr("forge.portal_files.open")}
|
|
212
|
+
</PortalLink>
|
|
213
|
+
);
|
|
214
|
+
case "invoice_pdf":
|
|
215
|
+
return api ? (
|
|
216
|
+
external(
|
|
217
|
+
`${api}/public/invoices/${encodeURIComponent(file.open.invoiceId)}/pdf`,
|
|
218
|
+
tr("forge.portal_files.download"),
|
|
219
|
+
true,
|
|
220
|
+
)
|
|
221
|
+
) : (
|
|
222
|
+
<PortalLink href={internalPath("invoice/$invoiceId", { invoiceId: file.open.invoiceId })} style={buttons.quiet}>
|
|
223
|
+
{tr("forge.portal_files.open")}
|
|
224
|
+
</PortalLink>
|
|
225
|
+
);
|
|
226
|
+
case "purchase_download":
|
|
227
|
+
// The download page, never a file address: it signs each file on the
|
|
228
|
+
// click, after checking it is this person's.
|
|
229
|
+
return (
|
|
230
|
+
<PortalLink
|
|
231
|
+
href={internalPath("downloads/$token", { token: file.open.token })}
|
|
232
|
+
data-testid="portal-file-action"
|
|
233
|
+
style={buttons.quiet}
|
|
234
|
+
>
|
|
235
|
+
{tr("forge.portal_files.download")}
|
|
236
|
+
</PortalLink>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A shared project file has no address until the click asks for one: a
|
|
243
|
+
* sixty-second signed read, minted and audited per open, followed once.
|
|
244
|
+
*/
|
|
245
|
+
function ProjectDocumentAction({
|
|
246
|
+
projectId,
|
|
247
|
+
documentId,
|
|
248
|
+
onError,
|
|
249
|
+
}: {
|
|
250
|
+
projectId: string;
|
|
251
|
+
documentId: string;
|
|
252
|
+
onError: (message: string | null) => void;
|
|
253
|
+
}) {
|
|
254
|
+
const tr = useForgeT();
|
|
255
|
+
const buttons = usePortalButtons();
|
|
256
|
+
const open = useWorkOpenSharedDocument(projectId);
|
|
257
|
+
|
|
258
|
+
const onClick = async () => {
|
|
259
|
+
onError(null);
|
|
260
|
+
try {
|
|
261
|
+
const { url } = await open.mutateAsync(documentId);
|
|
262
|
+
window.open(url, "_blank", "noopener");
|
|
263
|
+
} catch {
|
|
264
|
+
onError(tr("forge.portal_files.open_error"));
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
data-testid="portal-file-action"
|
|
272
|
+
onClick={() => void onClick()}
|
|
273
|
+
disabled={open.isPending}
|
|
274
|
+
style={{ ...buttons.quiet, cursor: open.isPending ? "wait" : "pointer", opacity: open.isPending ? 0.6 : 1 }}
|
|
275
|
+
>
|
|
276
|
+
{tr("forge.portal_files.open")}
|
|
277
|
+
</button>
|
|
278
|
+
);
|
|
279
|
+
}
|