@tribe-nest/forge 1.13.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 +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- package/src/utils/structuredData.ts +126 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { CheckCircle2, FolderOpen } from "lucide-react";
|
|
3
|
+
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
5
|
+
import { ACCESS_TOKEN_KEY } from "../../../contexts/PublicAuthContext";
|
|
6
|
+
import { Loading } from "../Loading";
|
|
7
|
+
import { useAcceptWorkInvite, useWorkInvite } from "../../headless/work/useWorkPortal";
|
|
8
|
+
|
|
9
|
+
export interface WorkInviteAcceptProps {
|
|
10
|
+
/** Invite token from the email link (read from search params by the shell). */
|
|
11
|
+
token?: string;
|
|
12
|
+
/** Project id from the email link. */
|
|
13
|
+
projectId?: string;
|
|
14
|
+
/** Base path for a project in the portal. The project link is
|
|
15
|
+
* `${projectHrefBase}/${projectId}`. Defaults to `/i/work/projects`. */
|
|
16
|
+
projectHrefBase?: string;
|
|
17
|
+
/** Href to the portal login (shown on an invalid/expired link). */
|
|
18
|
+
loginHref?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const msg = (e: unknown) => (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Branded, AUTH-AWARE client-invite accept page. Shows who invited the client
|
|
25
|
+
* to which project, then:
|
|
26
|
+
* • already signed in as the invited person → "View project" (no password),
|
|
27
|
+
* • signed in as someone else → accept as the invited person (set password),
|
|
28
|
+
* • not signed in → set a password to accept (mints a portal session).
|
|
29
|
+
* On accept it persists the returned session token and navigates to the
|
|
30
|
+
* project. Theme tokens only — it inherits the artist's site styling.
|
|
31
|
+
*/
|
|
32
|
+
export function WorkInviteAccept({
|
|
33
|
+
token,
|
|
34
|
+
projectId,
|
|
35
|
+
projectHrefBase = "/i/work/projects",
|
|
36
|
+
loginHref = "/i/login",
|
|
37
|
+
}: WorkInviteAcceptProps) {
|
|
38
|
+
const theme = useForgeTheme();
|
|
39
|
+
const { isAuthenticated, user } = usePublicAuth();
|
|
40
|
+
const { data: invite, isLoading, isError } = useWorkInvite(token, projectId);
|
|
41
|
+
const acceptInvite = useAcceptWorkInvite();
|
|
42
|
+
|
|
43
|
+
const [password, setPassword] = useState("");
|
|
44
|
+
const [confirm, setConfirm] = useState("");
|
|
45
|
+
const [error, setError] = useState("");
|
|
46
|
+
const [submitting, setSubmitting] = useState(false);
|
|
47
|
+
|
|
48
|
+
const projectHref = `${projectHrefBase}/${projectId}`;
|
|
49
|
+
|
|
50
|
+
const card: React.CSSProperties = {
|
|
51
|
+
maxWidth: 460,
|
|
52
|
+
margin: "40px auto 0",
|
|
53
|
+
padding: 28,
|
|
54
|
+
border: `1px solid ${theme.colors.primary}33`,
|
|
55
|
+
borderRadius: theme.cornerRadius,
|
|
56
|
+
color: theme.colors.text,
|
|
57
|
+
background: theme.colors.background,
|
|
58
|
+
};
|
|
59
|
+
const input: React.CSSProperties = {
|
|
60
|
+
width: "100%",
|
|
61
|
+
height: 42,
|
|
62
|
+
padding: "8px 12px",
|
|
63
|
+
borderRadius: theme.cornerRadius,
|
|
64
|
+
border: `1px solid ${theme.colors.primary}40`,
|
|
65
|
+
background: "transparent",
|
|
66
|
+
color: theme.colors.text,
|
|
67
|
+
outline: "none",
|
|
68
|
+
};
|
|
69
|
+
const button: React.CSSProperties = {
|
|
70
|
+
width: "100%",
|
|
71
|
+
padding: "11px 14px",
|
|
72
|
+
borderRadius: theme.cornerRadius,
|
|
73
|
+
background: theme.colors.primary,
|
|
74
|
+
color: theme.colors.background,
|
|
75
|
+
border: "none",
|
|
76
|
+
fontWeight: 700,
|
|
77
|
+
cursor: "pointer",
|
|
78
|
+
textAlign: "center",
|
|
79
|
+
textDecoration: "none",
|
|
80
|
+
display: "inline-flex",
|
|
81
|
+
alignItems: "center",
|
|
82
|
+
justifyContent: "center",
|
|
83
|
+
gap: 8,
|
|
84
|
+
};
|
|
85
|
+
const link: React.CSSProperties = { color: theme.colors.primary, textDecoration: "underline" };
|
|
86
|
+
const eyebrow: React.CSSProperties = { fontSize: 13, color: theme.colors.primary, fontWeight: 700, letterSpacing: 0.3 };
|
|
87
|
+
const heading: React.CSSProperties = { fontSize: 22, fontWeight: 800, margin: "6px 0 14px" };
|
|
88
|
+
const sub: React.CSSProperties = { fontSize: 14, color: `${theme.colors.text}b3`, marginBottom: 22 };
|
|
89
|
+
|
|
90
|
+
if (!token || !projectId) {
|
|
91
|
+
return (
|
|
92
|
+
<div style={card} data-testid="work-invite-accept">
|
|
93
|
+
<h1 style={heading} data-testid="work-invite-invalid">
|
|
94
|
+
Invalid invite link
|
|
95
|
+
</h1>
|
|
96
|
+
<p style={sub}>This invite link is missing information. Please use the link from your email.</p>
|
|
97
|
+
<a href={loginHref} style={link}>
|
|
98
|
+
Go to sign in
|
|
99
|
+
</a>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isLoading) {
|
|
105
|
+
return (
|
|
106
|
+
<div style={card} data-testid="work-invite-accept">
|
|
107
|
+
<Loading />
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (isError || !invite) {
|
|
113
|
+
return (
|
|
114
|
+
<div style={card} data-testid="work-invite-accept">
|
|
115
|
+
<h1 style={heading} data-testid="work-invite-invalid">
|
|
116
|
+
Invite not available
|
|
117
|
+
</h1>
|
|
118
|
+
<p style={sub}>This invite is invalid or has expired. Ask your provider to send a new one.</p>
|
|
119
|
+
<a href={loginHref} style={link}>
|
|
120
|
+
Go to sign in
|
|
121
|
+
</a>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const invitedEmail = invite.email?.toLowerCase() ?? "";
|
|
127
|
+
const currentEmail = (user?.email ?? "").toLowerCase();
|
|
128
|
+
const signedInAsInvitee = isAuthenticated && currentEmail && currentEmail === invitedEmail;
|
|
129
|
+
|
|
130
|
+
const header = (
|
|
131
|
+
<>
|
|
132
|
+
<div style={eyebrow}>{invite.profileName}</div>
|
|
133
|
+
<h1 style={heading} data-testid="work-invite-heading">
|
|
134
|
+
You're invited to <span style={{ color: theme.colors.primary }}>{invite.projectName}</span>
|
|
135
|
+
</h1>
|
|
136
|
+
</>
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Already signed in AS the invited person → nothing to do but open the project.
|
|
140
|
+
if (signedInAsInvitee) {
|
|
141
|
+
return (
|
|
142
|
+
<div style={card} data-testid="work-invite-accept">
|
|
143
|
+
{header}
|
|
144
|
+
<p style={sub}>
|
|
145
|
+
<CheckCircle2 size={16} style={{ verticalAlign: "-2px", color: theme.colors.primary }} /> You're signed in as{" "}
|
|
146
|
+
<strong>{invite.email}</strong>. You already have access.
|
|
147
|
+
</p>
|
|
148
|
+
<a href={projectHref} style={button} data-testid="work-invite-view-project">
|
|
149
|
+
<FolderOpen size={16} /> View project
|
|
150
|
+
</a>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const onAccept = async (e: React.FormEvent) => {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
setError("");
|
|
158
|
+
if (password.length < 8) {
|
|
159
|
+
setError("Password must be at least 8 characters.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (password !== confirm) {
|
|
163
|
+
setError("Passwords don't match.");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
setSubmitting(true);
|
|
167
|
+
try {
|
|
168
|
+
const result = await acceptInvite.mutateAsync({ token, projectId, password });
|
|
169
|
+
// Persist the fresh portal session and hard-navigate so the auth context
|
|
170
|
+
// re-initializes as the invited client, then lands on the project.
|
|
171
|
+
localStorage.setItem(ACCESS_TOKEN_KEY, result.token);
|
|
172
|
+
window.location.assign(projectHref);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
setError(msg(err) || "Something went wrong. Please try again.");
|
|
175
|
+
setSubmitting(false);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div style={card} data-testid="work-invite-accept">
|
|
181
|
+
{header}
|
|
182
|
+
<p style={sub}>
|
|
183
|
+
{isAuthenticated && currentEmail && currentEmail !== invitedEmail ? (
|
|
184
|
+
<>
|
|
185
|
+
You're signed in as <strong>{user?.email}</strong>, but this invite is for{" "}
|
|
186
|
+
<strong>{invite.email}</strong>. Set a password to continue as {invite.email}.
|
|
187
|
+
</>
|
|
188
|
+
) : (
|
|
189
|
+
<>
|
|
190
|
+
Set a password for <strong>{invite.email}</strong> to access your client portal.
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
</p>
|
|
194
|
+
{error && (
|
|
195
|
+
<p style={{ color: "#ef4444", marginBottom: 14, fontSize: 14 }} data-testid="work-invite-error">
|
|
196
|
+
{error}
|
|
197
|
+
</p>
|
|
198
|
+
)}
|
|
199
|
+
<form onSubmit={onAccept} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
|
200
|
+
<input
|
|
201
|
+
type="password"
|
|
202
|
+
placeholder="Create a password"
|
|
203
|
+
value={password}
|
|
204
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
205
|
+
style={input}
|
|
206
|
+
data-testid="work-invite-password"
|
|
207
|
+
/>
|
|
208
|
+
<input
|
|
209
|
+
type="password"
|
|
210
|
+
placeholder="Confirm password"
|
|
211
|
+
value={confirm}
|
|
212
|
+
onChange={(e) => setConfirm(e.target.value)}
|
|
213
|
+
style={input}
|
|
214
|
+
data-testid="work-invite-confirm"
|
|
215
|
+
/>
|
|
216
|
+
<button type="submit" disabled={submitting} style={button} data-testid="work-invite-submit">
|
|
217
|
+
{submitting ? "Accepting…" : "Accept invite"}
|
|
218
|
+
</button>
|
|
219
|
+
</form>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { ArrowRight, FolderKanban } from "lucide-react";
|
|
2
|
+
import { useThemeTokens, type ResolvedThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
3
|
+
import { Loading } from "../Loading";
|
|
4
|
+
import {
|
|
5
|
+
formatWorkDate,
|
|
6
|
+
useWorkPortalProjects,
|
|
7
|
+
type WorkProjectSummary,
|
|
8
|
+
} from "../../headless/work/useWorkPortal";
|
|
9
|
+
|
|
10
|
+
export interface WorkPortalProjectsProps {
|
|
11
|
+
/** Build the href for a project's report page. Rendered as an `<a>` unless
|
|
12
|
+
* `onOpenProject` is supplied. Defaults to `/i/work/projects/{id}`. */
|
|
13
|
+
projectHref?: (projectId: string) => string;
|
|
14
|
+
/** Router-driven open handler. When provided, cards render as buttons calling
|
|
15
|
+
* this instead of anchors (lets the host app keep its own navigation/prefix). */
|
|
16
|
+
onOpenProject?: (projectId: string) => void;
|
|
17
|
+
/** Heading above the grid. Pass `null` to hide it. */
|
|
18
|
+
heading?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The authenticated client's read-only list of their (non-archived) client
|
|
23
|
+
* projects, as cards with a task/done progress bar. Built on
|
|
24
|
+
* `useWorkPortalProjects`; theme tokens only. Tapping a card opens the project
|
|
25
|
+
* report (`WorkProjectReport`).
|
|
26
|
+
*/
|
|
27
|
+
export function WorkPortalProjects({
|
|
28
|
+
projectHref = (id) => `/i/work/projects/${id}`,
|
|
29
|
+
onOpenProject,
|
|
30
|
+
heading = "Your projects",
|
|
31
|
+
}: WorkPortalProjectsProps = {}) {
|
|
32
|
+
const t = useThemeTokens();
|
|
33
|
+
const { data: projects, isLoading } = useWorkPortalProjects();
|
|
34
|
+
|
|
35
|
+
if (isLoading) {
|
|
36
|
+
return <Loading fullPage />;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (!projects || projects.length === 0) {
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
data-testid="work-portal-empty"
|
|
43
|
+
style={{
|
|
44
|
+
border: `1px solid ${t.border}`,
|
|
45
|
+
borderRadius: t.cornerRadius,
|
|
46
|
+
background: t.surface,
|
|
47
|
+
padding: 32,
|
|
48
|
+
textAlign: "center",
|
|
49
|
+
color: t.text,
|
|
50
|
+
fontFamily: t.fontFamily,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<FolderKanban size={28} style={{ margin: "0 auto 12px", color: t.primary }} />
|
|
54
|
+
<p style={{ margin: 0, color: t.muted }}>You don't have any projects yet.</p>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
data-testid="work-portal-projects"
|
|
62
|
+
style={{ display: "flex", flexDirection: "column", gap: 24, color: t.text, fontFamily: t.fontFamily }}
|
|
63
|
+
>
|
|
64
|
+
{heading && <h1 style={{ fontSize: 22, fontWeight: 800, margin: 0, color: t.text }}>{heading}</h1>}
|
|
65
|
+
<div
|
|
66
|
+
style={{
|
|
67
|
+
display: "grid",
|
|
68
|
+
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
|
|
69
|
+
gap: 12,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{projects.map((p) => (
|
|
73
|
+
<ProjectCard
|
|
74
|
+
key={p.id}
|
|
75
|
+
project={p}
|
|
76
|
+
t={t}
|
|
77
|
+
href={onOpenProject ? undefined : projectHref(p.id)}
|
|
78
|
+
onOpen={onOpenProject ? () => onOpenProject(p.id) : undefined}
|
|
79
|
+
/>
|
|
80
|
+
))}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function ProjectCard({
|
|
87
|
+
project,
|
|
88
|
+
t,
|
|
89
|
+
href,
|
|
90
|
+
onOpen,
|
|
91
|
+
}: {
|
|
92
|
+
project: WorkProjectSummary;
|
|
93
|
+
t: ResolvedThemeTokens;
|
|
94
|
+
href?: string;
|
|
95
|
+
onOpen?: () => void;
|
|
96
|
+
}) {
|
|
97
|
+
const pct = project.taskCount > 0 ? Math.round((project.doneCount / project.taskCount) * 100) : 0;
|
|
98
|
+
|
|
99
|
+
const inner = (
|
|
100
|
+
<>
|
|
101
|
+
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
|
|
102
|
+
<span style={{ fontWeight: 600, color: t.text }}>{project.name}</span>
|
|
103
|
+
<ArrowRight size={16} style={{ color: t.muted, flexShrink: 0 }} />
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
107
|
+
<span
|
|
108
|
+
style={{
|
|
109
|
+
fontSize: 12,
|
|
110
|
+
fontWeight: 600,
|
|
111
|
+
padding: "2px 8px",
|
|
112
|
+
borderRadius: 999,
|
|
113
|
+
textTransform: "capitalize",
|
|
114
|
+
background: t.border,
|
|
115
|
+
color: t.primary,
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{project.status.replace(/_/g, " ")}
|
|
119
|
+
</span>
|
|
120
|
+
<span style={{ fontSize: 12, color: t.muted }}>Due {formatWorkDate(project.dueDate)}</span>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
124
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: 12, color: t.muted }}>
|
|
125
|
+
<span>
|
|
126
|
+
{project.doneCount} / {project.taskCount} tasks
|
|
127
|
+
</span>
|
|
128
|
+
<span>{pct}%</span>
|
|
129
|
+
</div>
|
|
130
|
+
<div style={{ width: "100%", height: 6, borderRadius: 999, overflow: "hidden", background: t.border }}>
|
|
131
|
+
<div style={{ height: "100%", borderRadius: 999, width: `${pct}%`, background: t.primary }} />
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const style: React.CSSProperties = {
|
|
138
|
+
display: "flex",
|
|
139
|
+
flexDirection: "column",
|
|
140
|
+
gap: 12,
|
|
141
|
+
textAlign: "left",
|
|
142
|
+
padding: 20,
|
|
143
|
+
cursor: "pointer",
|
|
144
|
+
border: `1px solid ${t.border}`,
|
|
145
|
+
borderRadius: t.cornerRadius,
|
|
146
|
+
background: t.surface,
|
|
147
|
+
color: t.text,
|
|
148
|
+
textDecoration: "none",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (onOpen) {
|
|
152
|
+
return (
|
|
153
|
+
<button type="button" data-testid="work-project-card" onClick={onOpen} style={style}>
|
|
154
|
+
{inner}
|
|
155
|
+
</button>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return (
|
|
159
|
+
<a data-testid="work-project-card" href={href} style={style}>
|
|
160
|
+
{inner}
|
|
161
|
+
</a>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
2
|
+
import {
|
|
3
|
+
formatWorkDate,
|
|
4
|
+
formatWorkMoney,
|
|
5
|
+
useWorkProjectInvoices,
|
|
6
|
+
type WorkProjectInvoice,
|
|
7
|
+
} from "../../headless/work/useWorkPortal";
|
|
8
|
+
|
|
9
|
+
export interface WorkProjectInvoicesProps {
|
|
10
|
+
/** The client project id. */
|
|
11
|
+
projectId: string;
|
|
12
|
+
/** Build the href for an invoice's pay/detail page. Defaults to
|
|
13
|
+
* `/i/invoice/{id}`. */
|
|
14
|
+
invoiceHref?: (invoiceId: string) => string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const STATUS_LABEL: Record<string, string> = {
|
|
18
|
+
sent: "Awaiting payment",
|
|
19
|
+
overdue: "Overdue",
|
|
20
|
+
initiated_payment: "Payment started",
|
|
21
|
+
payment_failed: "Payment failed",
|
|
22
|
+
paid: "Paid",
|
|
23
|
+
cancelled: "Cancelled",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The client's invoices for a project, with a Pay button for anything still
|
|
28
|
+
* owing (links to the hosted invoice page). Read-only otherwise. Built on
|
|
29
|
+
* `useWorkProjectInvoices`; theme tokens only. Renders nothing when there are
|
|
30
|
+
* no invoices yet.
|
|
31
|
+
*/
|
|
32
|
+
export function WorkProjectInvoices({ projectId, invoiceHref }: WorkProjectInvoicesProps) {
|
|
33
|
+
const t = useThemeTokens();
|
|
34
|
+
const { data: invoices, isLoading } = useWorkProjectInvoices(projectId);
|
|
35
|
+
const href = invoiceHref ?? ((id: string) => `/i/invoice/${id}`);
|
|
36
|
+
|
|
37
|
+
if (isLoading || !invoices || invoices.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
const statusColor = (inv: WorkProjectInvoice) =>
|
|
40
|
+
inv.status === "paid" ? t.primary : inv.status === "overdue" || inv.status === "payment_failed" ? "#ef4444" : t.muted;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
data-testid="work-project-invoices"
|
|
45
|
+
style={{
|
|
46
|
+
border: `1px solid ${t.border}`,
|
|
47
|
+
borderRadius: t.cornerRadius,
|
|
48
|
+
background: t.surface,
|
|
49
|
+
padding: 20,
|
|
50
|
+
color: t.text,
|
|
51
|
+
fontFamily: t.fontFamily,
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<h2 style={{ fontSize: 16, fontWeight: 800, margin: "0 0 14px" }}>Invoices</h2>
|
|
55
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
56
|
+
{invoices.map((inv) => (
|
|
57
|
+
<div
|
|
58
|
+
key={inv.id}
|
|
59
|
+
data-testid={`work-invoice-${inv.id}`}
|
|
60
|
+
style={{
|
|
61
|
+
display: "flex",
|
|
62
|
+
alignItems: "center",
|
|
63
|
+
justifyContent: "space-between",
|
|
64
|
+
gap: 12,
|
|
65
|
+
flexWrap: "wrap",
|
|
66
|
+
padding: "12px 14px",
|
|
67
|
+
border: `1px solid ${t.border}`,
|
|
68
|
+
borderRadius: t.cornerRadius,
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
72
|
+
<span style={{ fontWeight: 700 }}>{inv.invoiceNumber}</span>
|
|
73
|
+
<span style={{ fontSize: 12, color: statusColor(inv) }}>
|
|
74
|
+
{STATUS_LABEL[inv.status] ?? inv.status}
|
|
75
|
+
{inv.status === "paid" && inv.paidAt ? ` · ${formatWorkDate(inv.paidAt)}` : ""}
|
|
76
|
+
{inv.payable && inv.dueDate ? ` · due ${formatWorkDate(inv.dueDate)}` : ""}
|
|
77
|
+
</span>
|
|
78
|
+
</div>
|
|
79
|
+
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
|
80
|
+
<span style={{ fontWeight: 700 }}>{formatWorkMoney(inv.total, inv.currency)}</span>
|
|
81
|
+
{inv.payable ? (
|
|
82
|
+
<a
|
|
83
|
+
href={href(inv.id)}
|
|
84
|
+
data-testid={`work-invoice-pay-${inv.id}`}
|
|
85
|
+
style={{
|
|
86
|
+
display: "inline-block",
|
|
87
|
+
padding: "8px 16px",
|
|
88
|
+
borderRadius: t.cornerRadius,
|
|
89
|
+
background: t.primary,
|
|
90
|
+
color: t.background,
|
|
91
|
+
fontWeight: 700,
|
|
92
|
+
fontSize: 13,
|
|
93
|
+
textDecoration: "none",
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
Pay
|
|
97
|
+
</a>
|
|
98
|
+
) : (
|
|
99
|
+
<a
|
|
100
|
+
href={href(inv.id)}
|
|
101
|
+
data-testid={`work-invoice-view-${inv.id}`}
|
|
102
|
+
style={{ color: t.primary, fontSize: 13, textDecoration: "underline" }}
|
|
103
|
+
>
|
|
104
|
+
View
|
|
105
|
+
</a>
|
|
106
|
+
)}
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
2
|
+
import { Loading } from "../Loading";
|
|
3
|
+
import { useWorkProjectReport } from "../../headless/work/useWorkPortal";
|
|
4
|
+
import { WorkReportBody } from "./WorkReportBody";
|
|
5
|
+
import { WorkProjectInvoices } from "./WorkProjectInvoices";
|
|
6
|
+
|
|
7
|
+
export interface WorkProjectReportProps {
|
|
8
|
+
/** The client project id (from the route). */
|
|
9
|
+
projectId: string;
|
|
10
|
+
/** Build the href for a task's detail/comments page. Defaults to
|
|
11
|
+
* `/i/work/projects/{projectId}/tasks/{taskId}`. Pass `null` to keep tasks
|
|
12
|
+
* read-only (no per-task navigation). */
|
|
13
|
+
taskHref?: ((taskId: string) => string) | null;
|
|
14
|
+
/** Router-driven open handler for a task. When provided, task rows render as
|
|
15
|
+
* buttons calling this instead of anchors. */
|
|
16
|
+
onOpenTask?: (taskId: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The authenticated client's read-only project report — milestones, task list
|
|
21
|
+
* and the billable-only time report. Each task links to its detail/comments
|
|
22
|
+
* page. Built on `useWorkProjectReport`; theme tokens only.
|
|
23
|
+
*/
|
|
24
|
+
export function WorkProjectReport({ projectId, taskHref, onOpenTask }: WorkProjectReportProps) {
|
|
25
|
+
const t = useThemeTokens();
|
|
26
|
+
const { data: report, isLoading, isError } = useWorkProjectReport(projectId);
|
|
27
|
+
|
|
28
|
+
if (isLoading) {
|
|
29
|
+
return <Loading fullPage />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (isError || !report) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
data-testid="work-project-report-error"
|
|
36
|
+
style={{
|
|
37
|
+
border: `1px solid ${t.border}`,
|
|
38
|
+
borderRadius: t.cornerRadius,
|
|
39
|
+
background: t.surface,
|
|
40
|
+
padding: 32,
|
|
41
|
+
textAlign: "center",
|
|
42
|
+
color: t.text,
|
|
43
|
+
fontFamily: t.fontFamily,
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<p style={{ margin: 0, color: t.muted }}>We couldn't find this project.</p>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const resolvedTaskHref =
|
|
52
|
+
taskHref === null
|
|
53
|
+
? undefined
|
|
54
|
+
: onOpenTask
|
|
55
|
+
? undefined
|
|
56
|
+
: (taskHref ?? ((taskId: string) => `/i/work/projects/${projectId}/tasks/${taskId}`));
|
|
57
|
+
|
|
58
|
+
// The invoices section is authed-only (not part of the login-free token
|
|
59
|
+
// share, which renders WorkReportBody directly).
|
|
60
|
+
return (
|
|
61
|
+
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
|
62
|
+
<WorkReportBody report={report} taskHref={resolvedTaskHref} onOpenTask={onOpenTask} />
|
|
63
|
+
<WorkProjectInvoices projectId={projectId} />
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|