Skip to content
Open
71 changes: 71 additions & 0 deletions app/(main)/spaces/[slug]/(profile)/-/program/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getCallerProfile } from '@/lib/auth'
import { getVisibleSpaceBySlug } from '@/lib/spaces/store'
import { resolveSpaceManageAccess } from '@/lib/spaces/entitlements'
import { spaceFunctionDef, spaceFunctionEnabled } from '@/lib/spaces/functions'
import { spaceProfileMetadata } from '@/lib/spaces/profile-metadata'
import { spaceManageHref } from '@/lib/spaces/types'
import { spaceProgramHref } from '@/lib/spaces/primary-nav'
import { LEARNING_KINDS, LEARNING_PAGE_SIZE, learningPageNumber, readSpaceLearningPage, type LearningKind } from '@/lib/spaces/program-catalog'
import { SectionHeader } from '@/components/ui/section-header'
import { EmptyState } from '@/components/ui/empty-state'
import { buttonClasses } from '@/components/ui/button'
import { EntityCard } from '@/components/cards/entity-card'

const LABEL: Record<LearningKind, string> = { program: 'Programs', journey: 'Journeys', practice: 'Practices' }
const FUNCTION: Record<LearningKind, 'program' | 'journeys' | 'practices'> = { program: 'program', journey: 'journeys', practice: 'practices' }

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
const { slug } = await params
return spaceProfileMetadata(slug, {
segment: '-/program', label: 'Program',
describe: (name) => `Explore public Programs, Journeys and Practices from ${name}.`,
})
}

export default async function SpaceLearningPage({ params, searchParams }: {
params: Promise<{ slug: string }>
searchParams: Promise<{ programs?: string | string[]; journeys?: string | string[]; practices?: string | string[] }>
}) {
const [{ slug }, query, caller] = await Promise.all([params, searchParams, getCallerProfile()])
const space = await getVisibleSpaceBySlug(slug, caller?.id ?? null)
if (!space) notFound()
const access = await resolveSpaceManageAccess(space, caller?.id ?? null, caller?.webRole ?? null)
const pages = { program: learningPageNumber(query.programs), journey: learningPageNumber(query.journeys), practice: learningPageNumber(query.practices) }
const enabled = LEARNING_KINDS.filter((kind) => {
const def = spaceFunctionDef(FUNCTION[kind])
return !!def && spaceFunctionEnabled(space, def)
})
const groups = await Promise.all(enabled.map((kind) => readSpaceLearningPage(space.id, kind, pages[kind])))
const href = (kind: LearningKind, page: number) => {
const q = new URLSearchParams()
for (const k of LEARNING_KINDS) {
const n = k === kind ? page : pages[k]
if (n > 1) q.set(`${k}s`, String(n))
}
return `${spaceProgramHref(slug)}${q.size ? `?${q}` : ''}`
}
const nothing = groups.every((group) => !group.total && !group.unavailable)
return (
<div className="space-y-8">
<div className="space-y-2">
<SectionHeader title="Program" action={access.canManage || access.staffViewing ? <Link href={spaceManageHref(space.type, slug)} className={buttonClasses('secondary', 'sm')}>Manage learning</Link> : undefined} />
<p className="text-body-sm text-muted">Programs, Journeys and Practices published for everyone by {space.brandName ?? space.name}.</p>
</div>
{nothing ? <EmptyState title="Nothing published here yet" description="Public learning offerings will appear here when they are ready." /> : null}
{groups.filter((group) => group.total > 0 || group.unavailable).map((group) => (
<section key={group.kind} className="space-y-4" aria-label={LABEL[group.kind]}>
<SectionHeader title={LABEL[group.kind]} count={group.unavailable ? undefined : group.total} />
{group.unavailable ? <EmptyState variant="error" title={`Could not load ${LABEL[group.kind].toLowerCase()}`} description="Try again in a moment." action={<Link href={href(group.kind, group.page)} className={buttonClasses('secondary', 'sm')}>Try again</Link>} /> : group.items.length ? <div className="grid gap-4 sm:grid-cols-2">{group.items.map((item) => <EntityCard key={item.id} href={item.href} title={item.title} description={item.summary} />)}</div> : <EmptyState variant="no-results" title="No offerings on this page" description="Start from the first page to see what is available." action={<Link href={href(group.kind, 1)} className={buttonClasses('secondary', 'sm')}>First page</Link>} />}
{!group.unavailable && group.items.length > 0 && group.total > LEARNING_PAGE_SIZE ? <nav className="flex items-center justify-between gap-4" aria-label={`${LABEL[group.kind]} pages`}>
{group.page > 1 ? <Link href={href(group.kind, group.page - 1)} className={buttonClasses('secondary', 'sm')}>Previous</Link> : <span />}
<span className="text-body-sm text-muted">Page {group.page} of {Math.ceil(group.total / LEARNING_PAGE_SIZE)}</span>
{group.page * LEARNING_PAGE_SIZE < group.total ? <Link href={href(group.kind, group.page + 1)} className={buttonClasses('secondary', 'sm')}>Next</Link> : <span />}
</nav> : null}
</section>
))}
</div>
)
}
6 changes: 5 additions & 1 deletion app/(main)/spaces/[slug]/(profile)/full/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { getMyMembership, listMembershipTiers } from '@/lib/spaces/memberships'
import { SpaceFrontDoor } from '@/components/spaces/space-front-door'
import type { SpaceViewer } from '@/lib/spaces/front-door'
import { SpaceProfileModules } from '@/components/widgets/space-profile/space-profile-modules'
import { SpaceContextLinks } from '@/components/spaces/space-context-links'
import { buildSpaceProfileNav } from '@/lib/spaces/profile-nav'
import { OwnerSpaceLayoutPreview } from '@/components/spaces/owner-space-layout-preview'
import { ProfileBodySkeleton } from '@/components/spaces/profile-body-skeleton'
import { SpaceBodyPanel } from '@/components/spaces/workspace/space-body-panel'
Expand Down Expand Up @@ -74,7 +76,8 @@ async function SpaceProfileBody({ slug, panel, area, tab, view }: { slug: string
// A visitor / non-owner never sees a panel (canSeeAsOwner is false).
if (canSeeAsOwner && isPanelId(panel)) return <SpaceBodyPanel slug={space.slug} panel={panel} area={area} tab={tab} view={view} />

if (canSeeAsOwner) return <OwnerSpaceLayoutPreview slug={space.slug} />
const { contextLinks } = await buildSpaceProfileNav(space)
if (canSeeAsOwner) return <><OwnerSpaceLayoutPreview slug={space.slug} /><SpaceContextLinks catalog={contextLinks} /></>

// 🔴 THE MEMBER TREE IS THE ONLY ONE THAT CAN TELL A MEMBER FROM A STRANGER (LIVE-524), and until
// now it never asked. A signed-in non-member and a paying member got BYTE-IDENTICAL bodies here,
Expand All @@ -91,6 +94,7 @@ async function SpaceProfileBody({ slug, panel, area, tab, view }: { slug: string
return (
<>
<SpaceProfileModules space={toProfileContext(space)} grid={grid} />
<SpaceContextLinks catalog={contextLinks} />
<SpaceFrontDoor
viewer={viewer}
brandName={space.brandName?.trim() || space.name}
Expand Down
4 changes: 3 additions & 1 deletion app/(public)/spaces/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { toProfileContext } from '@/lib/spaces/profile-modules'
import { parseEntityLayout } from '@/lib/entity-blocks/layout'
import { SpaceProfileModules } from '@/components/widgets/space-profile/space-profile-modules'
import { SpaceProfileMenuView } from '@/components/spaces/space-profile-menu-view'
import { SpaceContextLinks } from '@/components/spaces/space-context-links'
import { buildPublicSpaceProfileNav } from '@/lib/spaces/profile-nav'
import { listMembershipTiers } from '@/lib/spaces/memberships'
import { SpaceFrontDoor } from '@/components/spaces/space-front-door'
Expand Down Expand Up @@ -161,7 +162,7 @@ export default async function PublicSpacePage({
// `buildPublicSpaceProfileNav` is the cookie-free entry point: reading a caller here would make
// this route dynamic and cost the share URL its ISR (ADR-1465 / ADR-1526). profile-nav.public
// .test.ts makes getCallerProfile throw and proves this call still resolves.
const { tabs } = await buildPublicSpaceProfileNav(space)
const { tabs, contextLinks } = await buildPublicSpaceProfileNav(space)

const structuredData = (
<JsonLd
Expand Down Expand Up @@ -216,6 +217,7 @@ export default async function PublicSpacePage({
<CardholderPerkNote spaceId={space.id} />
</Suspense>
<SpaceProfileModules space={toProfileContext(space)} grid={grid} />
<SpaceContextLinks catalog={contextLinks} />
{/* THE VISITOR'S FRONT DOOR (LIVE-524), and it sits BELOW the operator's blocks on purpose.
LIVE-500's probe fails if `<SpaceProfileModules` appears after `<SignInCta` in this
file, because a sign-in card in front of the content is the exact defect ADR-1526
Expand Down
21 changes: 10 additions & 11 deletions components/spaces/site-menu-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,17 @@ import {
} from '@/lib/spaces/site-menu'

// THE MENU EDITOR (lib/spaces/site-menu.ts). One component, mounted in the Space console's Page settings
// and in the website editor's header settings, editing the ONE menu and header logo both platforms
// read. It loads its own data (getSiteMenuEditorData re-gates the caller), so any surface can drop it
// and in the website editor's header settings, editing the website menu and shared header logo.
// The Frequency Space has its own fixed navigation. It loads its own data
// (getSiteMenuEditorData re-gates the caller), so any surface can drop it
// in with just the Space slug.
//
// A Free Space sees its automatic menu, locked, with the Business note. The logo choice is open to
// every plan.

const VISIBILITY: { value: MenuVisibility; label: string }[] = [
{ value: 'both', label: 'Space page and website' },
{ value: 'space', label: 'Space page only' },
{ value: 'both', label: 'Website and Space Home links' },
{ value: 'space', label: 'Space Home links only' },
{ value: 'website', label: 'Website only' },
]

Expand Down Expand Up @@ -93,7 +94,7 @@ export function SiteMenuEditor({ slug, className }: { slug: string; className?:
if (!('error' in r)) {
setDirty(false)
setData({ ...data, saved: true })
setMessage({ tone: 'ok', text: 'Saved. Your Space page and website now share this menu.' })
setMessage({ tone: 'ok', text: 'Saved. Your website menu is updated.' })
} else setMessage({ tone: 'error', text: r.error })
})
const reset = () =>
Expand All @@ -114,19 +115,17 @@ export function SiteMenuEditor({ slug, className }: { slug: string; className?:
<section className="space-y-4" aria-labelledby="site-menu-heading">
<div>
<h3 id="site-menu-heading" className="text-body font-semibold text-text">
Menu
Website menu
</h3>
<p className="text-body-sm text-muted">
{data.hasWebsite
? 'One menu for your Space page and your website. Edit it here or in the other one and both stay in sync.'
: 'The menu across the top of your Space page. When you publish a website, it uses this menu too.'}
Your website keeps its own pages and menu. Links shown on your Space appear below Home; its main menu stays Home, Community, Calendar and Program, with Manage separate for your team.
</p>
</div>

{!data.canEdit && (
<p className="flex items-start gap-2 rounded-card bg-surface-elevated px-4 py-3 text-body-sm text-muted">
<Lock className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
Your menu builds itself from your pages and the features you turn on. Reordering, custom links and dropdowns come
Your website menu builds itself from your pages and the features you turn on. Reordering, custom links and dropdowns come
with the Business plan.
</p>
)}
Expand Down Expand Up @@ -230,7 +229,7 @@ function MenuItemRow({
{!isDropdown && (
<TargetPicker label="Links to" target={item.target} options={options} disabled={readOnly} onChange={(target) => onChange({ target })} />
)}
<Field label="Shows on" className="min-w-44">
<Field label="Shown in" className="min-w-44">
<Select
value={item.visibility}
disabled={readOnly}
Expand Down
19 changes: 19 additions & 0 deletions components/spaces/space-context-links.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Link from 'next/link'
import { buttonClasses } from '@/components/ui/button'
import { SectionHeader } from '@/components/ui/section-header'
import { spaceContextLinks } from '@/lib/spaces/primary-nav'
import type { SpaceProfileTab } from '@/lib/spaces/profile-tab'

/** Existing Space functions remain reachable on Home; the primary menu stays small. */
export function SpaceContextLinks({ catalog }: { catalog: SpaceProfileTab[] }) {
const links = spaceContextLinks(catalog)
if (!links.length) return null
return (
<section className="space-y-4" aria-label="More from this Space">
<SectionHeader title="More from this Space" />
<div className="flex flex-wrap gap-3">
{links.map((link) => <Link key={link.href} href={link.href} className={buttonClasses('secondary', 'sm')} target={link.external ? '_blank' : undefined} rel={link.external ? 'noopener noreferrer' : undefined}>{link.label}</Link>)}
</div>
</section>
)
}
15 changes: 15 additions & 0 deletions components/spaces/space-profile-menu-view.render.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { SpaceProfileMenuView } from './space-profile-menu-view'
import type { SpaceProfileTab } from '@/lib/spaces/profile-tab'
import { primarySpaceTabs } from '@/lib/spaces/primary-nav'

// THE SPACE MENU RAIL, MEASURED IN A DOM (LIVE-744).
//
Expand Down Expand Up @@ -139,3 +140,17 @@ vi.mock('@/lib/theme/design', async (original) => ({
...(await original<typeof import('@/lib/theme/design')>()),
SITE_DESIGN: 'dawn',
}))


describe('fixed primary destinations and separate management', () => {
it('marks Community on a Circle subpage with one active primary destination', () => {
const el = mount(<SpaceProfileMenuView tabs={primarySpaceTabs('temple')} pathname={`${base}/circles`} />)
expect(railHrefs(el)).toEqual([base, `${base}/community`, `${base}/calendar`, `${base}/-/program`])
expect([...el.querySelectorAll('[aria-current="page"]')].map((a) => a.getAttribute('href'))).toEqual([`${base}/community`])
})
it('marks Manage alone when the manager opens its workspace', () => {
const el = mount(<SpaceProfileMenuView tabs={primarySpaceTabs('temple')} pathname={base} panel="manage" canManage />)
expect([...el.querySelectorAll('[aria-current="page"]')].map((a) => a.getAttribute('href'))).toEqual([`${base}?panel=manage`])
expect(rail(el).textContent).not.toContain('Manage')
})
})
7 changes: 6 additions & 1 deletion components/spaces/space-profile-menu-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SURFACE_PANELS, isPanelId } from '@/components/spaces/workspace/surface
import type { SpaceProfileTab } from '@/lib/spaces/profile-tab'
import { SpaceMenuDropdown } from '@/components/spaces/space-menu-dropdown'
import { SITE_DESIGN } from '@/lib/theme/design'
import { spacePrimarySection } from '@/lib/spaces/primary-nav'

// THE SPACE MENU'S MARKUP, WITH NO HOOKS (LIVE-522).
//
Expand Down Expand Up @@ -69,12 +70,16 @@ export function SpaceProfileMenuView({
// the "You are editing X" affordance — that cue is only for the narrower single-surface panels.
const openPanelLabel =
canManage && isPanelId(rawPanel) && rawPanel !== 'manage' ? SURFACE_PANELS[rawPanel].label : null
const manageActive = canManage && rawPanel === 'manage'

// Home is found by its menu key: a saved menu (lib/spaces/site-menu.ts) may move or drop it, and the
// Manage pin below hangs off the Space's index either way.
const indexHref = homeHref ?? tabs.find((t) => t.key === 'home')?.href ?? tabs[0]?.href
const ownerPath = indexHref && pathname.startsWith(`${indexHref}/`) ? pathname.slice(indexHref.length) : ''
const manageActive = canManage && (rawPanel === 'manage' || /^\/(?:manage|settings|crm)(?:\/|$)/.test(ownerPath))
const primary = tabs.some((tab) => tab.key === 'feature:program')
const section = indexHref ? spacePrimarySection(pathname, indexHref) : null
const isActive = (tab: SpaceProfileTab): boolean => {
if (primary) return !manageActive && openPanelLabel == null && tab.key === section
if (tab.href.includes('#')) return false
if (tab.href === indexHref) return pathname === tab.href && openPanelLabel == null
return pathname === tab.href || pathname.startsWith(`${tab.href}/`)
Expand Down
21 changes: 21 additions & 0 deletions docs/MENU-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,27 @@ to any of them fails CI exactly as a new hand‑rolled menu would.
the system it belongs to**, or put `// menu-ok: <reason>` on the declaration line. Never grow
`FROZEN_MENU_DEBT` — that list only shrinks, except the OWN-058 raise recorded in §Frozen debt.

## Frequency Space public navigation (owner decision, 2026-11-04)

The Frequency Space menu is **Home · Community · Calendar · Program**, with the existing
role-gated **Manage** control separate. The platform root tenant has no Community wall and omits
that destination. Primary links navigate to pages and never contain section fragments. Existing
functions remain reachable from contextual Home links and their existing direct addresses.

Program collects the Space's public Programs, Journeys and Practices. It lives at
`/spaces/<slug>/-/program`: the reserved `-` segment cannot be a custom profile-page slug, so an
existing `/spaces/<slug>/program` page is preserved. Catalog reads are bounded, tenant-filtered,
explicitly public/published and subject to the request client's RLS, even for owners. Unlisted
content remains reachable only through its existing authorized or curated surfaces.

The website keeps its independent custom pages, menu settings and plan entitlement. Stored menu
configuration and page documents are retained. Saved Space URL, dropdown and feature links retain their
existing plan and viewer gates as contextual Home links; website-only visibility stays website-only.
Changing the Frequency navigation does not migrate,
delete or overwrite them. The old role/function gates continue to determine contextual doors and
management access. Primary selection follows the current public section; a management workspace
never marks Home active at the same time.

## Do not

- Do not hand‑roll a per‑scope menu array in a page/component — renaming the variable no longer helps.
Expand Down
12 changes: 12 additions & 0 deletions docs/ledger/rows/LIVE-891.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"id": "LIVE-891",
"patch": {
"verify": {
"kind": "cmd",
"cmd": "node -e \"const fs=require('fs');const r=(p)=>fs.existsSync(p)?fs.readFileSync(p,'utf8'):'';const miss=[];if(!/resolveSiteMenu\\(readSiteMenu\\(space\\.preferences\\)/.test(r('lib/spaces/profile-nav.ts')))miss.push('the Space page menu does not read the saved menu');const sp=r('components/sites/site-page.tsx');if(!/siteMenuLinks\\(space,/.test(sp)||!/'website', \\{ canEdit: spaceCanUseFullWebsite\\(space\\)/.test(sp))miss.push('the website menu does not read the saved menu behind the plan gate');const ed=r('components/spaces/site-menu-editor.tsx');if(!/Space Home links only/.test(ed)||!/Website only/.test(ed)||!/Add a dropdown/.test(ed))miss.push('the editor has no visibility choice or dropdowns');const mounts=['app/(main)/spaces/[slug]/manage/layout/page.tsx','components/sites/editor/shell.tsx'].filter(p=>/<SiteMenuEditor/.test(r(p)));if(mounts.length<2)miss.push('the editor is not on both platforms');if(!/readHeaderLogo\\(space\\.preferences\\)/.test(r('app/(main)/spaces/[slug]/(profile)/layout.tsx'))||!/headerLogoSlot\\(readHeaderLogo/.test(sp))miss.push('the header logo choice is not read on both platforms');if(miss.length){console.error('LIVE-891: '+miss.join('; '));process.exit(1)}\""
}
},
"append": {
"detail": "2026-11-04: Daniel approved a fixed four-destination Frequency Space menu. Saved links continue to power contextual Space Home links and the independent website menu. The visibility choice is now named Space Home links only. The verification command updates that one literal; its saved-menu, plan gate, dropdown, both editor mounts and both header-logo checks remain intact."
}
}
Loading
Loading