diff --git a/CHANGELOG.md b/CHANGELOG.md index d85cad8..8991d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project are documented here. Format follows [Keep a ## [Unreleased] ### Added +- **Customize hub** at `/customize` (admin/board): a dedicated home for per-space customization with a section rail (Roles, Membership tiers, Areas, Invite codes, Onboarding), split into one focused module per panel. Replaces the overloaded Settings tab strip. +- **Roles editor**: rename and recolor the five built-in roles, and create/edit/delete custom org-structure roles. Renamed roles now display (sidebar user card; helper `lib/role-labels.ts`). +- **Invite share links**: copy a one-click `/signup?invite=CODE` link in addition to the bare code; the signup page prefills the code and preselects join mode from `?invite=`. + +### Changed +- Brand mark reverted to the terminal `>_` glyph (`components/brand-mark.tsx`) across the landing nav/footer, onboarding header, and favicon. `AtlasLogo` remains only on the resources project page. +- Settings slimmed to Space, Integrations, and Webhooks. Roles, Tiers, Areas, Invites, and Onboarding moved to `/customize`. + +### Added (continued) - **Configurable member onboarding**: a new member who joins via invite code is walked through an admin-defined sequence of steps at `/onboarding` before reaching the app. Built-in step types: welcome, code of conduct (required acknowledgement), profile, dues/payment nudge. Admins can add custom content steps with sanitized markdown/HTML, reorder, enable/disable, and mark steps required from Settings -> Onboarding. Founders skip the member flow (their onboarding is space configuration). Existing members were backfilled as completed so nobody is trapped. Server-side enforcement of required steps. New tables: `space_onboarding_steps`; new columns `space_members.onboarding_completed_at` and `onboarding_progress`. - `SafeMarkdown` component (react-markdown + remark-gfm + rehype-raw + rehype-sanitize) for rendering admin-authored markdown/HTML without XSS. - `docs/UX_AND_PERSONAS.md`: persona model and the UX audit that drove onboarding. diff --git a/app/(app)/customize/customize-client.tsx b/app/(app)/customize/customize-client.tsx new file mode 100644 index 0000000..3dfb696 --- /dev/null +++ b/app/(app)/customize/customize-client.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useState } from 'react' +import { RolesPanel } from './panels/roles-panel' +import { TiersPanel } from './panels/tiers-panel' +import { AreasPanel } from './panels/areas-panel' +import { InvitesPanel } from './panels/invites-panel' +import { OnboardingPanel } from './panels/onboarding-panel' +import type { RoleLabelRow, CustomRole, Tier, Invite, Step, Area } from './panels/types' + +type Section = 'roles' | 'tiers' | 'areas' | 'invites' | 'onboarding' + +const SECTIONS: { key: Section; label: string }[] = [ + { key: 'roles', label: 'Roles' }, + { key: 'tiers', label: 'Membership tiers' }, + { key: 'areas', label: 'Areas' }, + { key: 'invites', label: 'Invite codes' }, + { key: 'onboarding', label: 'Onboarding' }, +] + +interface Props { + isAdmin: boolean + roleLabels: RoleLabelRow[] + customRoles: CustomRole[] + tiers: Tier[] + invites: Invite[] + onboardingSteps: Step[] + areas: Area[] +} + +export function CustomizeClient(props: Props) { + const [section, setSection] = useState
('roles') + + return ( +
+
+

Customize

+

+ Make the space yours. Operational settings (identity, integrations, webhooks) live under Settings. +

+
+ +
+ + +
+ {section === 'roles' && } + {section === 'tiers' && } + {section === 'areas' && } + {section === 'invites' && } + {section === 'onboarding' && } +
+
+
+ ) +} diff --git a/app/(app)/customize/page.tsx b/app/(app)/customize/page.tsx new file mode 100644 index 0000000..4ee7e5a --- /dev/null +++ b/app/(app)/customize/page.tsx @@ -0,0 +1,51 @@ +import { redirect } from 'next/navigation' +import { createClient } from '@/lib/supabase/server' +import { CustomizeClient } from './customize-client' + +export const dynamic = 'force-dynamic' + +export default async function CustomizePage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/login') + + const { data: member } = await supabase + .from('space_members') + .select('space_id, role') + .eq('user_id', user.id) + .in('status', ['current', 'unverified', 'late']) + .single() + if (!member) redirect('/login') + + const isAdmin = member.role === 'admin' + const canCustomize = member.role === 'admin' || member.role === 'board' + if (!canCustomize) redirect('/dashboard') + + const [ + { data: roleLabels }, + { data: customRoles }, + { data: tiers }, + { data: invites }, + { data: onboardingSteps }, + { data: areas }, + ] = await Promise.all([ + supabase.from('space_role_labels').select('role, display_name, description, color').eq('space_id', member.space_id), + supabase.from('space_custom_roles').select('id, slug, name, description, color, sort_order').eq('space_id', member.space_id).order('sort_order'), + supabase.from('space_tiers').select('id, slug, name, description, monthly_price_cents, billing_cadence, is_system, is_archived, sort_order').eq('space_id', member.space_id).order('sort_order'), + supabase.from('space_invites').select('id, code, label, expires_at, max_uses, uses_count, is_enabled, created_at').eq('space_id', member.space_id).order('created_at', { ascending: false }), + supabase.from('space_onboarding_steps').select('id, step_key, step_type, title, body, config, is_enabled, is_required, is_system, sort_order').eq('space_id', member.space_id).order('sort_order'), + supabase.from('space_areas').select('id, code, name, icon, sort_order, is_archived').eq('space_id', member.space_id).order('sort_order').order('name'), + ]) + + return ( + } + customRoles={(customRoles ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; color: string | null; sort_order: number }>} + tiers={(tiers ?? []) as Array<{ id: string; slug: string; name: string; description: string | null; monthly_price_cents: number; billing_cadence: string; is_system: boolean; is_archived: boolean; sort_order: number }>} + invites={(invites ?? []) as Array<{ id: string; code: string; label: string | null; expires_at: string | null; max_uses: number | null; uses_count: number; is_enabled: boolean; created_at: string }>} + onboardingSteps={(onboardingSteps ?? []) as Array<{ id: string; step_key: string; step_type: 'welcome' | 'code_of_conduct' | 'profile' | 'payment' | 'content'; title: string; body: string | null; config: Record; is_enabled: boolean; is_required: boolean; is_system: boolean; sort_order: number }>} + areas={(areas ?? []) as Array<{ id: string; code: string; name: string; icon: string | null; sort_order: number; is_archived: boolean }>} + /> + ) +} diff --git a/app/(app)/customize/panels/areas-panel.tsx b/app/(app)/customize/panels/areas-panel.tsx new file mode 100644 index 0000000..d58e202 --- /dev/null +++ b/app/(app)/customize/panels/areas-panel.tsx @@ -0,0 +1,58 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { createArea, updateArea, deleteArea } from '@/lib/actions' +import { Card } from './card' +import type { Area } from './types' + +export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; areas: Area[] }) { + const [areas, setAreas] = useState(initial) + const [showNew, setShowNew] = useState(false) + const [d, setD] = useState({ code: '', name: '', icon: '' }) + + return ( + setShowNew(v => !v)} className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap">{showNew ? 'Cancel' : '+ New area'} : undefined} + > + {showNew && ( +
{ + e.preventDefault() + const result = await createArea({ code: d.code.trim().toLowerCase(), name: d.name.trim(), icon: d.icon.trim() || undefined }) + if ('error' in result && result.error) { toast.error(result.error); return } + toast.success('Area created') + const created = result as { data?: { id: string } } + setAreas(prev => [...prev, { id: created.data?.id ?? crypto.randomUUID(), code: d.code.trim().toLowerCase(), name: d.name.trim(), icon: d.icon.trim() || null, sort_order: 100, is_archived: false }]) + setShowNew(false); setD({ code: '', name: '', icon: '' }) + }} + className="mb-4 p-4 border border-border rounded bg-background grid grid-cols-1 md:grid-cols-3 gap-3" + > + setD({ ...d, code: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-') })} placeholder="code" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" /> + setD({ ...d, name: e.target.value })} placeholder="Display name" className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary" /> + setD({ ...d, icon: e.target.value })} placeholder="Icon (emoji, optional)" className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary" /> + +
+ )} +
    + {areas.length === 0 &&
  • No areas yet.
  • } + {[...areas].sort((a, b) => a.sort_order - b.sort_order).map(a => ( +
  • + { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === a.sort_order) return; const res = await updateArea({ areaId: a.id, sort_order: n }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" /> + {a.icon && {a.icon}} + { const v = e.target.value.trim(); if (!v || v === a.name) return; const res = await updateArea({ areaId: a.id, name: v }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, name: v } : x)); toast.success('Saved') }} className="flex-1 min-w-[140px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" /> + {a.code} + {isAdmin && ( +
    + + +
    + )} +
  • + ))} +
+
+ ) +} diff --git a/app/(app)/customize/panels/card.tsx b/app/(app)/customize/panels/card.tsx new file mode 100644 index 0000000..94273bf --- /dev/null +++ b/app/(app)/customize/panels/card.tsx @@ -0,0 +1,25 @@ +// Shared section card chrome for every Customize panel. +export function Card({ + title, + blurb, + action, + children, +}: { + title: string + blurb: string + action?: React.ReactNode + children: React.ReactNode +}) { + return ( +
+
+
+

{title}

+

{blurb}

+
+ {action} +
+ {children} +
+ ) +} diff --git a/app/(app)/customize/panels/invites-panel.tsx b/app/(app)/customize/panels/invites-panel.tsx new file mode 100644 index 0000000..1cec8aa --- /dev/null +++ b/app/(app)/customize/panels/invites-panel.tsx @@ -0,0 +1,78 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { createInvite, updateInvite, deleteInvite } from '@/lib/actions' +import { Card } from './card' +import type { Invite } from './types' + +function inviteLink(code: string) { + const origin = typeof window !== 'undefined' ? window.location.origin : '' + return `${origin}/signup?invite=${encodeURIComponent(code)}` +} + +export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean; invites: Invite[] }) { + const [invites, setInvites] = useState(initial) + const [showNew, setShowNew] = useState(false) + const [d, setD] = useState({ code: '', label: '', expires_at: '', max_uses: '' }) + + return ( + setShowNew(v => !v)} className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap">{showNew ? 'Cancel' : '+ New invite'} : undefined} + > + {showNew && ( +
{ + e.preventDefault() + const result = await createInvite({ code: d.code.trim().toUpperCase() || undefined, label: d.label.trim() || undefined, expires_at: d.expires_at || undefined, max_uses: d.max_uses ? Number(d.max_uses) : undefined }) + if ('error' in result && result.error) { toast.error(result.error); return } + const c = result as { id: string; code: string } + toast.success(`Invite created: ${c.code}`) + setInvites(prev => [{ id: c.id, code: c.code, label: d.label.trim() || null, expires_at: d.expires_at || null, max_uses: d.max_uses ? Number(d.max_uses) : null, uses_count: 0, is_enabled: true, created_at: new Date().toISOString() }, ...prev]) + setShowNew(false); setD({ code: '', label: '', expires_at: '', max_uses: '' }) + }} + className="mb-4 p-4 border border-border rounded bg-background grid grid-cols-1 md:grid-cols-2 gap-3" + > + setD({ ...d, code: e.target.value.toUpperCase().replace(/[^A-Z0-9-]/g, '') })} placeholder="CODE (blank = auto)" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" /> + setD({ ...d, label: e.target.value })} placeholder="Label (optional)" className="bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1.5 focus:outline-none focus:border-primary" /> + setD({ ...d, expires_at: e.target.value })} className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" /> + setD({ ...d, max_uses: e.target.value })} placeholder="Max uses (blank = unlimited)" className="bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1.5 focus:outline-none focus:border-primary" /> + +
+ )} +
    + {invites.length === 0 &&
  • No invites yet.
  • } + {invites.map(inv => { + const expired = inv.expires_at ? new Date(inv.expires_at) < new Date() : false + const usedUp = inv.max_uses !== null && inv.uses_count >= inv.max_uses + return ( +
  • +
    +
    + {inv.code} + {inv.label && {inv.label}} + {!inv.is_enabled && disabled} + {expired && expired} + {usedUp && at cap} +
    +

    {inv.expires_at ? `expires ${new Date(inv.expires_at).toLocaleString()}` : 'permanent'} · uses {inv.uses_count}{inv.max_uses ? ` / ${inv.max_uses}` : ' (unlimited)'}

    +
    +
    + + + {isAdmin && ( + <> + + + + )} +
    +
  • + ) + })} +
+
+ ) +} diff --git a/app/(app)/customize/panels/onboarding-panel.tsx b/app/(app)/customize/panels/onboarding-panel.tsx new file mode 100644 index 0000000..ee4df00 --- /dev/null +++ b/app/(app)/customize/panels/onboarding-panel.tsx @@ -0,0 +1,66 @@ +'use client' + +import { useState } from 'react' +import { toast } from 'sonner' +import { createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from '@/lib/actions' +import { Card } from './card' +import type { Step } from './types' + +export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean; steps: Step[] }) { + const [steps, setSteps] = useState(initial) + + return ( + { + const result = await createOnboardingStep({ step_type: 'content', title: 'New step', body: 'Edit this content in Customize, Onboarding.', sort_order: steps.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 }) + if ('error' in result && result.error) { toast.error(result.error); return } + toast.success('Step added') + const id = (result as { id: string }).id + setSteps(prev => [...prev, { id, step_key: `custom-${id.slice(0, 12)}`, step_type: 'content', title: 'New step', body: 'Edit this content in Customize, Onboarding.', config: {}, is_enabled: true, is_required: false, is_system: false, sort_order: prev.reduce((m, s) => Math.max(m, s.sort_order), 0) + 1 }]) + }} + className="font-mono text-[10px] border border-border px-3 py-1.5 rounded hover:border-primary hover:text-primary transition whitespace-nowrap" + > + + Custom step + + ) : undefined} + > +
    + {[...steps].sort((a, b) => a.sort_order - b.sort_order).map(s => ( +
  • +
    + { const n = parseInt(e.target.value, 10); if (isNaN(n) || n === s.sort_order) return; const res = await updateOnboardingStep(s.id, { sort_order: n }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, sort_order: n } : x)) }} className="w-14 bg-background border border-border text-foreground font-mono text-xs rounded px-2 py-1 focus:outline-none focus:border-primary" /> + {s.step_type.replace(/_/g, ' ')} + { const v = e.target.value.trim(); if (!v || v === s.title) return; const res = await updateOnboardingStep(s.id, { title: v }); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.map(x => x.id === s.id ? { ...x, title: v } : x)) }} className="flex-1 min-w-[160px] bg-background border border-border text-foreground font-sans text-sm rounded px-2 py-1 focus:outline-none focus:border-primary" /> + {s.is_system && built-in} +
    + {(s.step_type === 'welcome' || s.step_type === 'code_of_conduct' || s.step_type === 'payment' || s.step_type === 'content') && ( +