@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.
Files changed (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. 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 no Lexical
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 resolved from the gated spaces list. */
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 same data,
21
- * two shapes. The composer is hidden by default behind a "+" in the header (it
22
- * cramps the top otherwise); a header search scopes FTS to THIS space and, while
23
- * a query is active, replaces the feed with matching posts.
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 hidden by default the "+" reveals it below the header.
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
- {/* Reveal/hide the composer (hidden by default). */}
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 the input itself is a full-width bar below the
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 lives below the header so it never distorts
163
- the title row; focus lifts a soft primary ring. */}
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 hidden until toggled; behavior/props identical to before. */}
250
- {composerOpen && (
251
- <div style={{ position: "relative" }}>
252
- <CommunityComposer
253
- withTitle={isForum}
254
- placeholder={isForum ? "Start a discussion…" : "Share something with the community…"}
255
- submitLabel={isForum ? "Start discussion" : "Post"}
256
- compact={!isForum}
257
- onSubmit={async (input) => {
258
- await feed.createPost.mutateAsync(input);
259
- setComposerOpen(false);
260
- }}
261
- />
262
- <button
263
- onClick={() => setComposerOpen(false)}
264
- aria-label="Close composer"
265
- title="Close composer"
266
- style={{
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 post matches (title + snippet) that navigate to the
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 the FTS row carries no engagement/media; callers only
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
- !replyingTo && <CommunityComposer compact placeholder="Write a reply…" submitLabel="Reply" onSubmit={submitReply} />
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 */}