+ )}
+ {hasBody && (
+
+ )}
+
+ )
+}
+
+// Body rendering goes through DOMPurify.sanitize and TipTap's
+// generateContentHTML, so memoize on the welcomeCard reference to keep
+// the admin live-preview cheap on title-only keystrokes.
+export const PortalWelcomeCard = memo(PortalWelcomeCardImpl)
diff --git a/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts b/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts
new file mode 100644
index 00000000..5fb0b84c
--- /dev/null
+++ b/apps/web/src/lib/server/domains/settings/__tests__/normalize-welcome-card-input.test.ts
@@ -0,0 +1,115 @@
+import { describe, it, expect } from 'vitest'
+import { ValidationError } from '@/lib/shared/errors'
+import { mergeWelcomeCard, normalizeWelcomeCardInput, publicWelcomeCard } from '../settings.helpers'
+import { DEFAULT_PORTAL_CONFIG } from '../settings.types'
+
+describe('normalizeWelcomeCardInput', () => {
+ it('returns the input unchanged when undefined', () => {
+ expect(normalizeWelcomeCardInput(undefined)).toBeUndefined()
+ })
+
+ it('passes through enabled with no validation', () => {
+ const out = normalizeWelcomeCardInput({ enabled: true })
+ expect(out).toEqual({ enabled: true })
+ })
+
+ it('trims the title', () => {
+ const out = normalizeWelcomeCardInput({ title: ' Hello ' })
+ expect(out?.title).toBe('Hello')
+ })
+
+ it('rejects a title longer than 120 chars', () => {
+ expect(() => normalizeWelcomeCardInput({ title: 'a'.repeat(121) })).toThrow(ValidationError)
+ })
+
+ it('accepts a title of exactly 120 chars', () => {
+ const out = normalizeWelcomeCardInput({ title: 'a'.repeat(120) })
+ expect(out?.title?.length).toBe(120)
+ })
+
+ it('strips disallowed nodes from the body', () => {
+ const out = normalizeWelcomeCardInput({
+ body: {
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'safe' }] },
+ // Disallowed node type — must be stripped.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ { type: 'rogueNode', attrs: { evil: 'true' } } as any,
+ ],
+ },
+ })
+ const body = out?.body
+ expect(body?.type).toBe('doc')
+ const types = body?.content?.map((c) => c.type) ?? []
+ expect(types).not.toContain('rogueNode')
+ expect(types).toContain('paragraph')
+ })
+
+ it('returns an empty doc when body sanitizes to nothing usable', () => {
+ const out = normalizeWelcomeCardInput({
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ body: { type: 'notDoc' } as any,
+ })
+ expect(out?.body).toEqual({ type: 'doc' })
+ })
+})
+
+describe('mergeWelcomeCard', () => {
+ const seed = DEFAULT_PORTAL_CONFIG.welcomeCard!
+
+ it('returns existing when partial is undefined', () => {
+ expect(mergeWelcomeCard(seed, undefined)).toBe(seed)
+ })
+
+ it('overrides scalar fields shallowly', () => {
+ const out = mergeWelcomeCard(seed, { enabled: true, title: 'Hi' })
+ expect(out.enabled).toBe(true)
+ expect(out.title).toBe('Hi')
+ expect(out.body).toEqual(seed.body)
+ })
+
+ it('replaces the body wholesale rather than deep-merging it', () => {
+ const existing = {
+ enabled: true,
+ title: 'Old',
+ body: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'old text' }],
+ },
+ ],
+ },
+ }
+ const out = mergeWelcomeCard(existing, { body: { type: 'doc' } })
+ expect(out.body).toEqual({ type: 'doc' })
+ expect(out.body.content).toBeUndefined()
+ expect(out.title).toBe('Old')
+ })
+
+ it('falls back to defaults when there is no existing card', () => {
+ const out = mergeWelcomeCard(undefined, { enabled: true })
+ expect(out.enabled).toBe(true)
+ expect(out.title).toBe(seed.title)
+ expect(out.body).toEqual(seed.body)
+ })
+})
+
+describe('publicWelcomeCard', () => {
+ it('returns undefined when the card is undefined', () => {
+ expect(publicWelcomeCard(undefined)).toBeUndefined()
+ })
+
+ it('returns undefined when the card is disabled — never expose drafts', () => {
+ expect(
+ publicWelcomeCard({ enabled: false, title: 'draft', body: { type: 'doc' } })
+ ).toBeUndefined()
+ })
+
+ it('returns the card verbatim when enabled', () => {
+ const card = { enabled: true, title: 'Hi', body: { type: 'doc' } }
+ expect(publicWelcomeCard(card)).toEqual(card)
+ })
+})
diff --git a/apps/web/src/lib/server/domains/settings/__tests__/portal-welcome-card-config.test.ts b/apps/web/src/lib/server/domains/settings/__tests__/portal-welcome-card-config.test.ts
new file mode 100644
index 00000000..e80dbae0
--- /dev/null
+++ b/apps/web/src/lib/server/domains/settings/__tests__/portal-welcome-card-config.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from 'vitest'
+import { parseJsonConfig } from '../settings.helpers'
+import {
+ DEFAULT_PORTAL_CONFIG,
+ type PortalConfig,
+ type PortalWelcomeCard,
+ type PublicPortalConfig,
+} from '../settings.types'
+
+describe('PortalWelcomeCard defaults', () => {
+ it('is off by default', () => {
+ expect(DEFAULT_PORTAL_CONFIG.welcomeCard?.enabled).toBe(false)
+ })
+
+ it('has empty title by default', () => {
+ expect(DEFAULT_PORTAL_CONFIG.welcomeCard?.title).toBe('')
+ })
+
+ it('has an empty doc body by default', () => {
+ expect(DEFAULT_PORTAL_CONFIG.welcomeCard?.body).toEqual({
+ type: 'doc',
+ content: [{ type: 'paragraph' }],
+ })
+ })
+})
+
+describe('PortalWelcomeCard type', () => {
+ it('accepts a fully-specified welcome card', () => {
+ const card: PortalWelcomeCard = {
+ enabled: true,
+ title: 'Share your product feedback!',
+ body: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'Tell us what you think.' }],
+ },
+ ],
+ },
+ }
+ expect(card.enabled).toBe(true)
+ expect(card.title).toContain('Share')
+ })
+
+ it('is exposed as an optional field on PortalConfig', () => {
+ const cfg: PortalConfig = {
+ ...DEFAULT_PORTAL_CONFIG,
+ welcomeCard: { enabled: true, title: 'Hi', body: { type: 'doc' } as never },
+ }
+ expect(cfg.welcomeCard?.enabled).toBe(true)
+ })
+
+ it('is exposed on PublicPortalConfig so the portal SSR loader can read it', () => {
+ const projection: PublicPortalConfig = {
+ oauth: { password: true },
+ features: DEFAULT_PORTAL_CONFIG.features,
+ welcomeCard: { enabled: true, title: 'x', body: { type: 'doc' } as never },
+ }
+ expect(projection.welcomeCard?.title).toBe('x')
+ })
+})
+
+describe('parseJsonConfig deep-merges welcomeCard', () => {
+ it('preserves welcomeCard defaults when stored config omits it', () => {
+ const stored = JSON.stringify({ features: { voting: false } })
+ const result = parseJsonConfig(stored, DEFAULT_PORTAL_CONFIG)
+ expect(result.welcomeCard).toEqual(DEFAULT_PORTAL_CONFIG.welcomeCard)
+ })
+
+ it('merges partial welcomeCard with defaults', () => {
+ const stored = JSON.stringify({
+ welcomeCard: { enabled: true, title: 'Hello' },
+ })
+ const result = parseJsonConfig(stored, DEFAULT_PORTAL_CONFIG)
+ expect(result.welcomeCard?.enabled).toBe(true)
+ expect(result.welcomeCard?.title).toBe('Hello')
+ expect(result.welcomeCard?.body).toEqual(DEFAULT_PORTAL_CONFIG.welcomeCard?.body)
+ })
+})
diff --git a/apps/web/src/lib/server/domains/settings/index.ts b/apps/web/src/lib/server/domains/settings/index.ts
index c4326666..213548f3 100644
--- a/apps/web/src/lib/server/domains/settings/index.ts
+++ b/apps/web/src/lib/server/domains/settings/index.ts
@@ -16,6 +16,7 @@ export type {
PortalAuthMethods,
PortalFeatures,
PortalConfig,
+ PortalWelcomeCard,
HeaderDisplayMode,
ThemeColors,
BrandingConfig,
@@ -32,6 +33,9 @@ export type {
HelpCenterSeoConfig,
} from './settings.types'
+// Welcome card constants (no DB dependency)
+export { PORTAL_WELCOME_CARD_TITLE_MAX } from './settings.types'
+
// Default config values (no DB dependency)
export {
DEFAULT_AUTH_CONFIG,
diff --git a/apps/web/src/lib/server/domains/settings/settings.helpers.ts b/apps/web/src/lib/server/domains/settings/settings.helpers.ts
index 187a0b51..baeaedbc 100644
--- a/apps/web/src/lib/server/domains/settings/settings.helpers.ts
+++ b/apps/web/src/lib/server/domains/settings/settings.helpers.ts
@@ -5,6 +5,12 @@
import { db } from '@/lib/server/db'
import { cacheDel, CACHE_KEYS } from '@/lib/server/redis'
import { NotFoundError, InternalError, ValidationError } from '@/lib/shared/errors'
+import { sanitizeTiptapContent } from '@/lib/server/sanitize-tiptap'
+import {
+ DEFAULT_PORTAL_CONFIG,
+ PORTAL_WELCOME_CARD_TITLE_MAX,
+ type PortalWelcomeCard,
+} from './settings.types'
export type SettingsRecord = NonNullable>>
@@ -72,3 +78,61 @@ export async function invalidateSettingsCache(): Promise {
console.log(`[domain:settings] Invalidating settings cache`)
await cacheDel(CACHE_KEYS.TENANT_SETTINGS)
}
+
+/**
+ * Merge a partial `welcomeCard` update into the stored card. Unlike
+ * {@link deepMerge}, the `body` field is replaced wholesale — a TipTap
+ * doc with no `content` must clear the previous content, not retain it.
+ *
+ * @internal
+ */
+export function mergeWelcomeCard(
+ existing: PortalWelcomeCard | undefined,
+ input: Partial | undefined
+): PortalWelcomeCard {
+ const base = existing ?? DEFAULT_PORTAL_CONFIG.welcomeCard!
+ if (!input) return existing ?? base
+ return { ...base, ...input }
+}
+
+/**
+ * Project a stored welcome card for public consumption. Disabled cards
+ * have draft title/body that must not leak through the public portal
+ * config endpoint.
+ *
+ * @internal
+ */
+export function publicWelcomeCard(
+ card: PortalWelcomeCard | undefined
+): PortalWelcomeCard | undefined {
+ if (!card?.enabled) return undefined
+ return card
+}
+
+/**
+ * Normalize a partial `welcomeCard` update before it's merged into stored
+ * portalConfig. Trims the title, enforces the length cap, and runs the
+ * TipTap body through the standard sanitizer.
+ *
+ * @internal
+ */
+export function normalizeWelcomeCardInput(
+ input: Partial | undefined
+): Partial | undefined {
+ if (!input) return input
+ const normalized: Partial = { ...input }
+ if (typeof input.title === 'string') {
+ const trimmed = input.title.trim()
+ if (trimmed.length > PORTAL_WELCOME_CARD_TITLE_MAX) {
+ throw new ValidationError(
+ 'WELCOME_CARD_TITLE_TOO_LONG',
+ `Welcome card title must be ${PORTAL_WELCOME_CARD_TITLE_MAX} characters or fewer`
+ )
+ }
+ normalized.title = trimmed
+ }
+ if (input.body !== undefined) {
+ normalized.body = sanitizeTiptapContent(input.body)
+ }
+ return normalized
+}
diff --git a/apps/web/src/lib/server/domains/settings/settings.service.ts b/apps/web/src/lib/server/domains/settings/settings.service.ts
index e6198c80..d6142782 100644
--- a/apps/web/src/lib/server/domains/settings/settings.service.ts
+++ b/apps/web/src/lib/server/domains/settings/settings.service.ts
@@ -35,6 +35,9 @@ import {
requireSettings,
wrapDbError,
invalidateSettingsCache,
+ normalizeWelcomeCardInput,
+ mergeWelcomeCard,
+ publicWelcomeCard,
} from './settings.helpers'
async function getConfiguredAuthTypes(): Promise> {
@@ -600,9 +603,16 @@ export async function getPortalConfig(): Promise {
export async function updatePortalConfig(input: UpdatePortalConfigInput): Promise {
console.log(`[domain:settings] updatePortalConfig`)
try {
+ const normalizedWelcome = normalizeWelcomeCardInput(input.welcomeCard)
+ const inputWithoutWelcome: UpdatePortalConfigInput = { ...input }
+ delete inputWithoutWelcome.welcomeCard
const org = await requireSettings()
const existing = parseJsonConfig(org.portalConfig, DEFAULT_PORTAL_CONFIG)
- const updated = deepMerge(existing, input as Partial)
+ const updated = deepMerge(existing, inputWithoutWelcome as Partial)
+ // welcomeCard.body must replace, not deep-merge — see mergeWelcomeCard.
+ if (normalizedWelcome) {
+ updated.welcomeCard = mergeWelcomeCard(existing.welcomeCard, normalizedWelcome)
+ }
const hasAuthMethod = Object.values(updated.oauth).some(Boolean)
if (!hasAuthMethod) {
@@ -612,21 +622,28 @@ export async function updatePortalConfig(input: UpdatePortalConfigInput): Promis
)
}
- // Provider registration in `auth/index.ts` reads
- // portalConfig.oauth at build time — toggling a portal OAuth
- // provider must invalidate other pods' Better-Auth instances or
- // they'll keep serving the stale provider list until cache TTL.
- // Mirrors the bump+resetAuth pattern in updateAuthConfig.
- const { bumpAuthConfigVersionInTx } = await import('@/lib/server/auth/config-version')
- const { resetAuth } = await import('@/lib/server/auth')
- await db.transaction(async (tx) => {
- await tx
+ // Provider registration in `auth/index.ts` reads portalConfig.oauth at
+ // build time — toggling a portal OAuth provider must invalidate other
+ // pods' Better-Auth instances or they'll keep serving the stale provider
+ // list until cache TTL. Skip the bump for non-oauth edits (e.g. the
+ // welcome card debounce-saves) to avoid an auth rebuild per keystroke.
+ if (input.oauth !== undefined) {
+ const { bumpAuthConfigVersionInTx } = await import('@/lib/server/auth/config-version')
+ const { resetAuth } = await import('@/lib/server/auth')
+ await db.transaction(async (tx) => {
+ await tx
+ .update(settings)
+ .set({ portalConfig: JSON.stringify(updated) })
+ .where(eq(settings.id, org.id))
+ await bumpAuthConfigVersionInTx(tx)
+ })
+ resetAuth()
+ } else {
+ await db
.update(settings)
.set({ portalConfig: JSON.stringify(updated) })
.where(eq(settings.id, org.id))
- await bumpAuthConfigVersionInTx(tx)
- })
- resetAuth()
+ }
await invalidateSettingsCache()
return updated
} catch (error) {
@@ -741,10 +758,12 @@ export async function getPublicPortalConfig(): Promise {
passthroughKeys
)
const customProviderNames = await getCustomProviderNames(filteredOAuth, configuredTypes)
+ const welcome = publicWelcomeCard(portalConfig.welcomeCard)
return {
oauth: filteredOAuth,
features: portalConfig.features,
...(customProviderNames && { customProviderNames }),
+ ...(welcome && { welcomeCard: welcome }),
}
} catch (error) {
console.error(`[domain:settings] getPublicPortalConfig failed:`, error)
@@ -821,11 +840,15 @@ export async function getTenantSettings(): Promise {
oauth: filteredAuthOAuth,
openSignup: authConfig.openSignup,
},
- publicPortalConfig: {
- oauth: filteredPortalOAuth,
- features: portalConfig.features,
- ...(portalCustomNames && { customProviderNames: portalCustomNames }),
- },
+ publicPortalConfig: (() => {
+ const welcome = publicWelcomeCard(portalConfig.welcomeCard)
+ return {
+ oauth: filteredPortalOAuth,
+ features: portalConfig.features,
+ ...(portalCustomNames && { customProviderNames: portalCustomNames }),
+ ...(welcome && { welcomeCard: welcome }),
+ }
+ })(),
publicWidgetConfig: {
enabled: widgetConfig.enabled,
defaultBoard: widgetConfig.defaultBoard,
diff --git a/apps/web/src/lib/server/domains/settings/settings.types.ts b/apps/web/src/lib/server/domains/settings/settings.types.ts
index 964211ef..3aa0d2f4 100644
--- a/apps/web/src/lib/server/domains/settings/settings.types.ts
+++ b/apps/web/src/lib/server/domains/settings/settings.types.ts
@@ -5,6 +5,8 @@
* This allows adding new settings without migrations.
*/
+import type { TiptapContent } from '@/lib/shared/db-types'
+
// =============================================================================
// Auth Configuration (Team sign-in settings)
// =============================================================================
@@ -201,6 +203,26 @@ export interface PortalFeatures {
videoEmbedsInPosts?: boolean
}
+/**
+ * Welcome card shown above the post list on the portal index.
+ * Title is plain text (server trims + caps at 120 chars). Body is
+ * sanitized TipTap JSON — same shape as post / help-center content,
+ * sanitized via `sanitizeTiptapContent` on every write.
+ *
+ * Default off. Renders only when `enabled` and at least one of
+ * `title` / `body` has content.
+ */
+export interface PortalWelcomeCard {
+ enabled: boolean
+ /** Plain text. Server trims and rejects > 120 chars. */
+ title: string
+ /** Sanitized TipTap JSON doc. */
+ body: TiptapContent
+}
+
+/** Max length of {@link PortalWelcomeCard.title} after trimming. */
+export const PORTAL_WELCOME_CARD_TITLE_MAX = 120
+
/**
* Portal configuration
* Controls the public feedback portal behavior
@@ -210,6 +232,8 @@ export interface PortalConfig {
oauth: PortalAuthMethods
/** Feature toggles */
features: PortalFeatures
+ /** Welcome card on the portal index. Optional — absent = disabled. */
+ welcomeCard?: PortalWelcomeCard
}
/**
@@ -234,6 +258,11 @@ export const DEFAULT_PORTAL_CONFIG: PortalConfig = {
anonymousCommenting: false,
anonymousPosting: false,
},
+ welcomeCard: {
+ enabled: false,
+ title: '',
+ body: { type: 'doc', content: [{ type: 'paragraph' }] },
+ },
}
// =============================================================================
@@ -462,6 +491,7 @@ export interface UpdateAuthConfigInput {
export interface UpdatePortalConfigInput {
oauth?: Partial
features?: Partial
+ welcomeCard?: Partial
}
// =============================================================================
@@ -484,6 +514,8 @@ export interface PublicPortalConfig {
features: PortalFeatures
/** Display name overrides for generic OAuth providers (e.g. custom-oidc → "Okta") */
customProviderNames?: Record
+ /** Welcome card on the portal index. Absent / disabled = nothing rendered. */
+ welcomeCard?: PortalWelcomeCard
}
// =============================================================================
diff --git a/apps/web/src/lib/server/functions/settings.ts b/apps/web/src/lib/server/functions/settings.ts
index 267ceef9..329c0787 100644
--- a/apps/web/src/lib/server/functions/settings.ts
+++ b/apps/web/src/lib/server/functions/settings.ts
@@ -1,5 +1,6 @@
import { z } from 'zod'
import { createServerFn } from '@tanstack/react-start'
+import { tiptapContentSchema } from '@/lib/shared/schemas/posts'
// Import types from barrel export (client-safe)
import {
DEFAULT_PORTAL_CONFIG,
@@ -302,6 +303,15 @@ const updatePortalConfigSchema = z.object({
anonymousPosting: z.boolean().optional(),
})
.optional(),
+ welcomeCard: z
+ .object({
+ enabled: z.boolean().optional(),
+ title: z.string().optional(),
+ // Body is re-sanitized server-side by normalizeWelcomeCardInput;
+ // tiptapContentSchema gates the shape at the boundary.
+ body: tiptapContentSchema.optional(),
+ })
+ .optional(),
})
const saveLogoKeySchema = z.object({
diff --git a/apps/web/src/lib/shared/types/settings.ts b/apps/web/src/lib/shared/types/settings.ts
index d02ea11c..33b71da2 100644
--- a/apps/web/src/lib/shared/types/settings.ts
+++ b/apps/web/src/lib/shared/types/settings.ts
@@ -11,6 +11,8 @@
export type {
PortalAuthMethods,
+ PortalConfig,
+ PortalWelcomeCard,
TenantSettings,
HelpCenterConfig,
AuthConfig,
@@ -24,4 +26,5 @@ export type { FeatureFlags } from '@/lib/server/domains/settings/settings.types'
export {
FEATURE_FLAG_REGISTRY,
DEFAULT_PORTAL_CONFIG,
+ PORTAL_WELCOME_CARD_TITLE_MAX,
} from '@/lib/server/domains/settings/settings.types'
diff --git a/apps/web/src/lib/shared/utils/__tests__/is-empty-tiptap-doc.test.ts b/apps/web/src/lib/shared/utils/__tests__/is-empty-tiptap-doc.test.ts
new file mode 100644
index 00000000..ccad5cd4
--- /dev/null
+++ b/apps/web/src/lib/shared/utils/__tests__/is-empty-tiptap-doc.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect } from 'vitest'
+import { isEmptyTiptapDoc } from '../is-empty-tiptap-doc'
+import type { TiptapContent } from '@/lib/shared/db-types'
+
+describe('isEmptyTiptapDoc', () => {
+ it('treats undefined as empty', () => {
+ expect(isEmptyTiptapDoc(undefined)).toBe(true)
+ })
+
+ it('treats a doc with no content as empty', () => {
+ expect(isEmptyTiptapDoc({ type: 'doc' })).toBe(true)
+ })
+
+ it('treats a single empty paragraph as empty', () => {
+ expect(isEmptyTiptapDoc({ type: 'doc', content: [{ type: 'paragraph' }] })).toBe(true)
+ })
+
+ it('treats a paragraph with only whitespace as empty', () => {
+ const doc: TiptapContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: ' ' }],
+ },
+ ],
+ }
+ expect(isEmptyTiptapDoc(doc)).toBe(true)
+ })
+
+ it('treats a paragraph with real text as non-empty', () => {
+ const doc: TiptapContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ content: [{ type: 'text', text: 'hello' }],
+ },
+ ],
+ }
+ expect(isEmptyTiptapDoc(doc)).toBe(false)
+ })
+
+ it('treats a doc with an image node as non-empty', () => {
+ const doc: TiptapContent = {
+ type: 'doc',
+ content: [{ type: 'image', attrs: { src: 'https://example.com/x.png' } }],
+ }
+ expect(isEmptyTiptapDoc(doc)).toBe(false)
+ })
+
+ it('treats a doc with a heading as non-empty', () => {
+ const doc: TiptapContent = {
+ type: 'doc',
+ content: [
+ {
+ type: 'heading',
+ attrs: { level: 2 },
+ content: [{ type: 'text', text: 'Welcome' }],
+ },
+ ],
+ }
+ expect(isEmptyTiptapDoc(doc)).toBe(false)
+ })
+
+ it('treats a doc with a horizontalRule as non-empty', () => {
+ const doc: TiptapContent = {
+ type: 'doc',
+ content: [{ type: 'horizontalRule' }],
+ }
+ expect(isEmptyTiptapDoc(doc)).toBe(false)
+ })
+})
diff --git a/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts b/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts
new file mode 100644
index 00000000..a1310084
--- /dev/null
+++ b/apps/web/src/lib/shared/utils/is-empty-tiptap-doc.ts
@@ -0,0 +1,37 @@
+import type { TiptapContent } from '@/lib/shared/db-types'
+
+/**
+ * Returns true when a TipTap document carries no visible content — either
+ * because it's undefined, has no children, or its only children are an
+ * empty paragraph or whitespace-only text. Used to decide whether to
+ * render rich-text-driven UI (e.g. the portal welcome card body).
+ *
+ * Non-text leaf nodes such as images, horizontal rules, hard breaks and
+ * embeds count as content, even when they carry no text.
+ */
+export function isEmptyTiptapDoc(doc: TiptapContent | undefined): boolean {
+ if (!doc) return true
+ const content = doc.content
+ if (!content || content.length === 0) return true
+ return content.every((child) => isEmptyNode(child, 0))
+}
+
+// Stack-safety guard for pathological inputs reaching this helper before
+// server-side sanitization (e.g. cached SSR payloads, third-party clients).
+const MAX_DEPTH = 50
+
+function isEmptyNode(node: TiptapContent, depth: number): boolean {
+ if (depth > MAX_DEPTH) return false
+ // Text-bearing block: empty if all text descendants are whitespace.
+ if (node.type === 'paragraph' || node.type === 'heading' || node.type === 'blockquote') {
+ const children = node.content
+ if (!children || children.length === 0) return true
+ return children.every((child) => isEmptyNode(child, depth + 1))
+ }
+ if (node.type === 'text') {
+ return (node.text ?? '').trim().length === 0
+ }
+ // Any other node type — image, horizontalRule, list, table, embed, etc.
+ // — counts as content even with no text.
+ return false
+}
diff --git a/apps/web/src/routes/_portal/index.tsx b/apps/web/src/routes/_portal/index.tsx
index 3af4a2a8..099ac29d 100644
--- a/apps/web/src/routes/_portal/index.tsx
+++ b/apps/web/src/routes/_portal/index.tsx
@@ -66,12 +66,15 @@ export const Route = createFileRoute('/_portal/')({
org.publicPortalConfig?.features?.anonymousVoting ??
DEFAULT_PORTAL_CONFIG.features.anonymousVoting
+ const welcomeCard = org.publicPortalConfig?.welcomeCard
+
return {
org,
baseUrl: context.baseUrl ?? '',
isEmpty: portalData.boards.length === 0,
session,
anonymousVotingEnabled,
+ welcomeCard,
}
},
head: ({ loaderData }) => {
@@ -100,7 +103,7 @@ function PublicPortalPage() {
const intl = useIntl()
const loaderData = Route.useLoaderData()
const search = Route.useSearch()
- const { org, session, anonymousVotingEnabled } = loaderData
+ const { org, session, anonymousVotingEnabled, welcomeCard } = loaderData
// Read filters directly from URL for instant updates
const currentBoard = search.board
@@ -180,6 +183,7 @@ function PublicPortalPage() {
defaultBoardId={portalData.boards[0]?.id}
user={user}
anonymousVotingEnabled={anonymousVotingEnabled}
+ welcomeCard={welcomeCard}
/>
)
diff --git a/apps/web/src/routes/admin/settings.help-center.tsx b/apps/web/src/routes/admin/settings.help-center.tsx
index 7b8ff45a..62b78883 100644
--- a/apps/web/src/routes/admin/settings.help-center.tsx
+++ b/apps/web/src/routes/admin/settings.help-center.tsx
@@ -1,7 +1,8 @@
import { useState, useRef, useEffect, useTransition } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router'
import { useSuspenseQuery } from '@tanstack/react-query'
-import { BookOpenIcon, ArrowPathIcon } from '@heroicons/react/24/solid'
+import { BookOpenIcon } from '@heroicons/react/24/solid'
+import { InlineSpinner } from '@/components/admin/settings/inline-spinner'
import { BackLink } from '@/components/ui/back-link'
import { PageHeader } from '@/components/shared/page-header'
import { SettingsCard } from '@/components/admin/settings/settings-card'
@@ -24,11 +25,6 @@ export const Route = createFileRoute('/admin/settings/help-center')({
component: HelpCenterSettingsPage,
})
-function InlineSpinner({ visible }: { visible: boolean }) {
- if (!visible) return null
- return
-}
-
function HelpCenterSettingsPage() {
const router = useRouter()
const helpCenterConfigQuery = useSuspenseQuery(settingsQueries.helpCenterConfig())
diff --git a/apps/web/src/routes/admin/settings.portal-widget.tsx b/apps/web/src/routes/admin/settings.portal-widget.tsx
index 798ec3aa..a54dcf73 100644
--- a/apps/web/src/routes/admin/settings.portal-widget.tsx
+++ b/apps/web/src/routes/admin/settings.portal-widget.tsx
@@ -24,6 +24,7 @@ import {
BrandingPreviewPanel,
} from '@/components/admin/settings/branding/branding-layout'
import { WidgetPreview } from '@/components/admin/settings/widget/widget-preview'
+import { InlineSpinner } from '@/components/admin/settings/inline-spinner'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Button } from '@/components/ui/button'
@@ -57,11 +58,6 @@ export const Route = createFileRoute('/admin/settings/portal-widget')({
component: PortalWidgetSettingsPage,
})
-function InlineSpinner({ visible }: { visible: boolean }) {
- if (!visible) return null
- return
-}
-
function PortalWidgetSettingsPage() {
const widgetConfigQuery = useSuspenseQuery(settingsQueries.widgetConfig())
const widgetSecretQuery = useSuspenseQuery(settingsQueries.widgetSecret())
diff --git a/apps/web/src/routes/admin/settings.portal.tsx b/apps/web/src/routes/admin/settings.portal.tsx
new file mode 100644
index 00000000..861091c6
--- /dev/null
+++ b/apps/web/src/routes/admin/settings.portal.tsx
@@ -0,0 +1,174 @@
+import { useMemo, useState, useTransition } from 'react'
+import { createFileRoute, useRouter } from '@tanstack/react-router'
+import { useSuspenseQuery } from '@tanstack/react-query'
+import { MegaphoneIcon } from '@heroicons/react/24/solid'
+import type { JSONContent } from '@tiptap/react'
+import { BackLink } from '@/components/ui/back-link'
+import { PageHeader } from '@/components/shared/page-header'
+import { SettingsCard } from '@/components/admin/settings/settings-card'
+import { InlineSpinner } from '@/components/admin/settings/inline-spinner'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { RichTextEditor } from '@/components/ui/rich-text-editor'
+import { PortalWelcomeCard } from '@/components/public/feedback/portal-welcome-card'
+import { settingsQueries } from '@/lib/client/queries/settings'
+import { updatePortalConfigFn } from '@/lib/server/functions/settings'
+import { useImageUpload } from '@/lib/client/hooks/use-image-upload'
+import { DEFAULT_PORTAL_CONFIG, PORTAL_WELCOME_CARD_TITLE_MAX } from '@/lib/shared/types/settings'
+import type {
+ PortalConfig,
+ PortalWelcomeCard as PortalWelcomeCardData,
+} from '@/lib/shared/types/settings'
+import type { TiptapContent } from '@/lib/shared/db-types'
+import { isEmptyTiptapDoc } from '@/lib/shared/utils/is-empty-tiptap-doc'
+
+export const Route = createFileRoute('/admin/settings/portal')({
+ loader: async ({ context }) => {
+ const { requireWorkspaceRole } = await import('@/lib/server/functions/workspace-utils')
+ await requireWorkspaceRole({ data: { allowedRoles: ['admin'] } })
+
+ const { queryClient } = context
+ await queryClient.ensureQueryData(settingsQueries.portalConfig())
+ return {}
+ },
+ component: PortalSettingsPage,
+})
+
+function PortalSettingsPage() {
+ const router = useRouter()
+ const portalConfigQuery = useSuspenseQuery(settingsQueries.portalConfig())
+ const config = portalConfigQuery.data as PortalConfig
+
+ // Initialise once from the loader-warmed query and treat local state as
+ // authoritative until the user explicitly clicks Save. router.invalidate
+ // after a successful save refreshes the cache for the next visit, but the
+ // live form fields never get re-synced from it.
+ const [enabled, setEnabled] = useState(config.welcomeCard?.enabled ?? false)
+ const [title, setTitle] = useState(
+ config.welcomeCard?.title ?? DEFAULT_PORTAL_CONFIG.welcomeCard!.title
+ )
+ const [body, setBody] = useState(
+ config.welcomeCard?.body ?? DEFAULT_PORTAL_CONFIG.welcomeCard!.body
+ )
+ const [saving, setSaving] = useState(false)
+ const [isPending, startTransition] = useTransition()
+ const { upload: uploadImage } = useImageUpload({ prefix: 'portal-welcome' })
+
+ const isBusy = saving || isPending
+
+ async function handleSave() {
+ setSaving(true)
+ try {
+ await updatePortalConfigFn({
+ data: { welcomeCard: { enabled, title, body } },
+ })
+ startTransition(() => router.invalidate())
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const previewCard = useMemo(
+ () => ({ enabled: true, title, body }),
+ [title, body]
+ )
+ const isPreviewEmpty = !title.trim() && isEmptyTiptapDoc(body)
+
+ return (
+
+
+ Settings
+
+
+
+
+
+
+
+
+
+ Shown at the top of the portal home page above the post list
+
+
+
+
+
+ {/* Title and message stay editable when the card is disabled so
+ admins can draft the next announcement without it going live
+ the moment they flip the switch on. */}
+