Skip to content

feat(portal): admin-configurable welcome card on the portal home#174

Merged
mortondev merged 15 commits into
mainfrom
feat/portal-welcome-card
May 16, 2026
Merged

feat(portal): admin-configurable welcome card on the portal home#174
mortondev merged 15 commits into
mainfrom
feat/portal-welcome-card

Conversation

@mortondev
Copy link
Copy Markdown
Member

Summary

  • New welcomeCard block under portalConfig (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 UI lives at Customization → Portal (/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.
  • Body is sanitized via the existing sanitizeTiptapContent pipeline on write and rendered through RichTextContent (DOMPurify defense-in-depth) on read, matching the post / help-center model.
  • Public portal config masks the card whenever enabled=false so draft title/body never leak through the public endpoint.

Files

  • apps/web/src/lib/server/domains/settings/settings.types.tsPortalWelcomeCard type, PORTAL_WELCOME_CARD_TITLE_MAX = 120, default values
  • apps/web/src/lib/server/domains/settings/settings.helpers.tsnormalizeWelcomeCardInput, mergeWelcomeCard (body replaces wholesale), publicWelcomeCard (masks disabled)
  • apps/web/src/lib/server/domains/settings/settings.service.ts — wires the helpers into updatePortalConfig + both public projections; bumpAuthConfigVersionInTx / resetAuth now skipped for non-oauth edits
  • apps/web/src/lib/server/functions/settings.ts — Zod schema admits welcomeCard.{enabled,title,body} at the wire boundary
  • apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts — depth-capped helper
  • apps/web/src/components/public/feedback/portal-welcome-card.tsx — public renderer
  • apps/web/src/components/public/feedback/feedback-container.tsx, apps/web/src/routes/_portal/index.tsx — wire-up
  • apps/web/src/routes/admin/settings.portal.tsx — admin form with single-timer debounced save + live preview
  • apps/web/src/components/admin/settings/settings-nav.tsx — new Portal nav entry

Test plan

  • bun run typecheck — 0 errors
  • bun run lint — 0 errors (1 pre-existing max-lines warning on settings.service.ts)
  • bun run test — 233 files, 2573 tests pass
  • New unit coverage: isEmptyTiptapDoc (8 cases), PortalWelcomeCard render guards (6 cases), normalizeWelcomeCardInput (7 cases), mergeWelcomeCard (4 cases), publicWelcomeCard (3 cases), parseJsonConfig deep-merge of welcomeCard (3 cases)
  • Manual: enable the card via the admin page, confirm it renders at the top of /portal/; disable, confirm it disappears; verify the public /api/portal/config response (or equivalent) omits the card when disabled

mortondev added 9 commits May 15, 2026 11:56
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.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/routes/admin/settings.portal.tsx Outdated
mortondev added 6 commits May 15, 2026 12:37
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.
@mortondev mortondev merged commit e30fb76 into main May 16, 2026
4 checks passed
@mortondev mortondev deleted the feat/portal-welcome-card branch May 16, 2026 00:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant