@tribe-nest/forge 3.26.0 → 3.31.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 +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ILiveBroadcast } from "../../../types/models";
|
|
2
|
+
import { useForgeT } from "../../../i18n";
|
|
3
|
+
import { useForgeTheme } from "../../theme/ForgeThemeProvider";
|
|
4
|
+
import { CallHelpHint } from "../../media/CallHelpHint";
|
|
5
|
+
|
|
6
|
+
export interface EndedBroadcastProps {
|
|
7
|
+
broadcast: ILiveBroadcast;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The screen a viewer lands on when the broadcast is over, including when it
|
|
12
|
+
* went off air under them a minute ago.
|
|
13
|
+
*
|
|
14
|
+
* The second case is the reason this page says as much as it does. A host on a
|
|
15
|
+
* bad connection drops and comes back, and a viewer with nothing on screen
|
|
16
|
+
* assumes the event is finished and leaves. Everything here that tells them what
|
|
17
|
+
* to DO stays visible. The one sentence that only explains WHY it happened sits
|
|
18
|
+
* behind the `?`, per the house design law.
|
|
19
|
+
*/
|
|
20
|
+
export function EndedBroadcast({ broadcast }: EndedBroadcastProps) {
|
|
21
|
+
const t = useForgeT();
|
|
22
|
+
const theme = useForgeTheme();
|
|
23
|
+
const thumb = broadcast.thumbnailUrl || broadcast.generatedThumbnailUrl;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div style={{ padding: "0 16px", flex: 1, color: theme.colors.text, fontFamily: theme.fontFamily }}>
|
|
27
|
+
<div
|
|
28
|
+
style={{
|
|
29
|
+
marginTop: 40,
|
|
30
|
+
maxWidth: 500,
|
|
31
|
+
marginInline: "auto",
|
|
32
|
+
backgroundColor: theme.colors.background,
|
|
33
|
+
borderRadius: theme.cornerRadius,
|
|
34
|
+
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
|
|
35
|
+
border: `1px solid ${theme.colors.primary}30`,
|
|
36
|
+
overflow: "hidden",
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<div
|
|
40
|
+
style={{
|
|
41
|
+
aspectRatio: "16/9",
|
|
42
|
+
position: "relative",
|
|
43
|
+
display: "flex",
|
|
44
|
+
alignItems: "center",
|
|
45
|
+
justifyContent: "center",
|
|
46
|
+
background: `linear-gradient(135deg, ${theme.colors.primary}20, ${theme.colors.primary}10)`,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{thumb && (
|
|
50
|
+
<img
|
|
51
|
+
src={thumb}
|
|
52
|
+
alt={broadcast.title}
|
|
53
|
+
style={{ width: "100%", height: "100%", objectFit: "cover", position: "absolute", top: 0, left: 0 }}
|
|
54
|
+
/>
|
|
55
|
+
)}
|
|
56
|
+
<p
|
|
57
|
+
data-testid="broadcast-ended-banner"
|
|
58
|
+
style={{
|
|
59
|
+
position: "relative",
|
|
60
|
+
zIndex: 1,
|
|
61
|
+
fontSize: 24,
|
|
62
|
+
fontWeight: 500,
|
|
63
|
+
padding: 16,
|
|
64
|
+
borderRadius: 8,
|
|
65
|
+
background: "rgba(0, 0, 0, 0.5)",
|
|
66
|
+
color: "#ffffff",
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
{t("forge.ended_broadcast.banner")}
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div style={{ padding: 16 }}>
|
|
74
|
+
<h3 style={{ fontWeight: 600 }}>{broadcast.title}</h3>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div style={{ marginTop: 16, width: "100%", maxWidth: 500, marginInline: "auto" }}>
|
|
79
|
+
<h4
|
|
80
|
+
style={{
|
|
81
|
+
color: theme.colors.primary,
|
|
82
|
+
fontWeight: 600,
|
|
83
|
+
marginBottom: 8,
|
|
84
|
+
fontSize: 18,
|
|
85
|
+
display: "flex",
|
|
86
|
+
alignItems: "center",
|
|
87
|
+
gap: 6,
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{t("forge.ended_broadcast.notice_title")}
|
|
91
|
+
<CallHelpHint label={t("forge.ended_broadcast.why_label")}>
|
|
92
|
+
{t("forge.ended_broadcast.why_body")}
|
|
93
|
+
</CallHelpHint>
|
|
94
|
+
</h4>
|
|
95
|
+
|
|
96
|
+
<p>{t("forge.ended_broadcast.close_instruction")}</p>
|
|
97
|
+
<p style={{ marginTop: 8 }}>{t("forge.ended_broadcast.refresh_instruction")}</p>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default EndedBroadcast;
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
2
|
-
import { Bold, Italic, Underline, List, ListOrdered, Link as LinkIcon } from "lucide-react";
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import { Bold, Italic, Underline, List, ListOrdered, Link as LinkIcon, PenLine } from "lucide-react";
|
|
3
3
|
import { useThemeTokens } from "../../theme/ForgeThemeProvider";
|
|
4
4
|
import { formatBytes } from "../../headless/chat/attachmentKind";
|
|
5
5
|
import { useCommunityAttachmentUpload } from "../../headless/community/useCommunityAttachmentUpload";
|
|
6
6
|
import { useRichMentionComposer, type RichCommand } from "../../headless/community/useRichMentionComposer";
|
|
7
|
+
// Forge's own `?` disclosure. It is named for the call screen it was first built
|
|
8
|
+
// for, but it is a plain button + panel over the theme tokens with no media
|
|
9
|
+
// dependency, so every styled surface uses it rather than growing a second one.
|
|
10
|
+
// Forge cannot import `HelpHint` from `@tribe-nest/frontend-shared`: that package
|
|
11
|
+
// is not a Forge dependency and would not resolve on a real creator site.
|
|
12
|
+
import { CallHelpHint } from "../../media/CallHelpHint";
|
|
7
13
|
import { MemberAvatar } from "./MemberAvatar";
|
|
8
14
|
import type { CommunityAttachmentInput } from "../../../data/queries/useCommunity";
|
|
9
15
|
|
|
@@ -21,6 +27,20 @@ export interface CommunityComposerProps {
|
|
|
21
27
|
submitLabel?: string;
|
|
22
28
|
autoFocus?: boolean;
|
|
23
29
|
compact?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Start closed behind a one-click affordance instead of sitting open.
|
|
32
|
+
*
|
|
33
|
+
* Set it on the composers a member did NOT ask for: the space feed's post box
|
|
34
|
+
* and a thread's top-level reply box. Leave it off for a composer the member
|
|
35
|
+
* summoned (the "Reply" box under a reply), which must be open the instant it
|
|
36
|
+
* mounts or the click that opened it did nothing.
|
|
37
|
+
*/
|
|
38
|
+
collapsible?: boolean;
|
|
39
|
+
/** Controlled open state. Omit to let the composer own it. Ignored unless `collapsible`. */
|
|
40
|
+
open?: boolean;
|
|
41
|
+
onOpenChange?: (open: boolean) => void;
|
|
42
|
+
/** Copy on the closed affordance. Defaults to `placeholder`. */
|
|
43
|
+
collapsedLabel?: string;
|
|
24
44
|
onSubmit: (input: CommunityComposerSubmit) => Promise<unknown>;
|
|
25
45
|
onCancel?: () => void;
|
|
26
46
|
}
|
|
@@ -29,9 +49,26 @@ export interface CommunityComposerProps {
|
|
|
29
49
|
* Post/reply composer: a lean contentEditable rich-text editor (bold/italic/
|
|
30
50
|
* underline, bullet + numbered lists, links) with `@mention` autocomplete
|
|
31
51
|
* (member directory) and attachments. The DOM is the source of truth; on submit
|
|
32
|
-
* it serializes to the Lexical state CommunityRichText renders
|
|
52
|
+
* it serializes to the Lexical state CommunityRichText renders, and no Lexical
|
|
33
53
|
* runtime is pulled into forge. Same onSubmit contract the plain-text composer
|
|
34
54
|
* used ({ title?, body, attachments, mentionedAccountIds }).
|
|
55
|
+
*
|
|
56
|
+
* ## Closing keeps the draft, and that is a decision
|
|
57
|
+
*
|
|
58
|
+
* Under `collapsible` the editor is HIDDEN, never unmounted. The DOM is this
|
|
59
|
+
* composer's state, so unmounting it is the same act as deleting what the
|
|
60
|
+
* member wrote: text, formatting, mention chips, the title and every staged
|
|
61
|
+
* attachment go at once, with no undo and no warning. A control whose whole job
|
|
62
|
+
* is to get out of the way must not be able to destroy work on the way out.
|
|
63
|
+
*
|
|
64
|
+
* The two alternatives are worse. Discarding on close is silent data loss from
|
|
65
|
+
* an X. Confirming on close taxes the common case, which is a member who opened
|
|
66
|
+
* the box by accident and typed nothing, with a dialog.
|
|
67
|
+
*
|
|
68
|
+
* So closing costs nothing, and the draft announces itself: the closed
|
|
69
|
+
* affordance reads "Continue your draft" rather than the placeholder, and grows
|
|
70
|
+
* a Discard control beside it. Throwing the draft away therefore stays possible
|
|
71
|
+
* but has to be asked for, and asked for twice.
|
|
35
72
|
*/
|
|
36
73
|
export function CommunityComposer({
|
|
37
74
|
withTitle = false,
|
|
@@ -39,6 +76,10 @@ export function CommunityComposer({
|
|
|
39
76
|
submitLabel = "Post",
|
|
40
77
|
autoFocus = false,
|
|
41
78
|
compact = false,
|
|
79
|
+
collapsible = false,
|
|
80
|
+
open,
|
|
81
|
+
onOpenChange,
|
|
82
|
+
collapsedLabel,
|
|
42
83
|
onSubmit,
|
|
43
84
|
onCancel,
|
|
44
85
|
}: CommunityComposerProps) {
|
|
@@ -49,10 +90,61 @@ export function CommunityComposer({
|
|
|
49
90
|
const [sending, setSending] = useState(false);
|
|
50
91
|
const [error, setError] = useState<string | null>(null);
|
|
51
92
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
93
|
+
const titleRef = useRef<HTMLInputElement | null>(null);
|
|
94
|
+
const bodyId = useId();
|
|
95
|
+
|
|
96
|
+
// A non-collapsible composer is always open: that is the pre-existing
|
|
97
|
+
// contract every "Reply" box relies on.
|
|
98
|
+
const [selfOpen, setSelfOpen] = useState(false);
|
|
99
|
+
const isOpen = !collapsible || (open ?? selfOpen);
|
|
100
|
+
const setOpen = (next: boolean) => {
|
|
101
|
+
if (open === undefined) setSelfOpen(next);
|
|
102
|
+
onOpenChange?.(next);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const [confirmingDiscard, setConfirmingDiscard] = useState(false);
|
|
52
106
|
|
|
53
107
|
const border = `1px solid ${t.border}`;
|
|
54
108
|
const canSubmit =
|
|
55
109
|
(!composer.isEmpty || upload.hasPending) && !sending && (!withTitle || title.trim().length > 0);
|
|
110
|
+
/** Is there work in here that closing would otherwise have thrown away? */
|
|
111
|
+
const hasDraft = !composer.isEmpty || upload.hasPending || title.trim().length > 0;
|
|
112
|
+
|
|
113
|
+
/** Put the caret where typing should carry on: the title first on a forum
|
|
114
|
+
* post, otherwise the end of whatever is already written. */
|
|
115
|
+
const focusEditor = () => {
|
|
116
|
+
if (withTitle && titleRef.current) {
|
|
117
|
+
titleRef.current.focus();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const el = composer.editorRef.current;
|
|
121
|
+
if (!el) return;
|
|
122
|
+
el.focus();
|
|
123
|
+
const doc = el.ownerDocument;
|
|
124
|
+
const sel = doc.defaultView?.getSelection();
|
|
125
|
+
if (!sel) return;
|
|
126
|
+
const range = doc.createRange();
|
|
127
|
+
range.selectNodeContents(el);
|
|
128
|
+
range.collapse(false);
|
|
129
|
+
sel.removeAllRanges();
|
|
130
|
+
sel.addRange(range);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Opening focuses, so writing is one click rather than two. Tracked against
|
|
134
|
+
// the previous value: a re-render while open must not steal the caret back.
|
|
135
|
+
const wasOpen = useRef(false);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (collapsible && isOpen && !wasOpen.current) focusEditor();
|
|
138
|
+
wasOpen.current = isOpen;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const discardDraft = () => {
|
|
142
|
+
composer.reset();
|
|
143
|
+
setTitle("");
|
|
144
|
+
upload.clear();
|
|
145
|
+
setError(null);
|
|
146
|
+
setConfirmingDiscard(false);
|
|
147
|
+
};
|
|
56
148
|
|
|
57
149
|
const submit = async () => {
|
|
58
150
|
if (!canSubmit) return;
|
|
@@ -69,6 +161,9 @@ export function CommunityComposer({
|
|
|
69
161
|
composer.reset();
|
|
70
162
|
setTitle("");
|
|
71
163
|
upload.clear();
|
|
164
|
+
// Posted, so the box has served its purpose and folds away. There is no
|
|
165
|
+
// draft left to lose: reset() ran a line above.
|
|
166
|
+
if (collapsible) setOpen(false);
|
|
72
167
|
} catch (err: any) {
|
|
73
168
|
setError(err?.response?.data?.message || "Couldn't publish. Please try again.");
|
|
74
169
|
} finally {
|
|
@@ -107,6 +202,17 @@ export function CommunityComposer({
|
|
|
107
202
|
|
|
108
203
|
const fmt = (command: RichCommand) => () => composer.format(command);
|
|
109
204
|
|
|
205
|
+
const ghostButton = {
|
|
206
|
+
background: "none",
|
|
207
|
+
border,
|
|
208
|
+
borderRadius: t.cornerRadius,
|
|
209
|
+
padding: "6px 12px",
|
|
210
|
+
cursor: "pointer",
|
|
211
|
+
color: t.text,
|
|
212
|
+
fontSize: 13,
|
|
213
|
+
whiteSpace: "nowrap" as const,
|
|
214
|
+
};
|
|
215
|
+
|
|
110
216
|
return (
|
|
111
217
|
<div
|
|
112
218
|
style={{
|
|
@@ -119,8 +225,69 @@ export function CommunityComposer({
|
|
|
119
225
|
gap: 8,
|
|
120
226
|
}}
|
|
121
227
|
>
|
|
228
|
+
{/* Closed: the affordance to open, and (only once there is something to
|
|
229
|
+
lose) the way to throw the draft away on purpose. */}
|
|
230
|
+
{!isOpen &&
|
|
231
|
+
(confirmingDiscard ? (
|
|
232
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
|
233
|
+
<span style={{ flex: 1, minWidth: 160, fontSize: 13, color: t.text }}>
|
|
234
|
+
Discard this draft? What you wrote cannot be brought back.
|
|
235
|
+
</span>
|
|
236
|
+
<button onClick={discardDraft} style={{ ...ghostButton, fontWeight: 600 }}>
|
|
237
|
+
Discard
|
|
238
|
+
</button>
|
|
239
|
+
<button onClick={() => setConfirmingDiscard(false)} style={ghostButton}>
|
|
240
|
+
Keep it
|
|
241
|
+
</button>
|
|
242
|
+
</div>
|
|
243
|
+
) : (
|
|
244
|
+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
245
|
+
<button
|
|
246
|
+
onClick={() => setOpen(true)}
|
|
247
|
+
aria-expanded={false}
|
|
248
|
+
aria-controls={bodyId}
|
|
249
|
+
style={{
|
|
250
|
+
flex: 1,
|
|
251
|
+
minWidth: 0,
|
|
252
|
+
display: "flex",
|
|
253
|
+
alignItems: "center",
|
|
254
|
+
gap: 8,
|
|
255
|
+
textAlign: "left",
|
|
256
|
+
padding: "8px 10px",
|
|
257
|
+
borderRadius: t.cornerRadius,
|
|
258
|
+
border,
|
|
259
|
+
background: t.background,
|
|
260
|
+
color: hasDraft ? t.text : t.muted,
|
|
261
|
+
fontFamily: "inherit",
|
|
262
|
+
fontSize: 14,
|
|
263
|
+
fontWeight: hasDraft ? 600 : 400,
|
|
264
|
+
cursor: "pointer",
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
<PenLine size={15} style={{ flexShrink: 0, color: t.primary }} aria-hidden />
|
|
268
|
+
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
269
|
+
{hasDraft ? "Continue your draft" : collapsedLabel ?? placeholder}
|
|
270
|
+
</span>
|
|
271
|
+
</button>
|
|
272
|
+
{hasDraft && (
|
|
273
|
+
<button onClick={() => setConfirmingDiscard(true)} style={ghostButton}>
|
|
274
|
+
Discard
|
|
275
|
+
</button>
|
|
276
|
+
)}
|
|
277
|
+
</div>
|
|
278
|
+
))}
|
|
279
|
+
|
|
280
|
+
{/* Open: the editor itself. Hidden rather than unmounted, because the DOM
|
|
281
|
+
in here IS the draft. `display: none` also takes it out of the
|
|
282
|
+
accessibility tree and the tab order, so nothing is reachable while
|
|
283
|
+
the composer reads as closed. */}
|
|
284
|
+
<div
|
|
285
|
+
id={bodyId}
|
|
286
|
+
style={{ display: isOpen ? "flex" : "none", flexDirection: "column", gap: 8 }}
|
|
287
|
+
>
|
|
122
288
|
{withTitle && (
|
|
123
289
|
<input
|
|
290
|
+
ref={titleRef}
|
|
124
291
|
value={title}
|
|
125
292
|
onChange={(e) => setTitle(e.target.value)}
|
|
126
293
|
placeholder="Title"
|
|
@@ -319,6 +486,17 @@ export function CommunityComposer({
|
|
|
319
486
|
📎
|
|
320
487
|
</button>
|
|
321
488
|
<span style={{ flex: 1, fontSize: 11, color: t.muted }}>Use @ to mention members</span>
|
|
489
|
+
{collapsible && (
|
|
490
|
+
<>
|
|
491
|
+
<CallHelpHint label="What happens to what I have typed?">
|
|
492
|
+
Closing keeps your draft, attachments included. Open the box again and it is still
|
|
493
|
+
there, with the cursor where you left it. Discard is the only thing that clears it.
|
|
494
|
+
</CallHelpHint>
|
|
495
|
+
<button onClick={() => setOpen(false)} style={ghostButton}>
|
|
496
|
+
Close
|
|
497
|
+
</button>
|
|
498
|
+
</>
|
|
499
|
+
)}
|
|
322
500
|
{onCancel && (
|
|
323
501
|
<button
|
|
324
502
|
onClick={onCancel}
|
|
@@ -353,6 +531,7 @@ export function CommunityComposer({
|
|
|
353
531
|
{sending ? "…" : submitLabel}
|
|
354
532
|
</button>
|
|
355
533
|
</div>
|
|
534
|
+
</div>
|
|
356
535
|
</div>
|
|
357
536
|
);
|
|
358
537
|
}
|
|
@@ -8,7 +8,7 @@ import { CommunityComposer } from "./CommunityComposer";
|
|
|
8
8
|
import { CommunityPostCard } from "./CommunityPostCard";
|
|
9
9
|
|
|
10
10
|
export interface CommunityFeedProps {
|
|
11
|
-
/** A space uuid OR its per-profile slug
|
|
11
|
+
/** A space uuid OR its per-profile slug, resolved from the gated spaces list. */
|
|
12
12
|
spaceId: string;
|
|
13
13
|
onOpenPost: (post: CommunityPost) => void;
|
|
14
14
|
onOpenProfile?: (accountId: string) => void;
|
|
@@ -17,17 +17,20 @@ export interface CommunityFeedProps {
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* A space's post feed, live over the space's Socket.io room. Renders feed
|
|
20
|
-
* spaces as a card stream and forum spaces as compact title rows
|
|
21
|
-
* two shapes. The composer
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* spaces as a card stream and forum spaces as compact title rows: same data,
|
|
21
|
+
* two shapes. The composer starts closed as a single-line bar (open, it cramps
|
|
22
|
+
* the top of every visit whether or not anyone wants to post); the header "+"
|
|
23
|
+
* and the empty-state button open the same one. A header search scopes FTS to
|
|
24
|
+
* THIS space and, while a query is active, replaces the feed with matching posts.
|
|
24
25
|
*/
|
|
25
26
|
export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, onBack }: CommunityFeedProps) {
|
|
26
27
|
const t = useThemeTokens();
|
|
27
28
|
const feed = useCommunitySpaceFeed(idOrSlug);
|
|
28
29
|
const border = `1px solid ${t.border}`;
|
|
29
30
|
|
|
30
|
-
// Composer is
|
|
31
|
+
// Composer is closed by default. This drives the composer's `open` prop, and
|
|
32
|
+
// the composer reports its own Close back through `onOpenChange`, so the
|
|
33
|
+
// header "+" and the box below it can never disagree about the state.
|
|
31
34
|
const [composerOpen, setComposerOpen] = useState(false);
|
|
32
35
|
// Search expands from the header icon; a live query >=2 chars replaces the feed.
|
|
33
36
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
@@ -96,7 +99,8 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
|
|
|
96
99
|
<h2 style={{ margin: 0, fontSize: 18, fontFamily: t.headingFontFamily, color: t.text }}>
|
|
97
100
|
{feed.space.name}
|
|
98
101
|
</h2>
|
|
99
|
-
{/*
|
|
102
|
+
{/* A second way into the same composer, for members who read the
|
|
103
|
+
title before they read the bar under it. */}
|
|
100
104
|
<button
|
|
101
105
|
onClick={() => setComposerOpen((o) => !o)}
|
|
102
106
|
aria-label={composerOpen ? "Hide composer" : "New post"}
|
|
@@ -122,7 +126,7 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
|
|
|
122
126
|
)}
|
|
123
127
|
</div>
|
|
124
128
|
|
|
125
|
-
{/* Search toggle only
|
|
129
|
+
{/* Search toggle only. The input itself is a full-width bar below the
|
|
126
130
|
header (see below) so it never squeezes the title on a narrow column. */}
|
|
127
131
|
<button
|
|
128
132
|
onClick={() => (searchOpen ? closeSearch() : setSearchOpen(true))}
|
|
@@ -159,8 +163,8 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
|
|
|
159
163
|
)}
|
|
160
164
|
</header>
|
|
161
165
|
|
|
162
|
-
{/* Full-width search bar
|
|
163
|
-
the title row
|
|
166
|
+
{/* Full-width search bar. It lives below the header so it never distorts
|
|
167
|
+
the title row, and focus lifts a soft primary ring. */}
|
|
164
168
|
{searchOpen && (
|
|
165
169
|
<div
|
|
166
170
|
style={{
|
|
@@ -210,8 +214,8 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
|
|
|
210
214
|
</div>
|
|
211
215
|
)}
|
|
212
216
|
|
|
213
|
-
{/* Un-joined viewers can still read + post (tier access governs that)
|
|
214
|
-
the banner nudges them to opt into new-post notifications. */}
|
|
217
|
+
{/* Un-joined viewers can still read + post (tier access governs that),
|
|
218
|
+
so the banner nudges them to opt into new-post notifications. */}
|
|
215
219
|
{!feed.isJoined && (
|
|
216
220
|
<div
|
|
217
221
|
style={{
|
|
@@ -246,43 +250,24 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
|
|
|
246
250
|
</div>
|
|
247
251
|
)}
|
|
248
252
|
|
|
249
|
-
{/* Composer
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
position: "absolute",
|
|
268
|
-
top: 8,
|
|
269
|
-
right: 8,
|
|
270
|
-
display: "inline-flex",
|
|
271
|
-
alignItems: "center",
|
|
272
|
-
justifyContent: "center",
|
|
273
|
-
width: 26,
|
|
274
|
-
height: 26,
|
|
275
|
-
borderRadius: 999,
|
|
276
|
-
border: "none",
|
|
277
|
-
background: "transparent",
|
|
278
|
-
color: t.muted,
|
|
279
|
-
cursor: "pointer",
|
|
280
|
-
}}
|
|
281
|
-
>
|
|
282
|
-
<X size={15} />
|
|
283
|
-
</button>
|
|
284
|
-
</div>
|
|
285
|
-
)}
|
|
253
|
+
{/* Composer: closed by default, and MOUNTED whether open or closed. The
|
|
254
|
+
editor DOM is the draft, so rendering it conditionally would delete a
|
|
255
|
+
half-written post every time the member folded the box away. It owns
|
|
256
|
+
its own open affordance, its Close and its Discard; the header "+"
|
|
257
|
+
and the empty-state button drive the same state through `open`. */}
|
|
258
|
+
<CommunityComposer
|
|
259
|
+
collapsible
|
|
260
|
+
open={composerOpen}
|
|
261
|
+
onOpenChange={setComposerOpen}
|
|
262
|
+
withTitle={isForum}
|
|
263
|
+
placeholder={isForum ? "Start a discussion…" : "Share something with the community…"}
|
|
264
|
+
collapsedLabel={isForum ? "Start a discussion" : "Share something with the community"}
|
|
265
|
+
submitLabel={isForum ? "Start discussion" : "Post"}
|
|
266
|
+
compact={!isForum}
|
|
267
|
+
onSubmit={async (input) => {
|
|
268
|
+
await feed.createPost.mutateAsync(input);
|
|
269
|
+
}}
|
|
270
|
+
/>
|
|
286
271
|
|
|
287
272
|
{/* While searching, results replace the feed; clearing returns to it. */}
|
|
288
273
|
{searching ? (
|
|
@@ -386,7 +371,7 @@ export function CommunityFeed({ spaceId: idOrSlug, onOpenPost, onOpenProfile, on
|
|
|
386
371
|
}
|
|
387
372
|
|
|
388
373
|
/**
|
|
389
|
-
* In-space search results
|
|
374
|
+
* In-space search results: post matches (title + snippet) that navigate to the
|
|
390
375
|
* thread via onOpenPost, and reply matches surfaced by their parent post. Search
|
|
391
376
|
* rows are lean FTS projections, so we hand onOpenPost a minimal post shape
|
|
392
377
|
* (callers route by id).
|
|
@@ -428,7 +413,7 @@ function SearchResults({
|
|
|
428
413
|
};
|
|
429
414
|
|
|
430
415
|
const openById = (id: string, title: string | null, bodyText: string | null) =>
|
|
431
|
-
// Minimal post shape
|
|
416
|
+
// Minimal post shape. The FTS row carries no engagement/media, and callers only
|
|
432
417
|
// read the id to route, and the thread view refetches the full post.
|
|
433
418
|
onOpenPost({
|
|
434
419
|
id,
|
|
@@ -137,6 +137,9 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
|
|
|
137
137
|
|
|
138
138
|
{replyingTo === reply.id && (
|
|
139
139
|
<div style={{ marginLeft: 44 }}>
|
|
140
|
+
{/* Deliberately NOT `collapsible`: the member already clicked
|
|
141
|
+
"Reply", so this box is open on arrival. Collapsing it here
|
|
142
|
+
would make writing a reply cost two clicks instead of one. */}
|
|
140
143
|
<CommunityComposer
|
|
141
144
|
compact
|
|
142
145
|
autoFocus
|
|
@@ -233,14 +236,25 @@ export function CommunityPostDetail({ postId, onOpenProfile, onBack }: Community
|
|
|
233
236
|
</footer>
|
|
234
237
|
</article>
|
|
235
238
|
|
|
236
|
-
{/* Top-level composer
|
|
239
|
+
{/* Top-level composer: closed until asked for, and hidden rather than
|
|
240
|
+
unmounted while a nested reply box is open, so a half-written
|
|
241
|
+
top-level reply survives the detour of replying to someone else. */}
|
|
237
242
|
{locked ? (
|
|
238
243
|
<div style={{ border, borderRadius: t.cornerRadius, padding: 14, textAlign: "center", color: t.muted, fontSize: 14 }}>
|
|
239
244
|
<Lock size={13} style={{ verticalAlign: -2, marginRight: 6 }} />
|
|
240
245
|
This post is locked: replies are closed.
|
|
241
246
|
</div>
|
|
242
247
|
) : (
|
|
243
|
-
|
|
248
|
+
<div style={{ display: replyingTo ? "none" : "block" }}>
|
|
249
|
+
<CommunityComposer
|
|
250
|
+
collapsible
|
|
251
|
+
compact
|
|
252
|
+
placeholder="Write a reply…"
|
|
253
|
+
collapsedLabel="Write a reply"
|
|
254
|
+
submitLabel="Reply"
|
|
255
|
+
onSubmit={submitReply}
|
|
256
|
+
/>
|
|
257
|
+
</div>
|
|
244
258
|
)}
|
|
245
259
|
|
|
246
260
|
{/* Thread */}
|