feat(portal): admin-configurable welcome card on the portal home#174
Merged
Conversation
Adds PortalWelcomeCard interface (enabled / title / TipTap body) and plumbs it through PortalConfig, DEFAULT_PORTAL_CONFIG, the partial update input, and the PublicPortalConfig projection. Off by default; absent / disabled = nothing rendered.
Adds normalizeWelcomeCardInput helper that trims the title, enforces the 120-char cap, and runs the TipTap body through sanitizeTiptapContent. Plugged into updatePortalConfig before the deep-merge, and the server-function Zod schema now admits the new welcomeCard field.
getPublicPortalConfig and getTenantSettings now pass through portalConfig.welcomeCard so the SSR loader can render it without an extra fetch. Absent / disabled stays absent in the projection.
Tells whether a TipTap doc carries any visible content. Treats empty paragraphs/headings/blockquotes and whitespace-only text as empty; images, lists, embeds and horizontal rules count as content.
Renders the admin-authored welcome card above the post list. Returns null when disabled, when the card is missing, or when both title and body are effectively empty so a stray enable=true doesn't leave a blank container on screen.
The _portal index loader pulls publicPortalConfig.welcomeCard from the already-fetched tenant settings and passes it through FeedbackContainer. The card renders as the first child of the main column on /portal/ and is absent from board pages, post detail, changelog, and roadmap.
New /admin/settings/portal route under Customization (between Branding and Widget) with an enable toggle, plain-text title input, TipTap rich-text body editor mirroring the post editor, and a live preview that re-renders the public component against form state. Auto-saves on 800 ms debounce; switch saves immediately.
Code-review pass on the welcome card surfaced five concrete bugs: - updatePortalConfig no longer bumps the auth config version or calls resetAuth() for non-oauth edits, so every debounced welcome-card save doesn't trigger a Better-Auth rebuild on every pod. - Toggling the enable switch now flushes any pending title/body debounce so the timer can't fire afterwards and silently overwrite the new enabled state. - The admin form re-syncs from the server on background refetch, but skips the sync while the user has unflushed edits so typing isn't clobbered. - The preview empty-check now uses isEmptyTiptapDoc, matching the public renderer; previously the placeholder hint vanished as soon as the editor had a default empty paragraph. - isEmptyTiptapDoc enforces a recursion depth cap as a stack-safety guard for inputs that bypass the server sanitizer.
- Welcome card body now replaces wholesale on save instead of being deep-merged. A sanitized empty doc must clear previous content, not silently retain it. New mergeWelcomeCard helper handles the special semantics; covered by unit tests. - updatePortalConfig partial saves no longer race: the admin form now uses a single debounce timer that sends a full welcomeCard snapshot, so two concurrent saves write the same fields rather than each overwriting the other. A monotonic seq guard makes only the latest save reset the pending-edit flag, so an in-flight resolve doesn't reopen background re-sync while the user is still typing. - getPublicPortalConfig and getTenantSettings now mask the welcome card when enabled=false, so draft title/body content can't leak out through the public portal config endpoint.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c741638bca
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
input-otp schedules three real setTimeouts (0/10/50ms) on mount for password-manager detection. Under happy-dom those timers can fire after the test environment has torn down `window`, surfacing as an unhandled "window is not defined" error that fails the CI run. The portal-auth-form tests don't exercise OTP-input behaviour — they just fireEvent.change the rendered control — so stub the library with a plain text input.
Second simplify pass surfaced three concrete improvements: - Race fix in the admin form: scheduleSave now claims a saveSeqRef slot when it arms the debounce, so an in-flight earlier save (e.g. an immediate switch-toggle) can't resolve with a matching seq and prematurely clear hasPendingEditsRef while the typed-but-not-yet- saved snapshot is still queued. - Live preview no longer reruns DOMPurify.sanitize + generateContentHTML on every title keystroke: previewCard is now useMemo'd on title+body, and PortalWelcomeCard is wrapped in React.memo, so a title edit alone skips the body render path. - Extract InlineSpinner to a shared component (components/admin/settings/inline-spinner.tsx). settings.portal, settings.help-center, settings.portal-widget and settings.widget all carried byte-identical copies of the same 4-line component; the new file replaces all four.
Toggling the card off disabled the title and message inputs, so admins couldn't edit a stale draft from a previous announcement before turning it back on. The toggle's immediate save then published that stale draft on the public portal the instant they flipped the switch. Leave the fields editable when the card is disabled. The debounced auto-save still persists edits with `enabled: false`, and the public portal hides the card regardless of content while disabled. Flipping on publishes whatever the admin has typed by then, which matches the draft → publish flow they expect.
The page initialised local state via useMemo+useEffect and re-synced from the server-side config whenever the route invalidated. After each save, router.invalidate refetched the canonical config, the resync effect called setBody(initial.body), and the new body reference fed back into RichTextEditor whose value-sync setContent fired onUpdate → onChange → scheduleSave → another save. Because the server-side sanitizer normalises the body differently than TipTap's editor.getJSON(), the two strings never converged, so the loop never terminated. Drop the resync effect entirely and initialise state once from the loader-warmed query, matching the pattern used by settings.help-center.tsx and settings.portal-widget.tsx — both of which treat local state as the source of truth post-mount and never run into this race. router.invalidate still fires after each save so the cache is refreshed for the next visit, but the live form fields are no longer overwritten mid-edit.
The autosave flow was producing an infinite save loop. Two interacting
causes:
1. After save, router.invalidate refetched the canonical config; the
server-side sanitizer normalises the body JSON differently than
TipTap's editor.getJSON(), so re-pushing the round-tripped value
into the editor re-fired onUpdate → onChange → scheduleSave.
2. saving=true → disabled=true on RichTextEditor → editor.setEditable
fires its own onUpdate event (TipTap default), which calls onChange
even though the doc content didn't change.
The previous attempt at "match help-center.tsx pattern" addressed (1)
but not (2), and the live dev-server log still showed updatePortalConfigFn
fired repeatedly with no user interaction.
Drop autosave entirely. Local state is edited freely; nothing is sent to
the server until the user clicks Save. With no scheduleSave-on-change
path, neither of the above feedback loops has anywhere to land.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
welcomeCardblock underportalConfig(off by default, no migration) — admins set a plain-text title and a TipTap rich-text message and the card renders at the top of/portal/above the Trending/Top/New toolbar./admin/settings/portal), modelled on the help-center settings page. Enable switch saves immediately; title + body auto-save on an 800 ms debounce with a single timer carrying a full snapshot of the card. Live preview re-renders the public component against form state.sanitizeTiptapContentpipeline on write and rendered throughRichTextContent(DOMPurify defense-in-depth) on read, matching the post / help-center model.enabled=falseso draft title/body never leak through the public endpoint.Files
apps/web/src/lib/server/domains/settings/settings.types.ts—PortalWelcomeCardtype,PORTAL_WELCOME_CARD_TITLE_MAX = 120, default valuesapps/web/src/lib/server/domains/settings/settings.helpers.ts—normalizeWelcomeCardInput,mergeWelcomeCard(body replaces wholesale),publicWelcomeCard(masks disabled)apps/web/src/lib/server/domains/settings/settings.service.ts— wires the helpers intoupdatePortalConfig+ both public projections;bumpAuthConfigVersionInTx/resetAuthnow skipped for non-oauth editsapps/web/src/lib/server/functions/settings.ts— Zod schema admitswelcomeCard.{enabled,title,body}at the wire boundaryapps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts— depth-capped helperapps/web/src/components/public/feedback/portal-welcome-card.tsx— public rendererapps/web/src/components/public/feedback/feedback-container.tsx,apps/web/src/routes/_portal/index.tsx— wire-upapps/web/src/routes/admin/settings.portal.tsx— admin form with single-timer debounced save + live previewapps/web/src/components/admin/settings/settings-nav.tsx— new Portal nav entryTest plan
bun run typecheck— 0 errorsbun run lint— 0 errors (1 pre-existingmax-lineswarning onsettings.service.ts)bun run test— 233 files, 2573 tests passisEmptyTiptapDoc(8 cases),PortalWelcomeCardrender guards (6 cases),normalizeWelcomeCardInput(7 cases),mergeWelcomeCard(4 cases),publicWelcomeCard(3 cases),parseJsonConfigdeep-merge of welcomeCard (3 cases)/portal/; disable, confirm it disappears; verify the public/api/portal/configresponse (or equivalent) omits the card when disabled