diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml
new file mode 100644
index 00000000..b9abd733
--- /dev/null
+++ b/.github/workflows/chromatic.yml
@@ -0,0 +1,57 @@
+name: Chromatic
+
+on:
+ pull_request:
+ branches: ['**']
+ push:
+ branches: ['main']
+
+jobs:
+ chromatic:
+ name: Visual Regression Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0 # Required for Chromatic to detect changes
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.24.0
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - name: Setup pnpm cache
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Initialize MSW
+ run: pnpm msw init public --save=false
+
+ - name: Publish to Chromatic
+ uses: chromaui/action@latest
+ with:
+ projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
+ buildScriptName: build-storybook
+ onlyChanged: true
+ autoAcceptChanges: main
+ exitOnceUploaded: true
diff --git a/.github/workflows/deploy-storybook.yml b/.github/workflows/deploy-storybook.yml
new file mode 100644
index 00000000..09d84911
--- /dev/null
+++ b/.github/workflows/deploy-storybook.yml
@@ -0,0 +1,81 @@
+name: Deploy Storybook
+
+on:
+ push:
+ branches: ['main']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: 'pages'
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build Storybook
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.24.0
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - name: Setup pnpm cache
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Initialize MSW
+ run: pnpm msw init public --save=false
+
+ - name: Build Storybook
+ run: pnpm build-storybook
+
+ - name: Add CNAME file
+ run: echo "design.cloudnativedays.no" > storybook-static/CNAME
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v5
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: storybook-static
+
+ deploy:
+ name: Deploy to GitHub Pages
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ needs: build
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml
index ae8fccdf..2a7f23b9 100644
--- a/.github/workflows/pr-checks.yml
+++ b/.github/workflows/pr-checks.yml
@@ -92,6 +92,50 @@ jobs:
- name: Run tests
run: pnpm run test
+ storybook:
+ name: Storybook Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24'
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.24.0
+
+ - name: Get pnpm store directory
+ shell: bash
+ run: |
+ echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
+
+ - name: Setup pnpm cache
+ uses: actions/cache@v4
+ with:
+ path: ${{ env.STORE_PATH }}
+ key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
+ restore-keys: |
+ ${{ runner.os }}-pnpm-store-
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Install Playwright browsers
+ run: pnpm exec playwright install --with-deps chromium
+
+ - name: Initialize MSW
+ run: pnpm msw init public --save=false
+
+ - name: Run Storybook tests
+ run: pnpm run storybook:test-ci
+
build:
name: Build
runs-on: ubuntu-latest
diff --git a/.gitignore b/.gitignore
index b4794f89..44336592 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,9 @@ dist/
# backups
*.tar.gz
+storybook-static/*
+
+# msw
+public/mockServiceWorker.js
+# storybook local env
+.storybook/.env
diff --git a/.storybook/.env.example b/.storybook/.env.example
new file mode 100644
index 00000000..fdfd575e
--- /dev/null
+++ b/.storybook/.env.example
@@ -0,0 +1,20 @@
+# Storybook Environment Variables (Example Template)
+# ---------------------------------------------------------------------------
+# This file is an example/template only. Do NOT put real secrets or production
+# configuration in this committed file.
+#
+# Recommended usage:
+# 1. Copy this file to `.storybook/.env` (which should be gitignored).
+# 2. Replace the example values in that copy with your real local values.
+#
+# The variables below are commented out so this example file does not define
+# any active environment variables when loaded.
+# ---------------------------------------------------------------------------
+
+# Sanity CMS
+# NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
+# NEXT_PUBLIC_SANITY_DATASET=production
+# NEXT_PUBLIC_SANITY_API_VERSION=2024-01-01
+
+# Base URL for stories
+# NEXT_PUBLIC_BASE_URL=http://localhost:6006
diff --git a/.storybook/decorators/TRPCDecorator.tsx b/.storybook/decorators/TRPCDecorator.tsx
new file mode 100644
index 00000000..504ec18a
--- /dev/null
+++ b/.storybook/decorators/TRPCDecorator.tsx
@@ -0,0 +1,35 @@
+import React from 'react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { httpBatchLink } from '@trpc/client'
+import { api } from '@/lib/trpc/client'
+import type { Decorator } from '@storybook/nextjs-vite'
+
+// Create these outside the decorator to avoid recreating on every render
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ refetchOnWindowFocus: false,
+ staleTime: Infinity,
+ },
+ },
+})
+
+const trpcClient = api.createClient({
+ links: [
+ httpBatchLink({
+ url: '/api/trpc',
+ // MSW will intercept these requests (relative URL for cross-origin compatibility)
+ }),
+ ],
+})
+
+export const TRPCDecorator: Decorator = (Story) => {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/.storybook/main.ts b/.storybook/main.ts
new file mode 100644
index 00000000..f0449c6c
--- /dev/null
+++ b/.storybook/main.ts
@@ -0,0 +1,36 @@
+import type { StorybookConfig } from '@storybook/nextjs-vite'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+
+const config: StorybookConfig = {
+ stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
+ addons: ['@storybook/addon-docs'],
+ framework: {
+ name: '@storybook/nextjs-vite',
+ options: {
+ nextConfigPath: join(__dirname, '../next.config.ts'),
+ },
+ },
+ staticDirs: ['../public'],
+ typescript: {
+ check: false,
+ reactDocgen: 'react-docgen-typescript',
+ reactDocgenTypescriptOptions: {
+ shouldExtractLiteralValuesFromEnum: true,
+ propFilter: (prop) =>
+ prop.parent ? !/node_modules/.test(prop.parent.fileName) : true,
+ },
+ },
+ core: {
+ disableTelemetry: true,
+ },
+ docs: {},
+ features: {
+ sidebarOnboardingChecklist: false,
+ },
+}
+
+export default config
diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
new file mode 100644
index 00000000..1f288983
--- /dev/null
+++ b/.storybook/preview-head.html
@@ -0,0 +1,19 @@
+
+
+
+
+
+
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
new file mode 100644
index 00000000..0b7bc87c
--- /dev/null
+++ b/.storybook/preview.tsx
@@ -0,0 +1,211 @@
+import type { Preview } from '@storybook/nextjs-vite'
+import type { Decorator } from '@storybook/nextjs-vite'
+import { initialize, mswLoader } from 'msw-storybook-addon'
+import { TRPCDecorator } from './decorators/TRPCDecorator'
+import '../src/styles/tailwind.css'
+
+// Initialize MSW
+initialize()
+
+const preview: Preview = {
+ parameters: {
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i,
+ },
+ },
+ nextjs: {
+ appDirectory: true,
+ navigation: {
+ pathname: '/',
+ query: {},
+ },
+ },
+ backgrounds: {
+ default: 'light',
+ values: [
+ {
+ name: 'light',
+ value: '#ffffff',
+ },
+ {
+ name: 'dark',
+ value: '#111827',
+ },
+ ],
+ },
+ options: {
+ storySort: {
+ order: [
+ 'Getting Started',
+ ['Introduction', 'Developer Guide'],
+ 'Design System',
+ [
+ 'Foundation',
+ ['Spacing', 'Shadows', 'Icons'],
+ 'Brand',
+ [
+ 'Brand Story',
+ 'Color Palette',
+ 'Typography',
+ 'Buttons',
+ 'Cloud Native Patterns',
+ ],
+ 'Examples',
+ ['Hero Sections', 'Conference Landing Page', 'Admin Pages'],
+ ],
+ 'Components',
+ ['Data Display', 'Feedback', 'Forms', 'Icons', 'Layout'],
+ 'Systems',
+ [
+ 'Program',
+ [
+ 'Architecture',
+ 'TalkCard',
+ 'TalkPromotionCard',
+ 'ProgramFilters',
+ 'ViewModeSelector',
+ 'ProgramScheduleView',
+ 'ProgramGridView',
+ 'ProgramListView',
+ 'ProgramAgendaView',
+ ],
+ 'Proposals',
+ [
+ 'Architecture',
+ 'Admin',
+ [
+ 'ProposalsList',
+ 'ProposalCard',
+ 'ProposalsFilter',
+ 'ProposalStatistics',
+ 'ProposalPreview',
+ 'ProposalReviewForm',
+ 'ProposalReviewList',
+ 'ProposalReviewSummary',
+ 'FeaturedTalksManager',
+ ],
+ 'ProposalCoSpeaker',
+ 'ProposalGuidanceSidebar',
+ 'CompactProposalList',
+ 'AttachmentDisplay',
+ ],
+ 'Speakers',
+ [
+ 'Architecture',
+ 'Admin',
+ [
+ 'SpeakerTable',
+ 'SpeakerActions',
+ 'SpeakerMultiSelect',
+ 'FeaturedSpeakersManager',
+ ],
+ 'Overview',
+ 'SpeakerAvatars',
+ 'ClickableSpeakerNames',
+ 'SpeakerDetailsForm',
+ 'SpeakerProfilePreview',
+ ],
+ 'Sponsors',
+ [
+ 'Architecture',
+ 'Workflow Diagram',
+ 'Admin',
+ [
+ 'Overview',
+ 'Pipeline',
+ [
+ 'SponsorCRMPipeline',
+ 'SponsorCard',
+ 'SponsorBoardColumn',
+ 'BoardViewSwitcher',
+ 'SponsorCRMForm',
+ 'SponsorBulkActions',
+ 'MobileFilterSheet',
+ 'OnboardingLinkButton',
+ 'ContractReadinessIndicator',
+ 'ImportHistoricSponsorsButton',
+ ],
+ 'Dashboard',
+ ['Metrics', 'Action Items', 'Activity Timeline'],
+ 'Tiers',
+ [
+ 'SponsorTierManagement',
+ 'SponsorTierEditor',
+ 'SponsorAddModal',
+ ],
+ 'Contacts',
+ [
+ 'SponsorContactTable',
+ 'SponsorContactEditor',
+ 'SponsorContactActions',
+ ],
+ 'Email',
+ [
+ 'EmailModal',
+ 'SponsorIndividualEmailModal',
+ 'SponsorDiscountEmailModal',
+ ],
+ 'Form',
+ [
+ 'SponsorLogoEditor',
+ 'SponsorCombobox',
+ 'TierRadioGroup',
+ 'StatusListbox',
+ 'OrganizerCombobox',
+ 'TagCombobox',
+ 'ContractValueInput',
+ 'AddonsCheckboxGroup',
+ ],
+ 'SponsorCRMFilterBar',
+ 'SponsorContactRoleSelect',
+ ],
+ 'Components',
+ ['SponsorLogo', 'Sponsors', 'SponsorThankYou'],
+ 'Onboarding',
+ ['SponsorOnboardingForm'],
+ 'Email',
+ ['SponsorTemplatePicker', 'SponsorEmailTemplateEditor'],
+ 'Form',
+ ['SponsorGlobalInfoFields'],
+ 'Stream',
+ ['SponsorBanner'],
+ ],
+ ],
+ ],
+ },
+ },
+ },
+ globalTypes: {
+ theme: {
+ name: 'Theme',
+ description: 'Global theme for components',
+ defaultValue: 'light',
+ toolbar: {
+ icon: 'circlehollow',
+ items: [
+ { value: 'light', title: 'Light', icon: 'sun' },
+ { value: 'dark', title: 'Dark', icon: 'moon' },
+ ],
+ dynamicTitle: true,
+ },
+ },
+ },
+ decorators: [
+ TRPCDecorator,
+ ((Story, context) => {
+ const theme = context.globals.theme || 'light'
+ return (
+
+ )
+ }) as Decorator,
+ ],
+ loaders: [mswLoader],
+}
+
+export default preview
diff --git a/AGENTS.md b/AGENTS.md
index ce99845d..34acf349 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -42,7 +42,7 @@ The site is multi-tenant, meaning it can be used for multiple events or conferen
- Follow Next.js best practices (App Router, Server Components, Server Actions where applicable).
- Utilize TypeScript for type safety.
- Adhere to Tailwind CSS utility-first principles for styling.
-- Refer to the branding page (`/branding` or `docs/BRANDING.md`) for styling guidelines and maintain visual consistency.
+- Refer to the Storybook documentation (run `pnpm storybook`) for comprehensive design system guidelines, brand colors, typography, and component examples. All UI/UX design decisions should align with the documented design system.
- Fetch and manage content primarily through Sanity.
- Implement authentication flows using NextAuth.js 5.0 with the specified providers.
- Ensure code is clean, maintainable and only comment when absolutely required to understand why the code is written in a certain way.
@@ -52,6 +52,134 @@ The site is multi-tenant, meaning it can be used for multiple events or conferen
- **JSX/TSX Content:** Use HTML entities (`'` for apostrophes and `"` for quotes) instead of raw quotes in JSX/TSX content to comply with linting rules.
- **Icons:** Use Heroicons (`@heroicons/react`) for all icon needs instead of creating custom SVG elements. Import icons from either `/24/outline` for stroke icons or `/24/solid` for filled icons. This ensures consistency and maintainability across the application.
+### Storybook & Design System
+
+The Storybook (`pnpm storybook`) is the single source of truth for all UI/UX documentation and should be consulted and updated for all visual design tasks.
+
+**Storybook Structure:**
+
+- **Getting Started/** - Introduction and developer guides
+- **Design System/Foundation/** - Colors, Typography, Spacing, Shadows, Icons
+- **Design System/Brand/** - Brand story, color palette, typography system, buttons, cloud native patterns
+- **Design System/Examples/** - Integration patterns showing how components work together (hero sections, conference landing page, admin pages)
+- **Components/** - General-purpose reusable components organized by category:
+ - **Data Display/** - CollapsibleDescription, CollapsibleSection, DownloadableImage, Email Templates, ShowMore, TypewriterEffect, VideoEmbed
+ - **Feedback/** - ConfirmationModal, ErrorDisplay, LoadingSkeleton
+ - **Forms/** - Form Elements, FilterDropdown, PortableTextEditor
+ - **Icons/** - OSIcons, SocialIcons
+ - **Layout/** - BackLink, Button, Container, Logo, MissingAvatar, ThemeToggle
+- **Systems/** - Domain-specific feature documentation organized by system:
+ - **Program/** - Schedule views (grid, list, agenda, schedule), filters, talk cards
+ - **Proposals/** - Proposal submission, review, and admin management components
+ - **Speakers/** - Speaker profiles, forms, and admin management components
+ - **Sponsors/** - Sponsor system (CRM pipeline, contacts, email, tiers, onboarding, dashboard)
+
+**Story Types & Organization:**
+
+There are two distinct types of stories with different purposes:
+
+1. **Component Stories** (individual component docs)
+ - Live alongside components in `/src/components/`
+ - Document a single component's props, variants, and usage
+ - Include interactive controls for testing
+ - Domain-specific components go under `Systems/{SystemName}/`
+
+2. **Examples Stories** (integration patterns)
+ - Located in `/src/docs/design-system/examples/`
+ - Show how multiple components work together
+ - Demonstrate common application patterns
+ - Render live components with realistic data
+
+**When to Update Storybook:**
+
+- When adding new reusable components, create corresponding stories
+- When modifying brand colors, typography, or visual patterns
+- When creating new page layouts or component variants
+- When documenting UI/UX best practices or guidelines
+- When adding domain-specific components, place them under the appropriate system
+
+**Story Files Location:**
+
+- General component stories: alongside components in `/src/components/`
+- Domain-specific component stories: alongside components with `Systems/{SystemName}/` title prefix
+- Documentation pages: `/src/docs/` (organized by category)
+- System documentation: `/src/docs/{SystemName}.stories.tsx`
+
+**Creating Component Stories:**
+
+```typescript
+// Component story (lives next to component file)
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { MyComponent } from './MyComponent'
+
+const meta = {
+ title: 'Components/Layout/MyComponent', // or 'Systems/Speakers/MyComponent' for domain-specific
+ component: MyComponent,
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: { variant: 'default' },
+}
+```
+
+**Creating Documentation Stories:**
+
+```typescript
+// Documentation story (in /src/docs/)
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Design System/Foundation/NewCategory',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Documentation: Story = {
+ render: () => ,
+}
+```
+
+**Known Limitations:**
+
+- `CloudNativePattern` and components using it (e.g., `SpeakerPromotionCard`) cannot be rendered in Storybook due to static SVG import incompatibility. Document these with screenshots or code examples instead.
+
+**Interaction Testing:**
+
+Stories can include `play` functions that test component behavior interactively. Import testing utilities from `storybook/test`:
+
+```typescript
+import { expect, fn, userEvent, within } from 'storybook/test'
+
+export const ClickTest: Story = {
+ args: { onClick: fn() },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const button = canvas.getByRole('button')
+ await userEvent.click(button)
+ await expect(args.onClick).toHaveBeenCalled()
+ },
+}
+```
+
+Interaction tests run automatically in CI via `pnpm run storybook:test-ci` and can be run locally with `pnpm run storybook:test` (requires Storybook running).
+
+**Visual Regression with Chromatic:**
+
+The project uses Chromatic for visual regression testing. PRs automatically trigger visual snapshots that compare against the main branch. To set up Chromatic:
+
+1. Add `CHROMATIC_PROJECT_TOKEN` secret to GitHub repository settings
+2. PRs will show visual diff status checks
+3. Changes to main are auto-accepted as the new baseline
+
### Privacy and GDPR Compliance
- **User Data Protection:** Always abide by GDPR regulations when handling any user data, including but not limited to:
@@ -115,7 +243,7 @@ The site is multi-tenant, meaning it can be used for multiple events or conferen
- **Next.js Cache Components:** All production public pages use Next.js 16+ Cache Components with the `'use cache'` directive for optimal performance in our multi-tenant architecture.
- **Wrapper Pattern:** Pages follow a consistent pattern where an outer component reads `headers()` to extract the domain, then passes it to an inner cached component that uses `getConferenceForDomain(domain)`.
-- **Cache Durations:** Use `cacheLife('hours')` for frequently changing content (homepage, program, speakers, tickets), `cacheLife('days')` for stable content (CFP), and `cacheLife('max')` for static pages (conduct, terms, privacy, branding).
+- **Cache Durations:** Use `cacheLife('hours')` for frequently changing content (homepage, program, speakers, tickets), `cacheLife('days')` for stable content (CFP), and `cacheLife('max')` for static pages (conduct, terms, privacy).
- **Cache Tags:** Every cached component includes a `cacheTag('content:pagename')` for granular invalidation via `revalidateTag()`.
- **Exclusions:** Authentication flows (`/signin`) and development/testing pages (`/css-test`) are intentionally excluded from caching as they require request-time execution.
@@ -155,6 +283,10 @@ The site is multi-tenant, meaning it can be used for multiple events or conferen
- **Testing:** `pnpm run test` - Runs Jest tests silently.
- **Testing (Debug):** `pnpm run test:debug` - Runs Jest tests with debug output.
- **Testing (Watch):** `pnpm run test:watch` - Runs Jest tests in watch mode.
+- **Storybook:** `pnpm storybook` - Starts the Storybook development server for design system documentation.
+- **Storybook Build:** `pnpm build-storybook` - Builds static Storybook for deployment.
+- **Storybook Tests:** `pnpm run storybook:test` - Runs Storybook interaction tests (requires Storybook running).
+- **Storybook Tests (CI):** `pnpm run storybook:test-ci` - Builds Storybook and runs tests in CI mode.
- Run sanity commands with `pnpm sanity {command}` (e.g., `pnpm sanity deploy`) - do not use `npx sanity` directly.
## Code Organization & Refactoring
diff --git a/__tests__/lib/sponsor-crm/contract-readiness.test.ts b/__tests__/lib/sponsor-crm/contract-readiness.test.ts
new file mode 100644
index 00000000..715dd62e
--- /dev/null
+++ b/__tests__/lib/sponsor-crm/contract-readiness.test.ts
@@ -0,0 +1,319 @@
+import { describe, it, expect } from '@jest/globals'
+import {
+ checkContractReadiness,
+ groupMissingBySource,
+ type MissingField,
+} from '@/lib/sponsor-crm/contract-readiness'
+import type { SponsorForConferenceExpanded } from '@/lib/sponsor-crm/types'
+
+describe('contract-readiness', () => {
+ const createMockSponsor = (
+ overrides: Partial = {},
+ ): SponsorForConferenceExpanded => ({
+ _id: 'sfc-1',
+ _createdAt: '2026-01-01T00:00:00Z',
+ _updatedAt: '2026-01-01T00:00:00Z',
+ sponsor: {
+ _id: 'sponsor-1',
+ name: 'Acme Corp',
+ website: 'https://acme.com',
+ logo: 'logo.png',
+ orgNumber: '123456789',
+ address: 'Oslo, Norway',
+ },
+ conference: {
+ _id: 'conf-1',
+ title: 'Cloud Native Days Norway 2026',
+ organizer: 'Cloud Native Bergen',
+ organizerOrgNumber: '987654321',
+ organizerAddress: 'Bergen, Norway',
+ city: 'Bergen',
+ venueName: 'Bergen Conference Center',
+ venueAddress: 'Main St 1, Bergen',
+ startDate: '2026-06-10',
+ endDate: '2026-06-11',
+ sponsorEmail: 'sponsors@cloudnativebergen.no',
+ },
+ tier: {
+ _id: 'tier-1',
+ title: 'Gold',
+ tagline: 'Premium sponsorship',
+ tierType: 'standard',
+ price: [{ _key: '1', amount: 50000, currency: 'NOK' }],
+ },
+ contractStatus: 'none',
+ status: 'prospect',
+ invoiceStatus: 'not-sent',
+ contractValue: 50000,
+ contractCurrency: 'NOK',
+ contactPersons: [
+ {
+ _key: 'contact-1',
+ name: 'John Doe',
+ email: 'john@acme.com',
+ isPrimary: true,
+ },
+ ],
+ ...overrides,
+ })
+
+ describe('checkContractReadiness', () => {
+ it('should return ready=true when all required fields are present', () => {
+ const sponsor = createMockSponsor()
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(true)
+ expect(result.missing).toHaveLength(0)
+ })
+
+ it('should detect missing organizer fields', () => {
+ const sponsor = createMockSponsor({
+ conference: {
+ _id: 'conf-1',
+ title: 'Cloud Native Days Norway 2026',
+ // Missing organizer, organizerOrgNumber, organizerAddress
+ },
+ })
+
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(false)
+ expect(result.missing).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: 'conference.organizer',
+ label: 'Organizer name',
+ source: 'organizer',
+ }),
+ expect.objectContaining({
+ field: 'conference.organizerOrgNumber',
+ label: 'Organizer org. number',
+ source: 'organizer',
+ }),
+ expect.objectContaining({
+ field: 'conference.organizerAddress',
+ label: 'Organizer address',
+ source: 'organizer',
+ }),
+ ]),
+ )
+ })
+
+ it('should detect missing sponsor fields', () => {
+ const sponsor = createMockSponsor({
+ sponsor: {
+ _id: 'sponsor-1',
+ name: 'Acme Corp',
+ website: 'https://acme.com',
+ logo: 'logo.png',
+ // Missing orgNumber and address
+ },
+ })
+
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(false)
+ expect(result.missing).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: 'sponsor.orgNumber',
+ label: 'Sponsor org. number',
+ source: 'sponsor',
+ }),
+ expect.objectContaining({
+ field: 'sponsor.address',
+ label: 'Sponsor address',
+ source: 'sponsor',
+ }),
+ ]),
+ )
+ })
+
+ it('should detect missing contact person', () => {
+ const sponsor = createMockSponsor({
+ contactPersons: [],
+ })
+
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(false)
+ expect(result.missing).toContainEqual(
+ expect.objectContaining({
+ field: 'contactPersons',
+ label: 'Primary contact person',
+ source: 'sponsor',
+ }),
+ )
+ })
+
+ it('should accept contact person without isPrimary if only one contact', () => {
+ const sponsor = createMockSponsor({
+ contactPersons: [
+ {
+ _key: 'contact-1',
+ name: 'John Doe',
+ email: 'john@acme.com',
+ // iPrimary not set, but only one contact
+ },
+ ],
+ })
+
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(true)
+ })
+
+ it('should require primary contact when multiple contacts exist', () => {
+ const sponsor = createMockSponsor({
+ contactPersons: [
+ {
+ _key: 'contact-1',
+ name: 'John Doe',
+ email: 'john@acme.com',
+ // No isPrimary
+ },
+ {
+ _key: 'contact-2',
+ name: 'Jane Smith',
+ email: 'jane@acme.com',
+ // No isPrimary
+ },
+ ],
+ })
+
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(false)
+ expect(result.missing).toContainEqual(
+ expect.objectContaining({
+ field: 'contactPersons',
+ source: 'sponsor',
+ }),
+ )
+ })
+
+ it('should detect missing pipeline fields', () => {
+ const sponsor = createMockSponsor({
+ tier: undefined,
+ contractValue: 0,
+ })
+
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(false)
+ expect(result.missing).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: 'tier',
+ label: 'Sponsor tier',
+ source: 'pipeline',
+ }),
+ expect.objectContaining({
+ field: 'contractValue',
+ label: 'Contract value',
+ source: 'pipeline',
+ }),
+ ]),
+ )
+ })
+
+ it('should handle missing venue and dates gracefully', () => {
+ const sponsor = createMockSponsor({
+ conference: {
+ _id: 'conf-1',
+ title: 'Cloud Native Days Norway 2026',
+ organizer: 'Cloud Native Bergen',
+ organizerOrgNumber: '987654321',
+ organizerAddress: 'Bergen, Norway',
+ sponsorEmail: 'sponsors@cloudnativebergen.no',
+ // Missing startDate, venueName
+ },
+ })
+
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(false)
+ expect(result.missing).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: 'conference.startDate',
+ source: 'organizer',
+ }),
+ expect.objectContaining({
+ field: 'conference.venueName',
+ source: 'organizer',
+ }),
+ ]),
+ )
+ })
+
+ it('should handle all missing fields across all sources', () => {
+ const sponsor = createMockSponsor({
+ sponsor: {
+ _id: 'sponsor-1',
+ name: 'Acme Corp',
+ website: 'https://acme.com',
+ logo: 'logo.png',
+ },
+ conference: {
+ _id: 'conf-1',
+ title: 'Cloud Native Days Norway 2026',
+ },
+ tier: undefined,
+ contractValue: undefined,
+ contactPersons: [],
+ })
+
+ const result = checkContractReadiness(sponsor)
+
+ expect(result.ready).toBe(false)
+ expect(result.missing.length).toBeGreaterThan(5)
+
+ const sources = new Set(result.missing.map((m) => m.source))
+ expect(sources).toContain('organizer')
+ expect(sources).toContain('sponsor')
+ expect(sources).toContain('pipeline')
+ })
+ })
+
+ describe('groupMissingBySource', () => {
+ it('should group missing fields by source', () => {
+ const missing: MissingField[] = [
+ {
+ field: 'conference.organizer',
+ label: 'Organizer name',
+ source: 'organizer',
+ },
+ {
+ field: 'sponsor.orgNumber',
+ label: 'Sponsor org. number',
+ source: 'sponsor',
+ },
+ { field: 'tier', label: 'Sponsor tier', source: 'pipeline' },
+ {
+ field: 'conference.startDate',
+ label: 'Conference start date',
+ source: 'organizer',
+ },
+ ]
+
+ const grouped = groupMissingBySource(missing)
+
+ expect(grouped.organizer).toHaveLength(2)
+ expect(grouped.sponsor).toHaveLength(1)
+ expect(grouped.pipeline).toHaveLength(1)
+ })
+
+ it('should return empty arrays for sources with no missing fields', () => {
+ const missing: MissingField[] = [
+ { field: 'tier', label: 'Sponsor tier', source: 'pipeline' },
+ ]
+
+ const grouped = groupMissingBySource(missing)
+
+ expect(grouped.organizer).toHaveLength(0)
+ expect(grouped.sponsor).toHaveLength(0)
+ expect(grouped.pipeline).toHaveLength(1)
+ })
+ })
+})
diff --git a/__tests__/lib/sponsor-crm/contract-variables.test.ts b/__tests__/lib/sponsor-crm/contract-variables.test.ts
new file mode 100644
index 00000000..8bab2909
--- /dev/null
+++ b/__tests__/lib/sponsor-crm/contract-variables.test.ts
@@ -0,0 +1,285 @@
+import { describe, it, expect } from '@jest/globals'
+import {
+ buildContractVariables,
+ CONTRACT_VARIABLE_DESCRIPTIONS,
+ type ContractVariableContext,
+} from '@/lib/sponsor-crm/contract-variables'
+
+describe('contract-variables', () => {
+ describe('buildContractVariables', () => {
+ const createBasicContext = (): ContractVariableContext => ({
+ sponsor: {
+ name: 'Acme Corporation',
+ orgNumber: '123456789',
+ address: 'Main Street 123, Oslo, Norway',
+ website: 'https://acme.com',
+ },
+ contactPerson: {
+ name: 'John Doe',
+ email: 'john.doe@acme.com',
+ },
+ tier: {
+ title: 'Gold Sponsor',
+ tagline: 'Premium partnership package',
+ },
+ addons: [
+ { title: 'Workshop Sponsorship' },
+ { title: 'Party Sponsorship' },
+ ],
+ contractValue: 75000,
+ contractCurrency: 'NOK',
+ conference: {
+ title: 'Cloud Native Days Norway 2026',
+ startDate: '2026-06-10',
+ endDate: '2026-06-11',
+ city: 'Bergen',
+ organizer: 'Cloud Native Bergen',
+ organizerOrgNumber: '987654321',
+ organizerAddress: 'Conference Street 1, Bergen, Norway',
+ venueName: 'Bergen Conference Center',
+ venueAddress: 'Venue Road 10, Bergen',
+ sponsorEmail: 'sponsors@cloudnativebergen.no',
+ },
+ })
+
+ it('should build all basic required variables', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.SPONSOR_NAME).toBe('Acme Corporation')
+ expect(vars.CONFERENCE_TITLE).toBe('Cloud Native Days Norway 2026')
+ expect(vars.TODAY_DATE).toBeDefined()
+ })
+
+ it('should build sponsor variables', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.SPONSOR_ORG_NUMBER).toBe('123456789')
+ expect(vars.SPONSOR_ADDRESS).toBe('Main Street 123, Oslo, Norway')
+ expect(vars.SPONSOR_WEBSITE).toBe('https://acme.com')
+ })
+
+ it('should build contact person variables', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONTACT_NAME).toBe('John Doe')
+ expect(vars.CONTACT_EMAIL).toBe('john.doe@acme.com')
+ })
+
+ it('should build tier variables', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.TIER_NAME).toBe('Gold Sponsor')
+ expect(vars.TIER_TAGLINE).toBe('Premium partnership package')
+ })
+
+ it('should build addons list', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.ADDONS_LIST).toBe('Workshop Sponsorship, Party Sponsorship')
+ })
+
+ it('should format contract value with currency', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONTRACT_VALUE).toContain('75')
+ expect(vars.CONTRACT_VALUE).toContain('000')
+ expect(vars.CONTRACT_VALUE_NUMBER).toBe('75000')
+ expect(vars.CONTRACT_CURRENCY).toBe('NOK')
+ })
+
+ it('should handle different currencies', () => {
+ const ctx = createBasicContext()
+ ctx.contractCurrency = 'USD'
+ ctx.contractValue = 10000
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONTRACT_CURRENCY).toBe('USD')
+ expect(vars.CONTRACT_VALUE_NUMBER).toBe('10000')
+ })
+
+ it('should build conference date variables', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONFERENCE_DATE).toContain('10')
+ expect(vars.CONFERENCE_DATE).toContain('June')
+ expect(vars.CONFERENCE_DATE).toContain('2026')
+ expect(vars.CONFERENCE_YEAR).toBe('2026')
+ })
+
+ it('should build date range for multi-day conference in same month', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ // 10–11 June 2026
+ expect(vars.CONFERENCE_DATES).toContain('10')
+ expect(vars.CONFERENCE_DATES).toContain('11')
+ expect(vars.CONFERENCE_DATES).toContain('June')
+ expect(vars.CONFERENCE_DATES).toContain('2026')
+ })
+
+ it('should build date range for conference spanning multiple months', () => {
+ const ctx = createBasicContext()
+ ctx.conference.startDate = '2026-05-31'
+ ctx.conference.endDate = '2026-06-01'
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONFERENCE_DATES).toContain('May')
+ expect(vars.CONFERENCE_DATES).toContain('June')
+ })
+
+ it('should handle single-day conference', () => {
+ const ctx = createBasicContext()
+ delete ctx.conference.endDate
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONFERENCE_DATES).toBe(vars.CONFERENCE_DATE)
+ })
+
+ it('should build conference location variables', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONFERENCE_CITY).toBe('Bergen')
+ expect(vars.VENUE_NAME).toBe('Bergen Conference Center')
+ expect(vars.VENUE_ADDRESS).toBe('Venue Road 10, Bergen')
+ })
+
+ it('should build organizer variables', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.ORG_NAME).toBe('Cloud Native Bergen')
+ expect(vars.ORG_ORG_NUMBER).toBe('987654321')
+ expect(vars.ORG_ADDRESS).toBe('Conference Street 1, Bergen, Norway')
+ expect(vars.ORG_EMAIL).toBe('sponsors@cloudnativebergen.no')
+ })
+
+ it('should handle missing optional sponsor fields', () => {
+ const ctx = createBasicContext()
+ delete ctx.sponsor.orgNumber
+ delete ctx.sponsor.address
+ delete ctx.sponsor.website
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.SPONSOR_NAME).toBe('Acme Corporation')
+ expect(vars.SPONSOR_ORG_NUMBER).toBeUndefined()
+ expect(vars.SPONSOR_ADDRESS).toBeUndefined()
+ expect(vars.SPONSOR_WEBSITE).toBeUndefined()
+ })
+
+ it('should handle missing contact person', () => {
+ const ctx = createBasicContext()
+ delete ctx.contactPerson
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONTACT_NAME).toBeUndefined()
+ expect(vars.CONTACT_EMAIL).toBeUndefined()
+ })
+
+ it('should handle missing tier', () => {
+ const ctx = createBasicContext()
+ delete ctx.tier
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.TIER_NAME).toBeUndefined()
+ expect(vars.TIER_TAGLINE).toBeUndefined()
+ })
+
+ it('should handle empty addons list', () => {
+ const ctx = createBasicContext()
+ ctx.addons = []
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.ADDONS_LIST).toBeUndefined()
+ })
+
+ it('should default to NOK when currency not specified', () => {
+ const ctx = createBasicContext()
+ delete ctx.contractCurrency
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONTRACT_CURRENCY).toBe('NOK')
+ })
+
+ it('should handle missing contract value', () => {
+ const ctx = createBasicContext()
+ delete ctx.contractValue
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONTRACT_VALUE).toBeUndefined()
+ expect(vars.CONTRACT_VALUE_NUMBER).toBeUndefined()
+ expect(vars.CONTRACT_CURRENCY).toBe('NOK')
+ })
+
+ it('should handle missing optional conference fields', () => {
+ const ctx = createBasicContext()
+ delete ctx.conference.city
+ delete ctx.conference.organizerOrgNumber
+ delete ctx.conference.organizerAddress
+ delete ctx.conference.venueName
+ delete ctx.conference.venueAddress
+ delete ctx.conference.sponsorEmail
+
+ const vars = buildContractVariables(ctx)
+
+ expect(vars.CONFERENCE_TITLE).toBe('Cloud Native Days Norway 2026')
+ expect(vars.CONFERENCE_CITY).toBeUndefined()
+ expect(vars.ORG_ORG_NUMBER).toBeUndefined()
+ expect(vars.ORG_ADDRESS).toBeUndefined()
+ expect(vars.VENUE_NAME).toBeUndefined()
+ expect(vars.VENUE_ADDRESS).toBeUndefined()
+ expect(vars.ORG_EMAIL).toBeUndefined()
+ })
+
+ it('should format today date in correct format', () => {
+ const ctx = createBasicContext()
+ const vars = buildContractVariables(ctx)
+
+ // Should be in format "11 February 2026"
+ const dateRegex = /^\d{1,2} [A-Z][a-z]+ \d{4}$/
+ expect(vars.TODAY_DATE).toMatch(dateRegex)
+ })
+ })
+
+ describe('CONTRACT_VARIABLE_DESCRIPTIONS', () => {
+ it('should have descriptions for all common variables', () => {
+ const expectedVars = [
+ 'SPONSOR_NAME',
+ 'SPONSOR_ORG_NUMBER',
+ 'SPONSOR_ADDRESS',
+ 'CONTACT_NAME',
+ 'TIER_NAME',
+ 'CONTRACT_VALUE',
+ 'CONFERENCE_TITLE',
+ 'CONFERENCE_DATE',
+ 'ORG_NAME',
+ 'ORG_ORG_NUMBER',
+ 'ORG_ADDRESS',
+ 'VENUE_NAME',
+ ]
+
+ expectedVars.forEach((varName) => {
+ expect(CONTRACT_VARIABLE_DESCRIPTIONS[varName]).toBeDefined()
+ expect(CONTRACT_VARIABLE_DESCRIPTIONS[varName].length).toBeGreaterThan(
+ 0,
+ )
+ })
+ })
+ })
+})
diff --git a/__tests__/lib/sponsor-crm/onboarding.test.ts b/__tests__/lib/sponsor-crm/onboarding.test.ts
new file mode 100644
index 00000000..caefd048
--- /dev/null
+++ b/__tests__/lib/sponsor-crm/onboarding.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect } from '@jest/globals'
+import { buildOnboardingUrl } from '@/lib/sponsor-crm/onboarding'
+
+describe('onboarding', () => {
+ describe('buildOnboardingUrl', () => {
+ it('should build correct onboarding URL', () => {
+ const baseUrl = 'https://cloudnativebergen.no'
+ const token = 'abc-123-def-456'
+
+ const url = buildOnboardingUrl(baseUrl, token)
+
+ expect(url).toBe(
+ 'https://cloudnativebergen.no/sponsor/onboarding/abc-123-def-456',
+ )
+ })
+
+ it('should handle base URL without trailing slash', () => {
+ const url = buildOnboardingUrl('https://example.com', 'token-123')
+
+ expect(url).toBe('https://example.com/sponsor/onboarding/token-123')
+ })
+ })
+})
diff --git a/docs/BRANDING.md b/docs/BRANDING.md
deleted file mode 100644
index a7223dfe..00000000
--- a/docs/BRANDING.md
+++ /dev/null
@@ -1,376 +0,0 @@
-# Cloud Native Days Norway - Branding Guide
-
-This document outlines the comprehensive brand system for Cloud Native Days Norway, including colors, typography, icons, patterns, and component examples.
-
-**đź“– For a comprehensive interactive brand guide with live examples, visit: [/branding](/branding)**
-
-The live branding page includes:
-
-- Interactive color palette with usage guidelines
-- Typography showcase with all font combinations
-- Complete icon library from Heroicons
-- Cloud Native pattern system with authentic project logos
-- Button showcase with all variants and states
-- Hero examples with different configurations
-- Speaker display patterns and guidelines
-- Talk promotion components
-- Call-to-action examples
-- Professional email templates
-- Component documentation and accessibility guidelines
-
-## Brand Story & Design Principles
-
-Cloud Native Days Norway embodies the spirit of Norway's tech community: innovative yet grounded, collaborative yet independent, modern yet respectful of tradition. Our visual identity draws inspiration from Bergen's dramatic landscapes—the meeting of mountains and sea, the interplay of mist and clarity, the harmony of natural and urban elements.
-
-### Design Principles
-
-1. **Developer-First**: Every design choice considers the developer experience and technical audience
-2. **Accessible by Design**: We prioritize accessibility and inclusive design in all brand applications
-3. **Nordic Minimalism**: Clean, functional design that lets content shine without unnecessary complexity
-4. **Community Driven**: Our brand reflects the collaborative spirit of the open source community
-
-## Quick Reference
-
-### Primary Colors
-
-- **Cloud Blue**: `bg-brand-cloud-blue` or `text-brand-cloud-blue` (#1D4ED8)
- - Used in headlines and CTA buttons. Strong, tech-oriented, and accessible.
-- **Aqua Gradient**: `bg-aqua-gradient` (linear-gradient(135deg, #3B82F6, #06B6D4))
- - Available as: `bg-aqua-gradient`, `bg-aqua-gradient-to-r`, `bg-aqua-gradient-to-b`
- - Used in backgrounds, section dividers, or digital badges.
-- **Brand Gradient**: `bg-brand-gradient` (linear-gradient(135deg, #1D4ED8, #06B6D4))
- - Enhanced brand gradient for premium sections and hero areas.
-- **Nordic Gradient**: `bg-nordic-gradient` (linear-gradient(135deg, #6366F1, #1D4ED8))
- - Accent gradient combining nordic purple with cloud blue.
-
-### Secondary Colors
-
-- **Sky Mist**: `bg-brand-sky-mist` or `bg-neutral-mist` (#E0F2FE)
- - A soft sky blue for background fills, cards, or hover states.
-- **Fresh Green**: `bg-brand-fresh-green` or `bg-secondary-500` (#10B981)
- - Reflects the green in the logo. Good for highlights, tags, or eco-related themes.
-- **Glacier White**: `bg-brand-glacier-white` or `bg-neutral-glacier` (#F9FAFB)
- - A clean background neutral to keep the interface minimal and modern.
-
-### Accent Colors
-
-- **Nordic Purple**: `bg-brand-nordic-purple` or `bg-accent-purple` (#6366F1)
- - Subtle contrast for agenda highlights, speaker names, or session tags.
-- **Sunbeam Yellow**: `bg-brand-sunbeam-yellow` or `bg-accent-yellow` (#FACC15)
- - For urgency, early-bird ticket alerts, and callouts without breaking the cool-tone harmony.
-
-### Neutral Base
-
-- **Slate Gray**: `bg-brand-slate-gray` or `text-neutral-slate` (#334155)
- - For body text, navigation, or footer elements.
-- **Frosted Steel**: `bg-brand-frosted-steel` or `bg-neutral-steel` (#CBD5E1)
- - For dividers, secondary buttons, or muted labels.
-
-### Convenience Color Classes
-
-The colors are also mapped to semantic color scales:
-
-- `bg-primary-{50-950}` - Blue tones (aqua-start to cloud-blue)
-- `bg-secondary-{50-950}` - Green tones (centered on fresh-green)
-
-## Cloud Native Pattern System
-
-Our animated background patterns incorporate authentic cloud native project logos with intelligent focus/diffusion effects. The pattern system includes icons from major CNCF projects:
-
-### Container Orchestration
-
-- Kubernetes, containerd, etcd - the foundation of modern container orchestration
-
-### Observability & Monitoring
-
-- Prometheus, Jaeger, Falco for comprehensive system observability and security
-
-### Service Mesh & Networking
-
-- Istio, Envoy, Cilium for secure, observable service-to-service communication
-
-### Packaging & GitOps
-
-- Helm, Argo, Crossplane for application packaging and deployment automation
-
-### Available Project Icons
-
-The pattern system includes white versions of icons from these cloud native projects:
-
-- Argo, Backstage, Cilium, CloudNativePG, Crossplane, etcd, Falco
-- gRPC, Harbor, Helm, Istio, Jaeger, KubeVirt, Kured, Logging Operator
-- OpenFeature, Prometheus, Shipwright, VirtualKubelet, Vitess, WasmEdge Runtime
-
-### Focus/Diffusion Technology
-
-- **Small Icons (Sharp Focus)**: Higher opacity, vibrant colors, no blur. Draw attention as foreground elements
-- **Medium Icons (Balanced)**: Moderate opacity and subtle blur. Provide visual texture without distraction
-- **Large Icons (Soft Diffusion)**: Lower opacity, muted colors, soft blur. Create atmospheric background depth
-
-### Pattern Configuration Options
-
-- **Content Background**: Subtle pattern for content sections and cards (opacity: 0.06, baseSize: 25, iconCount: 18)
-- **Hero Section**: Perfect balance for wide hero sections (opacity: 0.15, baseSize: 52, iconCount: 38)
-- **Dramatic Background**: Dense, dramatic effect for special sections (opacity: 0.2, baseSize: 58, iconCount: 55)
-
-## Icon Library (Heroicons)
-
-Our comprehensive icon system uses Heroicons with cloud native and tech-focused selections:
-
-### Platform Icons
-
-- **Cloud Infrastructure**: CloudIcon - scalable, distributed infrastructure
-- **Server & Compute**: ServerIcon - compute resources and workload execution
-- **Container & Packaging**: CubeIcon - application packaging and isolation
-- **Queue & Lists**: QueueListIcon - task queues and ordered processing
-
-### Data & Storage Icons
-
-- **Database & Storage**: CircleStackIcon - data storage and database systems
-- **Command Line & CLI**: CommandLineIcon - developer tools and terminal operations
-- **Configuration & Settings**: CogIcon - system configuration management
-- **Tools & Utilities**: WrenchScrewdriverIcon - development and operational tools
-
-### Operations Icons
-
-- **Security & Protection**: ShieldCheckIcon - security measures and compliance
-- **Monitoring & Analytics**: ChartBarIcon - observability dashboards and metrics
-- **Performance & Speed**: BoltIcon - high-performance computing and deployment
-- **Observability & Insights**: EyeIcon - system visibility and monitoring
-
-### Network & Connectivity Icons
-
-- **Global Distribution**: GlobeAltIcon - multi-region deployment strategies
-- **Service Mesh & Links**: LinkIcon - service interconnection patterns
-- **CI/CD & Automation**: ArrowPathIcon - continuous integration and deployment
-- **Deployment & Launch**: RocketLaunchIcon - application deployment processes
-
-### Icon Usage Guidelines
-
-- **Outline Style**: Clean, stroke-based icons for most UI elements and content sections
-- **Solid Style**: Filled icons for emphasis, status indicators, and important highlights
-- Available in multiple sizes: 4x4, 6x6, 8x8, 12x12 (Tailwind classes: `h-4 w-4` through `h-12 w-12`)
-- Color with brand palette: `text-brand-cloud-blue`, `text-brand-fresh-green`, etc.
-
-## Email Templates
-
-The brand system includes professional email templates for all conference communications:
-
-### Template Types
-
-1. **Proposal Response Templates**
- - Proposal Acceptance Email with speaker onboarding information
- - Proposal Rejection Email with constructive feedback and community engagement
-
-2. **Speaker Communication Templates**
- - Direct Speaker Email for individual outreach with rich content support
- - Speaker Broadcast Template for speaker-specific announcements
-
-3. **General Communication Templates**
- - Community Broadcast Email with customizable content and unsubscribe management
- - Base Email Template providing consistent structure and branding
-
-### Template Features
-
-- Consistent branding with logo and color scheme
-- Mobile-responsive design with email client compatibility
-- Accessible design with proper contrast ratios and alt text
-- Automated personalization with dynamic content
-- Unsubscribe management and compliance features
-- Rich HTML content support with fallback text versions
-
-## Button System
-
-Our button system provides consistent, accessible interactions with clear visual hierarchy:
-
-### Button Variants
-
-- **Primary**: `bg-brand-cloud-blue` - Main actions, CTAs
-- **Secondary**: `border border-brand-cloud-blue text-brand-cloud-blue` - Secondary actions
-- **Success**: `bg-brand-fresh-green` - Positive actions, confirmations
-- **Warning**: `bg-brand-sunbeam-yellow` - Caution, important notices
-- **Danger**: `bg-red-600` - Delete, destructive actions
-- **Ghost**: `text-brand-cloud-blue hover:bg-brand-sky-mist` - Subtle actions
-
-### Button Sizes
-
-- **Small**: `px-3 py-1.5 text-sm` - Compact spaces, secondary actions
-- **Medium**: `px-4 py-2 text-base` - Default size for most buttons
-- **Large**: `px-6 py-3 text-lg` - Hero sections, important CTAs
-
-### Button States
-
-- **Default**: Normal interactive state
-- **Hover**: Enhanced background/border colors
-- **Focus**: Visible focus ring for accessibility
-- **Disabled**: Reduced opacity, no interactions
-- **Loading**: Spinner indicator for async actions
-
-## Component Examples
-
-The branding page showcases real implementations of key components:
-
-### Hero Sections
-
-- Multiple hero variants with different layouts and messaging
-- Background pattern integration with customizable opacity
-- Typography combinations showcasing font pairings
-- Responsive design with mobile-optimized layouts
-
-### Speaker Components
-
-- **Featured Speaker**: Large format with detailed information and CTA
-- **Speaker Grid**: Multiple speakers in grid layouts (2x2, 3x3, etc.)
-- **Compact Speaker List**: Dense list format for directories
-- **Speaker Cards**: Individual speaker cards with consistent styling
-- **Social Sharing**: Download-ready speaker images for social media
-
-### Talk Components
-
-- **Talk Cards**: Format-specific styling (presentation, workshop, keynote)
-- **Talk Promotion**: Banner-style promotion with detailed information
-- **Schedule Integration**: Talk cards within schedule context
-
-### Call-to-Action Components
-
-- **Standard CTA**: Balanced speaker submission and ticket messaging
-- **Speaker Focus**: Emphasizes CFP submissions
-- **Ticket Focus**: Emphasizes ticket sales
-- **Custom Messaging**: Fully customizable for campaigns
-- **Organizer Context**: Community-focused messaging for organizers
-
-## Usage Guidelines
-
-### Accessibility Standards
-
-- All components maintain WCAG 2.1 AA compliance
-- Color contrast ratios meet accessibility requirements
-- Focus states are clearly visible and consistent
-- Alt text provided for all images and icons
-- Semantic HTML structure for screen readers
-
-### Responsive Design
-
-- Mobile-first approach with progressive enhancement
-- Breakpoints: `sm` (640px), `md` (768px), `lg` (1024px), `xl` (1280px)
-- Flexible grid systems that adapt to different screen sizes
-- Touch-friendly button sizes on mobile devices
-
-### Performance Considerations
-
-- Optimized images with appropriate formats (WebP with fallbacks)
-- Lazy loading for pattern backgrounds and images
-- Minimal animation impact on performance
-- Efficient CSS with Tailwind's purging system
-
-### Brand Consistency
-
-- Consistent spacing using Tailwind's spacing scale
-- Typography hierarchy maintained across all components
-- Color usage follows established palette guidelines
-- Icon usage follows outlined standards and sizing
-
-## Typography
-
-### Primary Fonts (Headings/Branding)
-
-- **JetBrains Mono**: `font-jetbrains`
- - Monospaced font for developers. Playful, readable, distinctly "dev culture."
- - Great for hero text, quotes, or session titles.
-- **Space Grotesk**: `font-space-grotesk` (also default `font-display`)
- - Clean, geometric sans-serif with a slightly quirky personality.
- - Offers great contrast and friendliness without losing professionalism.
-- **Bricolage Grotesque**: `font-bricolage`
- - Grotesque-style with some expressive, almost rebellious energy.
- - Matches the "nerdy and proud" community vibe.
-
-### Secondary Fonts (Body/UI Text)
-
-- **Inter**: `font-inter` (also default `font-sans`)
- - Versatile, neutral sans-serif with high legibility.
- - Pairs well with more expressive display fonts.
-- **IBM Plex Sans**: `font-ibm-plex-sans`
- - Great balance of precision and friendliness.
-- **IBM Plex Mono**: `font-ibm-plex-mono`
- - Monospaced variant for code snippets.
-- **Atkinson Hyperlegible**: `font-atkinson`
- - Designed for readability with unique, humanistic forms.
- - Strong accessibility and inclusive design signal.
-
-### Suggested Font Pairings
-
-1. **JetBrains Mono + Inter**: "Dev terminal meets clean UI"
-
- ```html
-
- Conference Title
-
- Body content here...
- ```
-
-2. **Space Grotesk + IBM Plex Sans**: "Playful headings with structured body"
-
- ```html
-
- Section Title
-
- Body content here...
- ```
-
-3. **Bricolage Grotesque + Atkinson Hyperlegible**: "Edgy but accessible"
-
- ```html
- Subsection
- Accessible body text...
- ```
-
-## Usage Examples
-
-### Hero Section
-
-```html
-
- Cloud Native Days Norway
-
- A community-driven conference for cloud native technologies
-
-
-```
-
-### Call to Action Button
-
-```html
-
- Submit Your Talk
-
-```
-
-### Card Component
-
-```html
-
-
- Speaker Name
-
-
Speaker bio and description...
-
- Keynote
-
-
-```
-
-### Alert/Notice
-
-```html
-
-
- Early Bird: Limited time offer - register now!
-
-
-```
diff --git a/docs/SPONSOR_SYSTEM.md b/docs/SPONSOR_SYSTEM.md
index 2224e9d3..132cc801 100644
--- a/docs/SPONSOR_SYSTEM.md
+++ b/docs/SPONSOR_SYSTEM.md
@@ -19,13 +19,14 @@ All sponsor data is stored in Sanity CMS across five document types:
The base company record. Conference-independent — a single sponsor can participate across multiple conferences/years.
-| Field | Description |
-| ------------ | --------------------------------------------------- |
-| `name` | Company name |
-| `website` | Company URL |
-| `logo` | Inline SVG logo |
-| `logoBright` | Optional bright/white variant for dark backgrounds |
-| `orgNumber` | Company registration number (admin-only visibility) |
+| Field | Description |
+| ------------ | ---------------------------------------------------------- |
+| `name` | Company name |
+| `website` | Company URL |
+| `logo` | Inline SVG logo |
+| `logoBright` | Optional bright/white variant for dark backgrounds |
+| `orgNumber` | Company registration number (admin-only visibility) |
+| `address` | Registered company address (admin-only, used in contracts) |
### `sponsorTier`
@@ -47,37 +48,67 @@ Defines pricing tiers for a conference. Each tier is scoped to a single conferen
The CRM join document linking a sponsor to a conference with relationship metadata. This is the central document the CRM pipeline operates on.
-| Field | Description |
-| ------------------ | ------------------------------------------------------------------------------------------------------ |
-| `sponsor` | Reference to `sponsor` document |
-| `conference` | Reference to `conference` document |
-| `tier` | Reference to a `sponsorTier` (standard/special) |
-| `addons[]` | Array of references to addon-type `sponsorTier` documents |
-| `contactPersons[]` | Per-conference contacts (name, email, phone, role, `isPrimary`) |
-| `billing` | Per-conference billing info (email, reference, comments) |
-| `status` | Pipeline stage: `prospect` → `contacted` → `negotiating` → `closed-won` / `closed-lost` |
-| `contractStatus` | `none` → `verbal-agreement` → `contract-sent` → `contract-signed` |
-| `invoiceStatus` | `not-sent` → `sent` → `paid` / `overdue` / `cancelled` |
-| `assignedTo` | Reference to an organizer (speaker with `isOrganizer: true`) |
-| `contractValue` | Actual contract value (defaults to tier price) |
-| `contractCurrency` | `NOK`, `USD`, `EUR`, or `GBP` |
-| `tags[]` | Classification tags (see Tags section below) |
-| `notes` | Freeform text |
-| Timestamps | `contactInitiatedAt`, `contractSignedAt`, `invoiceSentAt`, `invoicePaidAt` |
-
-Contact person roles are defined by `CONTACT_ROLE_OPTIONS` in `src/lib/sponsor/types.ts`. The `isPrimary` flag identifies the main contact for contract signing (Phase 2).
+| Field | Description |
+| ----------------------- | ------------------------------------------------------------------------------------------------------ |
+| `sponsor` | Reference to `sponsor` document |
+| `conference` | Reference to `conference` document |
+| `tier` | Reference to a `sponsorTier` (standard/special) |
+| `addons[]` | Array of references to addon-type `sponsorTier` documents |
+| `contactPersons[]` | Per-conference contacts (name, email, phone, role, `isPrimary`) |
+| `billing` | Per-conference billing info (email, reference, comments) |
+| `status` | Pipeline stage: `prospect` → `contacted` → `negotiating` → `closed-won` / `closed-lost` |
+| `contractStatus` | `none` → `verbal-agreement` → `contract-sent` → `contract-signed` |
+| `signatureStatus` | Digital signature state: `not-started` → `pending` → `signed` / `rejected` / `expired` |
+| `signatureId` | External ID from e-signing provider (read-only) |
+| `signerEmail` | Email of the person designated to sign the contract |
+| `contractSentAt` | When the contract was sent for signing (read-only) |
+| `contractDocument` | Generated PDF contract stored as a Sanity file asset |
+| `reminderCount` | Number of contract signing reminders sent (read-only) |
+| `contractTemplate` | Reference to the `contractTemplate` used to generate the contract |
+| `invoiceStatus` | `not-sent` → `sent` → `paid` / `overdue` / `cancelled` |
+| `assignedTo` | Reference to an organizer (speaker with `isOrganizer: true`) |
+| `contractValue` | Actual contract value (defaults to tier price) |
+| `contractCurrency` | `NOK`, `USD`, `EUR`, or `GBP` |
+| `tags[]` | Classification tags (see Tags section below) |
+| `notes` | Freeform text |
+| `onboardingToken` | Unique token for sponsor self-service onboarding portal (read-only) |
+| `onboardingComplete` | Whether the sponsor has completed onboarding (read-only) |
+| `onboardingCompletedAt` | When the sponsor completed onboarding (read-only) |
+| Timestamps | `contactInitiatedAt`, `contractSignedAt`, `invoiceSentAt`, `invoicePaidAt` |
+
+Contact person roles are defined by `CONTACT_ROLE_OPTIONS` in `src/lib/sponsor/types.ts`. The `isPrimary` flag identifies the main contact for contract signing.
+
+### `contractTemplate`
+
+Defines the structure and content for contract PDF generation. Each template is scoped to a conference and optionally to a specific tier. Supports variable substitution via `{{{VARIABLE}}}` placeholders.
+
+| Field | Description |
+| ------------ | --------------------------------------------------------------------- |
+| `title` | Internal name for identification |
+| `conference` | Reference to the owning conference |
+| `tier` | Optional reference to a `sponsorTier` for tier-specific contracts |
+| `language` | `nb` (Norwegian) or `en` (English) |
+| `currency` | Default currency for this template |
+| `sections[]` | Ordered array of `{ heading, body }` — body is PortableText with vars |
+| `headerText` | Text shown in PDF header (e.g. organization name) |
+| `footerText` | Text shown in PDF footer (e.g. org number, contact info) |
+| `terms` | General terms & conditions (PortableText, included as Appendix 1) |
+| `isDefault` | Fallback template when no tier-specific template exists |
+| `isActive` | Whether this template is available for use |
+
+**Contract template variables:** `SPONSOR_NAME`, `SPONSOR_ORG_NUMBER`, `SPONSOR_ADDRESS`, `SPONSOR_WEBSITE`, `CONTACT_NAME`, `CONTACT_EMAIL`, `TIER_NAME`, `TIER_TAGLINE`, `CONTRACT_VALUE`, `CONTRACT_VALUE_NUMBER`, `CONTRACT_CURRENCY`, `CONFERENCE_TITLE`, `CONFERENCE_DATE`, `CONFERENCE_DATES`, `CONFERENCE_YEAR`, `CONFERENCE_CITY`, `VENUE_NAME`, `VENUE_ADDRESS`, `TODAY_DATE`, `ORG_NAME`, `ORG_ORG_NUMBER`, `ORG_ADDRESS`, `ORG_EMAIL`, `ADDONS_LIST`.
### `sponsorActivity`
Audit log for CRM actions. Each activity references a `sponsorForConference` document.
-| Field | Description |
-| -------------- | ------------------------------------------------------------------------------------------------------------------------ |
-| `activityType` | `stage_change`, `invoice_status_change`, `contract_status_change`, `contract_signed`, `note`, `email`, `call`, `meeting` |
-| `description` | Human-readable summary |
-| `metadata` | Structured data with `oldValue`, `newValue`, `timestamp`, `additionalData` |
-| `createdBy` | Reference to the organizer who performed the action |
-| `createdAt` | ISO timestamp |
+| Field | Description |
+| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `activityType` | `stage_change`, `invoice_status_change`, `contract_status_change`, `contract_signed`, `note`, `email`, `call`, `meeting`, `signature_status_change`, `onboarding_complete`, `contract_reminder_sent` |
+| `description` | Human-readable summary |
+| `metadata` | Structured data with `oldValue`, `newValue`, `timestamp`, `additionalData` |
+| `createdBy` | Reference to the organizer who performed the action |
+| `createdAt` | ISO timestamp |
## Status Enumerations
@@ -91,6 +122,10 @@ All CRM status values are defined as TypeScript union types in `src/lib/sponsor-
`none` → `verbal-agreement` → `contract-sent` → `contract-signed`
+### Signature Status (`SignatureStatus`)
+
+`not-started` → `pending` → `signed` / `rejected` / `expired`
+
### Invoice Status (`InvoiceStatus`)
`not-sent` → `sent` → `paid` / `overdue` / `cancelled`
@@ -135,17 +170,25 @@ src/
│ ├── activity.ts # Activity logging helpers
│ ├── activities.ts # Activity list/query operations
│ ├── action-items.ts # Action item management
-│ └── bulk.ts # Bulk update/delete operations
+│ ├── bulk.ts # Bulk update/delete operations
+│ ├── contract-templates.ts # Contract template CRUD and lookup
+│ ├── contract-variables.ts # Variable substitution for contract generation
+│ ├── contract-pdf.tsx # PDF generation using React-PDF
+│ ├── contract-readiness.ts # Contract signing readiness validation
+│ ├── onboarding.ts # Sponsor self-service onboarding (token, validation, completion)
+│ └── pipeline.ts # Pipeline aggregation utilities
├── server/
│ ├── routers/sponsor.ts # tRPC router (all sponsor procedures)
│ └── schemas/
│ ├── sponsor.ts # Zod schemas for core sponsor operations
-│ └── sponsorForConference.ts # Zod schemas for CRM operations
+│ ├── sponsorForConference.ts # Zod schemas for CRM operations
+│ └── onboarding.ts # Zod schemas for onboarding submissions
├── components/
│ ├── Sponsors.tsx # Public sponsor display (grouped by tier)
│ ├── SponsorLogo.tsx # Public inline SVG logo renderer
│ ├── SponsorThankYou.tsx # Marketing thank-you card for sponsors
│ ├── sponsor/
+│ │ ├── SponsorOnboardingForm.tsx # Sponsor self-service onboarding form
│ │ └── SponsorProspectus.tsx # Public sponsorship prospectus page
│ └── admin/
│ ├── sponsor/ # Sponsor management admin UI
@@ -173,7 +216,10 @@ src/
│ ├── SponsorCard.tsx # Kanban card
│ ├── SponsorBulkActions.tsx # Multi-select action bar
│ ├── BoardViewSwitcher.tsx # Pipeline/Contract/Invoice toggle
+│ ├── ContractReadinessIndicator.tsx # Contract readiness status display
+│ ├── OnboardingLinkButton.tsx # Onboarding link generation button
│ ├── ImportHistoricSponsorsButton.tsx # Historic import dialog
+│ ├── MobileFilterSheet.tsx # Mobile-responsive filter panel
│ ├── utils.ts # CRM-specific UI utilities
│ └── form/ # Form sub-components
│ ├── constants.ts # Status/tag constants with labels & icons
@@ -190,12 +236,16 @@ src/
│ ├── useSponsorCRMFormMutations.ts # CRM form mutation hooks
│ └── useSponsorDragDrop.ts # Drag-and-drop for board columns
└── app/
- ├── (main)/sponsor/page.tsx # Public /sponsor prospectus page
+ ├── (main)/sponsor/
+ │ ├── page.tsx # Public /sponsor prospectus page
+ │ ├── terms/page.tsx # Public sponsor terms page
+ │ └── onboarding/[token]/page.tsx # Sponsor self-service onboarding
└── (admin)/admin/
├── sponsors/
│ ├── page.tsx # Sponsor management page
│ ├── crm/page.tsx # CRM pipeline page
│ ├── tiers/page.tsx # Tier management page
+ │ ├── contracts/page.tsx # Contract template management page
│ ├── templates/page.tsx # Email template management page
│ └── activity/page.tsx # Activity log page
└── marketing/page.tsx # Marketing page (includes SponsorThankYou)
@@ -205,20 +255,22 @@ sanity/schemaTypes/
├── sponsorTier.ts # Tier document schema
├── sponsorForConference.ts # CRM join document schema
├── sponsorActivity.ts # Activity log schema
-└── sponsorEmailTemplate.ts # Email template schema
+├── sponsorEmailTemplate.ts # Email template schema
+└── contractTemplate.ts # Contract template schema
```
### API Layer
All sponsor operations go through a single tRPC router at `src/server/routers/sponsor.ts`, organized into namespaces. See `docs/TRPC_SERVER_ARCHITECTURE.md` for general tRPC patterns.
-| Namespace | Procedures | Purpose |
-| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- |
-| `sponsor.*` | `list`, `getById`, `create`, `update`, `delete` | Core sponsor company CRUD |
-| `sponsor.tiers.*` | `list`, `listByConference`, `getById`, `create`, `update`, `delete` | Tier management |
-| `sponsor.crm.*` | `listOrganizers`, `list`, `getById`, `create`, `update`, `moveStage`, `updateInvoiceStatus`, `updateContractStatus`, `bulkUpdate`, `bulkDelete`, `delete`, `copyFromPreviousYear`, `importAllHistoric` | CRM pipeline operations |
-| `sponsor.crm.activities.*` | `list` | Activity log queries |
-| `sponsor.emailTemplates.*` | `list`, `create`, `update`, `delete` | Email template CRUD |
+| Namespace | Procedures | Purpose |
+| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------- |
+| `sponsor.*` | `list`, `getById`, `create`, `update`, `delete` | Core sponsor company CRUD |
+| `sponsor.tiers.*` | `list`, `listByConference`, `getById`, `create`, `update`, `delete` | Tier management |
+| `sponsor.crm.*` | `listOrganizers`, `list`, `getById`, `create`, `update`, `moveStage`, `updateInvoiceStatus`, `updateContractStatus`, `bulkUpdate`, `bulkDelete`, `delete`, `copyFromPreviousYear`, `importAllHistoric` | CRM pipeline operations |
+| `sponsor.crm.activities.*` | `list` | Activity log queries |
+| `sponsor.emailTemplates.*` | `list`, `create`, `update`, `delete` | Email template CRUD |
+| `sponsor.contractTemplates.*` | `list`, `get`, `create`, `update`, `delete`, `findBest`, `contractReadiness`, `generatePdf` | Contract template CRUD and PDF gen |
All procedures are protected by `adminProcedure` (requires `isOrganizer: true`).
@@ -250,6 +302,53 @@ Sponsor contact management integrates with the email system (see `docs/EMAIL_SYS
Sponsor tier assignments feed into the ticket allocation system, where each tier level maps to a specific number of complimentary tickets (configured in the tickets admin page).
+## Contract System
+
+The contract system enables organizers to generate, manage, and (eventually) digitally sign sponsorship agreements directly from the CRM.
+
+### Contract Templates
+
+Contract templates are stored in Sanity as `contractTemplate` documents. Each template belongs to a conference and contains ordered sections with PortableText bodies that support `{{{VARIABLE}}}` substitution. Templates can be scoped to a specific tier or marked as a default fallback.
+
+The `findBestContractTemplate()` function selects the most appropriate template by matching on conference, tier, and language — falling back to the default template if no tier-specific one exists.
+
+### Contract PDF Generation
+
+PDF generation uses React-PDF (`@react-pdf/renderer`) to produce professional contract documents. The generated PDF includes:
+
+- Header with organizer name and logo
+- Info table with parties, dates, and venue details
+- Contract sections with variable substitution
+- Package/tier details table
+- Appendix 1: General Terms & Conditions
+- Footer with organizer contact information
+
+Variable values are built from the `SponsorForConferenceExpanded` record by `buildContractVariables()` in `contract-variables.ts`.
+
+### Contract Readiness
+
+Before a contract can be generated or sent, all required data must be present. The `checkContractReadiness()` function in `contract-readiness.ts` validates 11 required fields and categorizes any missing ones by responsible party:
+
+| Source | Required Fields |
+| ------------- | ---------------------------------------------------------------------- |
+| **Organizer** | Conference name, org number, address, dates, venue name, sponsor email |
+| **Sponsor** | Org number, address, primary contact person |
+| **Pipeline** | Tier assignment, contract value |
+
+The `ContractReadinessIndicator` component displays readiness status in the CRM form — green when ready, amber with a categorized list of missing fields when not.
+
+## Sponsor Self-Service Onboarding
+
+The onboarding portal (`/sponsor/onboarding/[token]`) allows sponsors to self-service their data entry after an organizer initiates the relationship. The flow:
+
+1. Organizer generates a unique onboarding token via the CRM
+2. Sponsor receives a link (e.g. via email) to `/sponsor/onboarding/{token}`
+3. Sponsor fills in: company information (org number, address), contact persons, and billing details
+4. On submission, the system patches both the `sponsor` document (org data) and the `sponsorForConference` document (contacts, billing)
+5. An `onboarding_complete` activity is logged
+
+Token validation checks existence, expiry, and whether onboarding was already completed.
+
## Public-Facing Components
The public website displays sponsors using data fetched from Sanity (not through tRPC):
@@ -263,16 +362,19 @@ The public website displays sponsors using data fetched from Sanity (not through
Tests are located in `__tests__/` mirroring the source structure:
-| Test file | Covers |
-| ------------------------------------------ | -------------------------------------------- |
-| `lib/sponsor/validation.test.ts` | Sponsor and tier input validation |
-| `lib/sponsor/utils.test.ts` | Tier sorting, formatting, grouping utilities |
-| `lib/sponsor/templates.test.ts` | Template variable processing utilities |
-| `lib/sponsor/sponsorForConference.test.ts` | CRM Zod schema validation |
-| `lib/sponsor-crm/bulk.test.ts` | Bulk update/delete operations |
-| `components/Sponsors.test.tsx` | Public sponsor display component |
-| `components/SponsorLogo.test.tsx` | Logo rendering |
-| `components/SponsorProspectus.test.tsx` | Prospectus page |
+| Test file | Covers |
+| -------------------------------------------- | -------------------------------------------- |
+| `lib/sponsor/validation.test.ts` | Sponsor and tier input validation |
+| `lib/sponsor/utils.test.ts` | Tier sorting, formatting, grouping utilities |
+| `lib/sponsor/templates.test.ts` | Template variable processing utilities |
+| `lib/sponsor/sponsorForConference.test.ts` | CRM Zod schema validation |
+| `lib/sponsor-crm/bulk.test.ts` | Bulk update/delete operations |
+| `lib/sponsor-crm/contract-readiness.test.ts` | Contract readiness validation logic |
+| `lib/sponsor-crm/contract-variables.test.ts` | Contract variable building and substitution |
+| `lib/sponsor-crm/onboarding.test.ts` | Onboarding URL building |
+| `components/Sponsors.test.tsx` | Public sponsor display component |
+| `components/SponsorLogo.test.tsx` | Logo rendering |
+| `components/SponsorProspectus.test.tsx` | Prospectus page |
## Roadmap
@@ -304,13 +406,13 @@ End-to-end sponsor contract workflow with digital signatures, automated reminder
| Issue | Summary | Status |
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | ------ |
-| [#300](https://github.com/CloudNativeBergen/website/issues/300) | Schema extensions (`signature_status`, `signer_email`, `contract_document`, `isPrimary`) | Open |
+| [#300](https://github.com/CloudNativeBergen/website/issues/300) | Schema extensions (`signature_status`, `signer_email`, `contract_document`, `isPrimary`) | Done |
| [#301](https://github.com/CloudNativeBergen/website/issues/301) | Contract template system with tier-based PDF generation | Done |
| [#302](https://github.com/CloudNativeBergen/website/issues/302) | Sponsor email templates (ContractSent, ContractReminder, WelcomeOnboarding) | Open |
| [#303](https://github.com/CloudNativeBergen/website/issues/303) | Posten.no e-signature integration (OAuth2, BankID, webhooks) | Open |
| [#304](https://github.com/CloudNativeBergen/website/issues/304) | Admin UI — send contract flow with signature status badges | Open |
| [#305](https://github.com/CloudNativeBergen/website/issues/305) | Automated contract reminders (daily cron, Slack notifications) | Open |
-| [#306](https://github.com/CloudNativeBergen/website/issues/306) | Sponsor self-service onboarding portal (`/sponsor/onboarding/[token]`) | Open |
+| [#306](https://github.com/CloudNativeBergen/website/issues/306) | Sponsor self-service onboarding portal (`/sponsor/onboarding/[token]`) | Done |
### Related Issues
@@ -320,7 +422,7 @@ End-to-end sponsor contract workflow with digital signatures, automated reminder
## Key Design Decisions
-**Separated sponsor vs. CRM types.** A sponsor company (`sponsor`) is a conference-independent entity holding only company-level data (name, logo, website). Contact persons and billing details live on `sponsorForConference` — the per-conference relationship record — since contacts and billing arrangements often differ between conferences/years.
+**Separated sponsor vs. CRM types.** A sponsor company (`sponsor`) is a conference-independent entity holding only company-level data (name, logo, website, org number, address). Contact persons and billing details live on `sponsorForConference` — the per-conference relationship record — since contacts and billing arrangements often differ between conferences/years.
**`sponsorForConference` as the single source of truth.** All conference-sponsor relationships are managed exclusively through `sponsorForConference` documents. There is no inline `sponsors[]` array on conference documents. Public-facing pages query `sponsorForConference` docs with `status == "closed-won"` and project them into the `ConferenceSponsor` shape. The `Conference.sponsors` TypeScript property is populated at runtime from this query for backward compatibility with downstream consumers.
diff --git a/docs/STORYBOOK_STRUCTURE.md b/docs/STORYBOOK_STRUCTURE.md
new file mode 100644
index 00000000..dd033de7
--- /dev/null
+++ b/docs/STORYBOOK_STRUCTURE.md
@@ -0,0 +1,250 @@
+# Storybook Structure & Best Practices
+
+This document outlines our approach to organizing and writing Storybook stories for the Cloud Native Days Norway component library.
+
+## Story Organization Principles
+
+### 1. One Interactive/Playground Story
+
+Every component should have a primary **Interactive** story with full controls enabled. This allows users to:
+
+- Experiment with different prop combinations
+- Quickly test component behavior
+- Use as a live playground
+
+**Example:**
+
+```typescript
+export const Interactive: Story = {
+ args: {
+ variant: 'primary',
+ size: 'md',
+ children: 'Button Text',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Interactive playground - use controls to experiment with different variants, sizes, and states.',
+ },
+ },
+ },
+}
+```
+
+### 2. Visual Comparison Stories
+
+For components with multiple variants or states, create a **Visual Variants** or **AllVariants** story that displays them side-by-side. This enables:
+
+- Quick visual inspection without clicking through stories
+- Easy design review and QA
+- Visual regression testing
+- Documentation of all available options
+
+**Example:**
+
+```typescript
+export const AllVariants: Story = {
+ render: () => (
+
+ Primary
+ Secondary
+ Success
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'Comprehensive visual overview for design review and visual regression testing.',
+ },
+ },
+ },
+}
+```
+
+### 3. Specific State Stories
+
+Keep dedicated stories for:
+
+- **Important variants** with significant visual differences (for visual regression testing)
+- **Edge cases** that test specific scenarios (long text, empty states, error states)
+- **Common use cases** that stakeholders frequently reference
+
+**When to create separate stories:**
+
+- âś… Different status states (Prospect, Contacted, Negotiating, etc.) - each has unique styling
+- âś… Edge cases (NoLogo, LongSponsorName, EmptyState)
+- âś… Common configurations (WithIcon, Disabled, Loading)
+
+**When to use controls instead:**
+
+- ❌ Simple visual variants (colors) - use interactive story
+- ❌ Size variations (sm, md, lg) - use interactive story or AllVariants
+- ❌ Boolean toggles - use interactive story
+
+## Hierarchy Structure
+
+Our Storybook uses a multi-level hierarchy. Refer to the `AGENTS.md` Storybook & Design System section for the canonical, up-to-date structure. The top-level categories are:
+
+```text
+Getting Started/ - Introduction and developer guides
+Design System/ - Brand, Foundation, Examples
+Components/{Category}/ - Generic reusable components (Data Display, Feedback, Forms, Icons, Layout)
+Systems/{SystemName}/ - Domain-specific components (Program, Proposals, Speakers, Sponsors)
+```
+
+### Placing New Stories
+
+- **Generic reusable components** → `Components/{Category}/ComponentName` (e.g., `Components/Layout/Button`)
+- **Domain-specific components** → `Systems/{SystemName}/ComponentName` (e.g., `Systems/Program/TalkCard`)
+- **Admin components for a system** → `Systems/{SystemName}/Admin/ComponentName` (e.g., `Systems/Sponsors/Admin/Pipeline/SponsorCard`)
+- **Integration examples** → `Design System/Examples/ExampleName`
+
+## ArgTypes Configuration
+
+Always configure `argTypes` to improve the Controls panel experience:
+
+```typescript
+argTypes: {
+ variant: {
+ control: 'select',
+ options: ['primary', 'secondary', 'success'],
+ description: 'Visual style variant following brand color system',
+ },
+ size: {
+ control: 'select',
+ options: ['sm', 'md', 'lg'],
+ description: 'Component size',
+ },
+ disabled: {
+ control: 'boolean',
+ description: 'Disable interaction',
+ },
+ children: {
+ control: 'text',
+ description: 'Content to display',
+ },
+}
+```
+
+## Story Descriptions
+
+Add descriptions at two levels:
+
+### 1. Component-level Description
+
+In the meta object's `parameters.docs.description.component`:
+
+```typescript
+parameters: {
+ docs: {
+ description: {
+ component: 'Consistent, accessible button system...',
+ },
+ },
+}
+```
+
+### 2. Story-level Description
+
+In individual stories' `parameters.docs.description.story`:
+
+```typescript
+parameters: {
+ docs: {
+ description: {
+ story: 'Tests layout with very long sponsor name...',
+ },
+ },
+}
+```
+
+## Visual Regression Testing
+
+Stories serve as visual regression test cases. When using tools like Chromatic or Percy:
+
+- Each story becomes a test snapshot
+- Individual variant stories help identify exactly what changed
+- Visual comparison stories help catch layout issues
+
+**Best Practice:** Keep individual stories for states that need precise visual testing (status changes, error states, etc.)
+
+## Common Patterns
+
+### Interactive Components (State Management)
+
+```typescript
+export const Interactive: Story = {
+ render: () => {
+ const [value, setValue] = useState('initial')
+ return
+ },
+}
+```
+
+### Async Server Components (Next.js)
+
+```typescript
+function ComponentWrapper(props: Parameters[0]) {
+ const [rendered, setRendered] = useState(null)
+
+ useEffect(() => {
+ Component(props).then(setRendered)
+ }, [props])
+
+ if (!rendered) return Loading...
+ return rendered
+}
+```
+
+### Grid Layouts for Comparison
+
+```typescript
+render: () => (
+
+
+
Variant 1
+
+
+
+
Variant 2
+
+
+
+)
+```
+
+## Checklist for New Components
+
+When creating Storybook stories for a new component:
+
+- [ ] Create `ComponentName.stories.tsx` next to the component file
+- [ ] Add component to appropriate hierarchy (`Components/{Category}/` or `Systems/{SystemName}/`)
+- [ ] Add `tags: ['autodocs']` to meta for automatic documentation
+- [ ] Configure `argTypes` for all important props
+- [ ] Add component-level description
+- [ ] Create **Interactive** story with controls
+- [ ] Create **VisualVariants/AllVariants** story if component has multiple variants
+- [ ] Add stories for important edge cases
+- [ ] Add story-level descriptions explaining each use case
+- [ ] Test with `pnpm storybook:test` to ensure no rendering errors
+- [ ] Document any special setup requirements (decorators, mocks, etc.)
+
+## Testing Stories
+
+Run the test runner to verify all stories render without errors:
+
+```bash
+# Run all story tests
+pnpm storybook:test
+
+# Run specific story file
+pnpm storybook:test --grep "ButtonStories"
+```
+
+## References
+
+- [Storybook Best Practices](https://storybook.js.org/docs/writing-stories/stories-for-multiple-frameworks)
+- [Component Story Format (CSF)](https://storybook.js.org/docs/api/csf)
+- [Controls Documentation](https://storybook.js.org/docs/essentials/controls)
+- [Visual Testing Guide](https://storybook.js.org/docs/writing-tests/visual-testing)
diff --git a/eslint.config.js b/eslint.config.js
index 50be1e09..cc053712 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -2,6 +2,7 @@ const nextPlugin = require('@next/eslint-plugin-next')
const tseslint = require('typescript-eslint')
const reactHooksPlugin = require('eslint-plugin-react-hooks')
const importPlugin = require('eslint-plugin-import')
+const storybookPlugin = require('eslint-plugin-storybook')
const eslintConfig = [
// Global ignores - these apply to all configurations
@@ -28,12 +29,16 @@ const eslintConfig = [
'__tests__/**/*.test.{js,ts,tsx}',
'__tests__/**/testdata/**',
'__tests__/**/mocks/**',
+ 'storybook-static/**', // Built Storybook output
],
},
// TypeScript configuration
...tseslint.configs.recommended,
+ // Storybook configuration
+ ...storybookPlugin.configs['flat/recommended'],
+
// Main configuration for TypeScript and JavaScript files
{
plugins: {
diff --git a/knip.json b/knip.json
index 6961b7e7..1041c55b 100644
--- a/knip.json
+++ b/knip.json
@@ -13,11 +13,15 @@
"sanity.config.ts",
"sanity/actions/**/*.js",
"scripts/**/*.ts",
- "migrations/**/index.ts"
+ "migrations/**/index.ts",
+ "src/**/*.stories.{ts,tsx}",
+ "src/docs/**/*.{ts,tsx}",
+ ".storybook/**/*.{ts,tsx}"
],
"project": [
"src/**/*.{ts,tsx,js,jsx}",
"sanity/**/*.{ts,tsx,js,jsx}",
+ ".storybook/**/*.{ts,tsx,js,jsx}",
"!src/**/*.test.{ts,tsx}",
"!**/__tests__/**"
],
@@ -32,13 +36,16 @@
"migrations/**",
"**/middleware.ts",
"src/lib/empty-module.ts",
- "scripts/strip-comments.ts"
+ "scripts/strip-comments.ts",
+ "storybook-static/**",
+ "src/components/email/CoSpeakerResponseTemplate.tsx"
],
"ignoreDependencies": [
"eslint-config-next",
"postcss",
"jest-environment-jsdom",
- "ts-node"
+ "ts-node",
+ "chromatic"
],
"ignoreExportsUsedInFile": true,
"includeEntryExports": true
diff --git a/migrations/033-add-signature-onboarding-fields/index.ts b/migrations/033-add-signature-onboarding-fields/index.ts
new file mode 100644
index 00000000..65f3bb45
--- /dev/null
+++ b/migrations/033-add-signature-onboarding-fields/index.ts
@@ -0,0 +1,21 @@
+import { defineMigration, at, setIfMissing } from 'sanity/migrate'
+
+export default defineMigration({
+ title: 'Add signature and onboarding fields to sponsorForConference',
+ description:
+ 'Backfills default values for new signatureStatus, reminderCount, and onboardingComplete fields ' +
+ 'on all sponsorForConference documents to support contract signing and sponsor onboarding features.',
+ documentTypes: ['sponsorForConference'],
+
+ migrate: {
+ document(_doc) {
+ const operations = []
+
+ operations.push(at('signatureStatus', setIfMissing('not-started')))
+ operations.push(at('reminderCount', setIfMissing(0)))
+ operations.push(at('onboardingComplete', setIfMissing(false)))
+
+ return operations
+ },
+ },
+})
diff --git a/package.json b/package.json
index 5d366587..36c275f9 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,10 @@
"format:check": "prettier --check .",
"format": "prettier --write --list-different .",
"typecheck": "tsc --noEmit",
+ "storybook": "storybook dev -p 6006",
+ "build-storybook": "storybook build",
+ "storybook:test": "test-storybook --maxWorkers=2",
+ "storybook:test-ci": "concurrently -k -s first -n \"SB,TEST\" -c \"magenta,blue\" \"pnpm build-storybook --quiet && npx http-server storybook-static --port 6006 --silent\" \"wait-on tcp:127.0.0.1:6006 && pnpm storybook:test\"",
"sanity": "sanity",
"manage-orphaned-files": "tsx scripts/manage-orphaned-files.ts"
},
@@ -38,6 +42,7 @@
"@portabletext/to-html": "^5.0.1",
"@portabletext/types": "^4.0.1",
"@react-email/render": "^2.0.4",
+ "@react-pdf/renderer": "^4.3.2",
"@sanity/image-url": "^2.0.3",
"@sanity/types": "^5.8.1",
"@tailwindcss/forms": "^0.5.11",
@@ -86,6 +91,9 @@
"@sanity/migrate": "^5.2.3",
"@sanity/vision": "^5.8.1",
"@starefossen/sanity-plugin-inline-svg-input": "^1.3.7",
+ "@storybook/addon-docs": "^10.2.8",
+ "@storybook/nextjs-vite": "^10.2.8",
+ "@storybook/test-runner": "^0.24.2",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
@@ -97,18 +105,33 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"autoprefixer": "^10.4.24",
+ "chromatic": "^12.0.0",
+ "concurrently": "^9.1.2",
"eslint": "^10.0.0",
"eslint-config-next": "^16.1.6",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-storybook": "^10.2.8",
+ "glob": "^13.0.2",
+ "http-server": "^14.1.1",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"knip": "^5.83.1",
+ "msw": "^2.12.10",
+ "msw-storybook-addon": "^2.0.6",
+ "playwright": "^1.58.2",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
+ "storybook": "^10.2.8",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
- "typescript-eslint": "^8.54.0"
+ "typescript-eslint": "^8.54.0",
+ "wait-on": "^8.0.3"
+ },
+ "msw": {
+ "workerDirectory": [
+ "public"
+ ]
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 13655e21..fbb1a640 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
'@react-email/render':
specifier: ^2.0.4
version: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@react-pdf/renderer':
+ specifier: ^4.3.2
+ version: 4.3.2(react@19.2.4)
'@sanity/image-url':
specifier: ^2.0.3
version: 2.0.3
@@ -186,6 +189,15 @@ importers:
'@starefossen/sanity-plugin-inline-svg-input':
specifier: ^1.3.7
version: 1.3.7(@noble/hashes@2.0.1)(@sanity/ui@3.1.11(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(styled-components@6.3.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sanity@5.8.1(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.0.1)(@swc/helpers@0.5.18)(@types/node@25.2.2)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(babel-plugin-react-compiler@1.0.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styled-components@6.3.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(styled-components@6.3.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
+ '@storybook/addon-docs':
+ specifier: ^10.2.8
+ version: 10.2.8(@types/react@19.2.13)(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@storybook/nextjs-vite':
+ specifier: ^10.2.8
+ version: 10.2.8(@babel/core@7.29.0)(esbuild@0.27.3)(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@storybook/test-runner':
+ specifier: ^0.24.2
+ version: 0.24.2(@swc/helpers@0.5.18)(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3))
'@tailwindcss/postcss':
specifier: ^4.1.18
version: 4.1.18
@@ -219,6 +231,12 @@ importers:
autoprefixer:
specifier: ^10.4.24
version: 10.4.24(postcss@8.5.6)
+ chromatic:
+ specifier: ^12.0.0
+ version: 12.2.0
+ concurrently:
+ specifier: ^9.1.2
+ version: 9.2.1
eslint:
specifier: ^10.0.0
version: 10.0.0(jiti@2.6.1)
@@ -231,6 +249,15 @@ importers:
eslint-plugin-react-hooks:
specifier: ^7.0.1
version: 7.0.1(eslint@10.0.0(jiti@2.6.1))
+ eslint-plugin-storybook:
+ specifier: ^10.2.8
+ version: 10.2.8(eslint@10.0.0(jiti@2.6.1))(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
+ glob:
+ specifier: ^13.0.2
+ version: 13.0.2
+ http-server:
+ specifier: ^14.1.1
+ version: 14.1.1
jest:
specifier: ^30.2.0
version: 30.2.0(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3))
@@ -240,12 +267,24 @@ importers:
knip:
specifier: ^5.83.1
version: 5.83.1(@types/node@25.2.2)(typescript@5.9.3)
+ msw:
+ specifier: ^2.12.10
+ version: 2.12.10(@types/node@25.2.2)(typescript@5.9.3)
+ msw-storybook-addon:
+ specifier: ^2.0.6
+ version: 2.0.6(msw@2.12.10(@types/node@25.2.2)(typescript@5.9.3))
+ playwright:
+ specifier: ^1.58.2
+ version: 1.58.2
prettier:
specifier: ^3.8.1
version: 3.8.1
prettier-plugin-tailwindcss:
specifier: ^0.7.2
version: 0.7.2(prettier@3.8.1)
+ storybook:
+ specifier: ^10.2.8
+ version: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
ts-jest:
specifier: ^29.4.6
version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.27.3)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3)))(typescript@5.9.3)
@@ -258,6 +297,9 @@ importers:
typescript-eslint:
specifier: ^8.54.0
version: 8.54.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)
+ wait-on:
+ specifier: ^8.0.3
+ version: 8.0.5
packages:
@@ -1516,6 +1558,32 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
+ '@hapi/address@5.1.1':
+ resolution: {integrity: sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==}
+ engines: {node: '>=14.0.0'}
+
+ '@hapi/formula@3.0.2':
+ resolution: {integrity: sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==}
+
+ '@hapi/hoek@11.0.7':
+ resolution: {integrity: sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==}
+
+ '@hapi/hoek@9.3.0':
+ resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==}
+
+ '@hapi/pinpoint@2.0.1':
+ resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==}
+
+ '@hapi/tlds@1.1.5':
+ resolution: {integrity: sha512-Vq/1gnIIsvFUpKlDdfrPd/ssHDpAyBP/baVukh3u2KSG2xoNjsnRNjQiPmuyPPGqsn1cqVWWhtZHfOBaLizFRQ==}
+ engines: {node: '>=14.0.0'}
+
+ '@hapi/topo@5.1.0':
+ resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
+
+ '@hapi/topo@6.0.2':
+ resolution: {integrity: sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==}
+
'@headlessui/react@2.2.9':
resolution: {integrity: sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==}
engines: {node: '>=10'}
@@ -1703,6 +1771,10 @@ packages:
cpu: [x64]
os: [win32]
+ '@inquirer/ansi@1.0.2':
+ resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
+ engines: {node: '>=18'}
+
'@inquirer/ansi@2.0.3':
resolution: {integrity: sha512-g44zhR3NIKVs0zUesa4iMzExmZpLUdTLRMCStqX3GE5NT6VkPcxQGJ+uC8tDgBUC/vB1rUhUd55cOf++4NZcmw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@@ -1716,6 +1788,15 @@ packages:
'@types/node':
optional: true
+ '@inquirer/confirm@5.1.21':
+ resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
'@inquirer/confirm@6.0.4':
resolution: {integrity: sha512-WdaPe7foUnoGYvXzH4jp4wH/3l+dBhZ3uwhKjXjwdrq5tEIFaANxj6zrGHxLdsIA0yKM0kFPVcEalOZXBB5ISA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@@ -1725,6 +1806,15 @@ packages:
'@types/node':
optional: true
+ '@inquirer/core@10.3.2':
+ resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
'@inquirer/core@11.1.1':
resolution: {integrity: sha512-hV9o15UxX46OyQAtaoMqAOxGR8RVl1aZtDx1jHbCtSJy1tBdTfKxLPKf7utsE4cRy4tcmCQ4+vdV+ca+oNxqNA==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@@ -1761,6 +1851,10 @@ packages:
'@types/node':
optional: true
+ '@inquirer/figures@1.0.15':
+ resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==}
+ engines: {node: '>=18'}
+
'@inquirer/figures@2.0.3':
resolution: {integrity: sha512-y09iGt3JKoOCBQ3w4YrSJdokcD8ciSlMIWsD+auPu+OZpfxLuyz+gICAQ6GCBOmJJt4KEQGHuZSVff2jiNOy7g==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@@ -1828,6 +1922,15 @@ packages:
'@types/node':
optional: true
+ '@inquirer/type@3.0.10':
+ resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/node': '>=18'
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+
'@inquirer/type@4.0.3':
resolution: {integrity: sha512-cKZN7qcXOpj1h+1eTTcGDVLaBIHNMT1Rz9JqJP5MnEJ0JhgVWllx7H/tahUp5YEK1qaByH2Itb8wLG/iScD5kw==}
engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'}
@@ -1874,6 +1977,10 @@ packages:
node-notifier:
optional: true
+ '@jest/create-cache-key-function@30.2.0':
+ resolution: {integrity: sha512-44F4l4Enf+MirJN8X/NhdGkl71k5rBYiwdVlo4HxOwbu0sHV8QKrGEedb1VUU4K3W7fBKE0HGfbn7eZm0Ti3zg==}
+ engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+
'@jest/diff-sequences@30.0.1':
resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -1953,6 +2060,15 @@ packages:
resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4':
+ resolution: {integrity: sha512-6PyZBYKnnVNqOSB0YFly+62R7dmov8segT27A+RVTBVd4iAE6kbW9QBJGlyR2yG4D4ohzhZSTIu7BK1UTtmFFA==}
+ peerDependencies:
+ typescript: '>= 4.3.x'
+ vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -1990,10 +2106,20 @@ packages:
'@marijn/find-cluster-break@1.0.2':
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
+ '@mdx-js/react@3.1.1':
+ resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
+ peerDependencies:
+ '@types/react': '>=16'
+ react: '>=16'
+
'@mswjs/interceptors@0.39.8':
resolution: {integrity: sha512-2+BzZbjRO7Ct61k8fMNHEtoKjeWI9pIlHFTqBwZ5icHpqszIgEZbjb1MW5Z0+bITTCTl3gk4PDBxs9tA/csXvA==}
engines: {node: '>=18'}
+ '@mswjs/interceptors@0.41.2':
+ resolution: {integrity: sha512-7G0Uf0yK3f2bjElBLGHIQzgRgMESczOMyYVasq1XK8P5HaXtlW4eQhz9MBL+TQILZLaruq+ClGId+hH0w4jvWw==}
+ engines: {node: '>=18'}
+
'@mux/mux-data-google-ima@0.2.8':
resolution: {integrity: sha512-0ZEkHdcZ6bS8QtcjFcoJeZxJTpX7qRIledf4q1trMWPznugvtajCjCM2kieK/pzkZj1JM6liDRFs1PJSfVUs2A==}
@@ -2025,6 +2151,9 @@ packages:
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
+ '@next/env@16.0.0':
+ resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==}
+
'@next/env@16.1.6':
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
@@ -2430,6 +2559,49 @@ packages:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
+ '@react-pdf/fns@3.1.2':
+ resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==}
+
+ '@react-pdf/font@4.0.4':
+ resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==}
+
+ '@react-pdf/image@3.0.4':
+ resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==}
+
+ '@react-pdf/layout@4.4.2':
+ resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==}
+
+ '@react-pdf/pdfkit@4.1.0':
+ resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==}
+
+ '@react-pdf/png-js@3.0.0':
+ resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==}
+
+ '@react-pdf/primitives@4.1.1':
+ resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==}
+
+ '@react-pdf/reconciler@2.0.0':
+ resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@react-pdf/render@4.3.2':
+ resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==}
+
+ '@react-pdf/renderer@4.3.2':
+ resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@react-pdf/stylesheet@6.1.2':
+ resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==}
+
+ '@react-pdf/textkit@6.1.0':
+ resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==}
+
+ '@react-pdf/types@2.9.2':
+ resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==}
+
'@react-stately/flags@3.1.2':
resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==}
@@ -2457,6 +2629,15 @@ packages:
'@rolldown/pluginutils@1.0.0-rc.2':
resolution: {integrity: sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==}
+ '@rollup/pluginutils@5.3.0':
+ resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
'@rollup/rollup-android-arm-eabi@4.57.1':
resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==}
cpu: [arm]
@@ -2939,6 +3120,15 @@ packages:
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
+ '@sideway/address@4.1.5':
+ resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
+
+ '@sideway/formula@3.0.1':
+ resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==}
+
+ '@sideway/pinpoint@2.0.0':
+ resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==}
+
'@sinclair/typebox@0.34.48':
resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==}
@@ -2951,6 +3141,9 @@ packages:
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@starefossen/sanity-plugin-inline-svg-input@1.3.7':
resolution: {integrity: sha512-ByKszE7wrbjjvW3iHDmPLGjrTu7YnGtfkBn4osEGinDpaHifQMPut9EfGj408R3g46Kq/Um6DYH12u3ra2LciQ==}
engines: {node: '>=18'}
@@ -2961,6 +3154,90 @@ packages:
sanity: ^3 || ^4
styled-components: ^5 || ^6
+ '@storybook/addon-docs@10.2.8':
+ resolution: {integrity: sha512-cEoWqQrLzrxOwZFee5zrD4cYrdEWKV80POb7jUZO0r5vfl2DuslIr3n/+RfLT52runCV4aZcFEfOfP/IWHNPxg==}
+ peerDependencies:
+ storybook: ^10.2.8
+
+ '@storybook/builder-vite@10.2.8':
+ resolution: {integrity: sha512-+6/Lwi7W0YIbzHDh798GPp0IHUYDwp0yv0Y1eVNK/StZD0tnv4/1C28NKyP+O7JOsFsuWI1qHiDhw8kNURugZw==}
+ peerDependencies:
+ storybook: ^10.2.8
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ '@storybook/csf-plugin@10.2.8':
+ resolution: {integrity: sha512-kKkLYhRXb33YtIPdavD2DU25sb14sqPYdcQFpyqu4TaD9truPPqW8P5PLTUgERydt/eRvRlnhauPHavU1kjsnA==}
+ peerDependencies:
+ esbuild: '*'
+ rollup: '*'
+ storybook: ^10.2.8
+ vite: '*'
+ webpack: '*'
+ peerDependenciesMeta:
+ esbuild:
+ optional: true
+ rollup:
+ optional: true
+ vite:
+ optional: true
+ webpack:
+ optional: true
+
+ '@storybook/global@5.0.0':
+ resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==}
+
+ '@storybook/icons@2.0.1':
+ resolution: {integrity: sha512-/smVjw88yK3CKsiuR71vNgWQ9+NuY2L+e8X7IMrFjexjm6ZR8ULrV2DRkTA61aV6ryefslzHEGDInGpnNeIocg==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@storybook/nextjs-vite@10.2.8':
+ resolution: {integrity: sha512-Zc4s5IYh/pfCV8amym3ghF5ITIw5P/eVKFVXruR4ZLKRH3sXuG4O98ityehKnHhp8S5cnV5InGIeacGhXTV++w==}
+ peerDependencies:
+ next: ^14.1.0 || ^15.0.0 || ^16.0.0
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.2.8
+ typescript: '*'
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@storybook/react-dom-shim@10.2.8':
+ resolution: {integrity: sha512-Xde9X3VszFV1pTXfc2ZFM89XOCGRxJD8MUIzDwkcT9xaki5a+8srs/fsXj75fMY6gMYfcL5lNRZvCqg37HOmcQ==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.2.8
+
+ '@storybook/react-vite@10.2.8':
+ resolution: {integrity: sha512-x5kmw+TPhxkQV84n4e9X0q6/rA5T8V2QQFolMuN+U93q1HX1r+GZ6g/nXaaq9ox168PhHUJZQnn+LzSQKGCMBA==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.2.8
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ '@storybook/react@10.2.8':
+ resolution: {integrity: sha512-nMFqQFUXq6Zg2O5SeuomyWnrIx61QfpNQMrfor8eCEzHrWNnXrrvVsz2RnHIgXN8RVyaWGDPh1srAECu/kDHXw==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ storybook: ^10.2.8
+ typescript: '>= 4.9.x'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ '@storybook/test-runner@0.24.2':
+ resolution: {integrity: sha512-76DbflDTGAKq8Af6uHbWTGnKzKHhjLbJaZXRFhVnKqFocoXcej58C9DpM0BJ3addu7fSDJmPwfR97OINg16XFQ==}
+ engines: {node: '>=20.0.0'}
+ hasBin: true
+ peerDependencies:
+ storybook: ^0.0.0-0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0
+
'@svgdotjs/svg.draggable.js@3.0.6':
resolution: {integrity: sha512-7iJFm9lL3C40HQcqzEfezK2l+dW2CpoVY3b77KQGqc8GXWa6LhhmX5Ckv7alQfUXBuZbjpICZ+Dvq1czlGx7gA==}
peerDependencies:
@@ -3068,6 +3345,12 @@ packages:
'@swc/helpers@0.5.18':
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
+ '@swc/jest@0.2.39':
+ resolution: {integrity: sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA==}
+ engines: {npm: '>= 7.0.0'}
+ peerDependencies:
+ '@swc/core': '*'
+
'@swc/types@0.1.25':
resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==}
@@ -3228,6 +3511,12 @@ packages:
'@types/react-dom':
optional: true
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@trpc/client@11.9.0':
resolution: {integrity: sha512-3r4RT/GbR263QO+2gCPyrs5fEYaXua3/AzCs+GbWC09X0F+mVkyBpO3GRSDObiNU/N1YB597U7WGW3WA1d1TVw==}
peerDependencies:
@@ -3285,6 +3574,9 @@ packages:
'@types/body-parser@1.19.6':
resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@@ -3297,6 +3589,12 @@ packages:
'@types/cookies@0.9.2':
resolution: {integrity: sha512-1AvkDdZM2dbyFybL4fxpuNCaWyv//0AwsuUk2DWeXyM1/5ZKm6W3z6mQi24RZ4l2ucY+bkSHzbDVpySqPGuV8A==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
+ '@types/doctrine@0.0.9':
+ resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==}
+
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -3357,6 +3655,9 @@ packages:
'@types/koa@2.15.0':
resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==}
+ '@types/mdx@2.0.13':
+ resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==}
+
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
@@ -3392,6 +3693,9 @@ packages:
'@types/react@19.2.13':
resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==}
+ '@types/resolve@1.20.6':
+ resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
+
'@types/send@0.17.6':
resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==}
@@ -3410,6 +3714,9 @@ packages:
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
+ '@types/statuses@2.0.6':
+ resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
+
'@types/stylis@4.2.7':
resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==}
@@ -3434,6 +3741,9 @@ packages:
'@types/uuid@8.3.4':
resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==}
+ '@types/wait-on@5.3.4':
+ resolution: {integrity: sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw==}
+
'@types/which@3.0.4':
resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==}
@@ -3690,6 +4000,18 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ '@vitest/expect@3.2.4':
+ resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
+
+ '@vitest/pretty-format@3.2.4':
+ resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
+
+ '@vitest/spy@3.2.4':
+ resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
+
+ '@vitest/utils@3.2.4':
+ resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
+
'@workos-inc/authkit-nextjs@2.13.0':
resolution: {integrity: sha512-ppxzhfakPumHPPggYSROaAlgxfS7viFMPmWPG76Tp6Rh9G7YqkBSp7xtvMtM6gXOFFMvvEJRcKEta6YHeercTQ==}
peerDependencies:
@@ -3720,6 +4042,9 @@ packages:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
+ abs-svg-path@0.1.1:
+ resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -3746,6 +4071,10 @@ packages:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
+ aggregate-error@3.1.0:
+ resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
+ engines: {node: '>=8'}
+
ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies:
@@ -3767,6 +4096,10 @@ packages:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
+ ansi-escapes@7.3.0:
+ resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==}
+ engines: {node: '>=18'}
+
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -3809,6 +4142,10 @@ packages:
apexcharts@5.3.6:
resolution: {integrity: sha512-sVEPw+J0Gp0IHQabKu8cfdsxlfME0e36Wid7RIaPclGM2OUt+O7O4+6mfAmTUYhy5bDk8cNHzEhPfVtLCIXEJA==}
+ append-transform@2.0.0:
+ resolution: {integrity: sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==}
+ engines: {node: '>=8'}
+
archiver-utils@5.0.2:
resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==}
engines: {node: '>= 14'}
@@ -3817,6 +4154,9 @@ packages:
resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==}
engines: {node: '>= 14'}
+ archy@1.0.0:
+ resolution: {integrity: sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==}
+
arg@4.1.3:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
@@ -3880,9 +4220,17 @@ packages:
resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==}
engines: {node: '>=12.0.0'}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
+ ast-types@0.16.1:
+ resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
+ engines: {node: '>=4'}
+
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -3924,6 +4272,9 @@ packages:
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
engines: {node: '>=4'}
+ axios@1.13.5:
+ resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==}
+
axobject-query@4.1.0:
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
engines: {node: '>= 0.4'}
@@ -3997,6 +4348,10 @@ packages:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
+ base64-js@0.0.8:
+ resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
+ engines: {node: '>= 0.4'}
+
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@@ -4004,6 +4359,10 @@ packages:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
+ basic-auth@2.0.1:
+ resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
+ engines: {node: '>= 0.8'}
+
basic-ftp@5.1.0:
resolution: {integrity: sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==}
engines: {node: '>=10.0.0'}
@@ -4038,9 +4397,15 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
+ brotli@1.3.3:
+ resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
+
browserify-zlib@0.1.4:
resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==}
+ browserify-zlib@0.2.0:
+ resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
+
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -4069,10 +4434,18 @@ packages:
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
+ bundle-name@4.1.0:
+ resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
+ engines: {node: '>=18'}
+
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
+ caching-transform@4.0.0:
+ resolution: {integrity: sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==}
+ engines: {node: '>=8'}
+
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -4123,6 +4496,10 @@ packages:
peerDependencies:
react: '>=17.0.0'
+ chai@5.3.3:
+ resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
+ engines: {node: '>=18'}
+
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
@@ -4151,6 +4528,10 @@ packages:
chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
+ check-error@2.1.3:
+ resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
+ engines: {node: '>= 16'}
+
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -4158,6 +4539,18 @@ packages:
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
+ chromatic@12.2.0:
+ resolution: {integrity: sha512-GswmBW9ZptAoTns1BMyjbm55Z7EsIJnUvYKdQqXIBZIKbGErmpA+p4c0BYA+nzw5B0M+rb3Iqp1IaH8TFwIQew==}
+ hasBin: true
+ peerDependencies:
+ '@chromatic-com/cypress': ^0.*.* || ^1.0.0
+ '@chromatic-com/playwright': ^0.*.* || ^1.0.0
+ peerDependenciesMeta:
+ '@chromatic-com/cypress':
+ optional: true
+ '@chromatic-com/playwright':
+ optional: true
+
ci-info@4.4.0:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
@@ -4168,6 +4561,10 @@ packages:
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
+ clean-stack@2.2.0:
+ resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==}
+ engines: {node: '>=6'}
+
clean-stack@3.0.1:
resolution: {integrity: sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==}
engines: {node: '>=10'}
@@ -4206,6 +4603,10 @@ packages:
resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==}
engines: {node: '>=6'}
+ clone@2.1.2:
+ resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
+ engines: {node: '>=0.8'}
+
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -4233,6 +4634,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ color-string@1.9.1:
+ resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
+
color2k@2.0.3:
resolution: {integrity: sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==}
@@ -4243,6 +4647,13 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+ commander@12.1.0:
+ resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
+ engines: {node: '>=18'}
+
+ commander@3.0.2:
+ resolution: {integrity: sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==}
+
commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
@@ -4256,6 +4667,11 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+ concurrently@9.2.1:
+ resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==}
+ engines: {node: '>=18'}
+ hasBin: true
+
config-chain@1.1.13:
resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==}
@@ -4270,6 +4686,9 @@ packages:
console-table-printer@2.15.0:
resolution: {integrity: sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==}
+ convert-source-map@1.9.0:
+ resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==}
+
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -4281,12 +4700,20 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
+ cookie@1.1.1:
+ resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
+ engines: {node: '>=18'}
+
core-js-compat@3.48.0:
resolution: {integrity: sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
+ corser@2.0.1:
+ resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==}
+ engines: {node: '>= 0.4.0'}
+
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
@@ -4306,6 +4733,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ crypto-js@4.2.0:
+ resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+
crypto-random-string@2.0.0:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'}
@@ -4348,6 +4778,10 @@ packages:
custom-media-element@1.4.5:
resolution: {integrity: sha512-cjrsQufETwxjvwZbYbKBCJNvmQ2++G9AvT45zDi7NXL9k2PdVcs2h0jQz96J6G4TMKRCcEsoJ+QTgQD00Igtjw==}
+ cwd@0.10.0:
+ resolution: {integrity: sha512-YGZxdTTL9lmLkCUTpg4j0zQ7IhRB5ZmqNBbGCl3Tg6MP/d5/6sY7L5mmTjzbc6JKgVZYiqTQTNhPFsbXNGlRaA==}
+ engines: {node: '>=0.8'}
+
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -4431,6 +4865,10 @@ packages:
resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==}
engines: {node: '>= 16'}
+ deep-eql@5.0.2:
+ resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
+ engines: {node: '>=6'}
+
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
@@ -4442,6 +4880,18 @@ packages:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
+ default-browser-id@5.0.1:
+ resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
+ engines: {node: '>=18'}
+
+ default-browser@5.5.0:
+ resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
+ engines: {node: '>=18'}
+
+ default-require-extensions@3.0.1:
+ resolution: {integrity: sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==}
+ engines: {node: '>=8'}
+
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@@ -4450,6 +4900,10 @@ packages:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
+ define-lazy-prop@3.0.0:
+ resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
+ engines: {node: '>=12'}
+
define-properties@1.2.1:
resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==}
engines: {node: '>= 0.4'}
@@ -4473,10 +4927,16 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ dfa@1.2.0:
+ resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
+
diff@4.0.4:
resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==}
engines: {node: '>=0.3.1'}
+ diffable-html@4.1.0:
+ resolution: {integrity: sha512-++kyNek+YBLH8cLXS+iTj/Hiy2s5qkRJEJ8kgu/WHbFrVY2vz9xPFUT+fii2zGF0m1CaojDlQJjkfrCt7YWM1g==}
+
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
@@ -4496,18 +4956,31 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ doctrine@3.0.0:
+ resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==}
+ engines: {node: '>=6.0.0'}
+
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+ dom-serializer@0.2.2:
+ resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
+
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+ domelementtype@1.3.1:
+ resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==}
+
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+ domhandler@2.4.2:
+ resolution: {integrity: sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==}
+
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
@@ -4515,6 +4988,9 @@ packages:
dompurify@3.3.1:
resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==}
+ domutils@1.7.0:
+ resolution: {integrity: sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==}
+
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
@@ -4552,6 +5028,9 @@ packages:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
engines: {node: '>=12'}
+ emoji-regex-xs@1.0.0:
+ resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
+
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -4561,6 +5040,10 @@ packages:
emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
+ empathic@2.0.0:
+ resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
+ engines: {node: '>=14'}
+
encoding-japanese@2.2.0:
resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==}
engines: {node: '>=8.10.0'}
@@ -4572,6 +5055,12 @@ packages:
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
engines: {node: '>=10.13.0'}
+ entities@1.1.2:
+ resolution: {integrity: sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==}
+
+ entities@2.2.0:
+ resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
+
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -4580,6 +5069,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
+ environment@1.1.0:
+ resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
+ engines: {node: '>=18'}
+
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -4618,6 +5111,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
+ es6-error@4.1.1:
+ resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
+
esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies:
@@ -4723,6 +5219,12 @@ packages:
peerDependencies:
eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7
+ eslint-plugin-storybook@10.2.8:
+ resolution: {integrity: sha512-BtysXrg1RoYT3DIrCc+svZ0+L3mbWsu7suxTLGrihBY5HfWHkJge+qjlBBR1Nm2ZMslfuFS5K0NUWbWCJRu6kg==}
+ peerDependencies:
+ eslint: '>=8'
+ storybook: ^10.2.8
+
eslint-scope@9.1.0:
resolution: {integrity: sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ==}
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
@@ -4774,6 +5276,9 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
+ estree-walker@2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@@ -4785,6 +5290,9 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
+ eventemitter3@4.0.7:
+ resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
+
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
@@ -4822,6 +5330,18 @@ packages:
resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==}
engines: {node: '>= 0.8.0'}
+ exit@0.1.2:
+ resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==}
+ engines: {node: '>= 0.8.0'}
+
+ expand-tilde@1.2.2:
+ resolution: {integrity: sha512-rtmc+cjLZqnu9dSYosX9EWmSJhTwpACgJQTfj4hgg2JjOD/6SIQalZrt4a3aQeh++oNxkazcaxrhPUj6+g5G/Q==}
+ engines: {node: '>=0.10.0'}
+
+ expect-playwright@0.8.0:
+ resolution: {integrity: sha512-+kn8561vHAY+dt+0gMqqj1oY+g5xWrsuGMk4QGxotT2WS545nVqqjs37z6hrYfIuucwqthzwJfCJUEYqixyljg==}
+ deprecated: ⚠️ The 'expect-playwright' package is deprecated. The Playwright core assertions (via @playwright/test) now cover the same functionality. Please migrate to built-in expect. See https://playwright.dev/docs/test-assertions for migration.
+
expect@30.2.0:
resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -4892,6 +5412,22 @@ packages:
resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==}
engines: {node: '>=6'}
+ find-cache-dir@3.3.2:
+ resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==}
+ engines: {node: '>=8'}
+
+ find-file-up@0.1.3:
+ resolution: {integrity: sha512-mBxmNbVyjg1LQIIpgO8hN+ybWBgDQK8qjht+EbrTCGmmPV/sc7RF1i9stPTD6bpvXZywBdrwRYxhSdJv867L6A==}
+ engines: {node: '>=0.10.0'}
+
+ find-pkg@0.1.2:
+ resolution: {integrity: sha512-0rnQWcFwZr7eO0513HahrWafsc3CTFioEB7DRiEYCUM/70QXSY8f3mCST17HXLcPvEhzH/Ty/Bxd72ZZsr/yvw==}
+ engines: {node: '>=0.10.0'}
+
+ find-process@1.4.11:
+ resolution: {integrity: sha512-mAOh9gGk9WZ4ip5UjV0o6Vb4SrfnAmtsFNzkMRH9HQiFXVQnDyQFrSHTK5UoG6E+KV+s+cIznbtwpfN41l2nFA==}
+ hasBin: true
+
find-up-simple@1.0.1:
resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==}
engines: {node: '>=18'}
@@ -4935,10 +5471,17 @@ packages:
debug:
optional: true
+ fontkit@2.0.4:
+ resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
+
for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'}
+ foreground-child@2.0.0:
+ resolution: {integrity: sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==}
+ engines: {node: '>=8.0.0'}
+
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -4969,12 +5512,24 @@ packages:
react-dom:
optional: true
+ fromentries@1.3.2:
+ resolution: {integrity: sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==}
+
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
+ fs-exists-sync@0.1.0:
+ resolution: {integrity: sha512-cR/vflFyPZtrN6b38ZyWxpWdhlXrzZEBawlpBQMq7033xVY7/kg0GDMBK5jg8lDYQckdJ5x/YC88lM3C7VMsLg==}
+ engines: {node: '>=0.10.0'}
+
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -5063,10 +5618,22 @@ packages:
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
+ glob@13.0.2:
+ resolution: {integrity: sha512-035InabNu/c1lW0tzPhAgapKctblppqsKKG9ZaNzbr+gXwWMjXoiyGSyB9sArzrjG7jY+zntRq5ZSUYemrnWVQ==}
+ engines: {node: 20 || >=22}
+
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+ global-modules@0.2.3:
+ resolution: {integrity: sha512-JeXuCbvYzYXcwE6acL9V2bAOeSIGl4dD+iwLY9iUx2VBJJ80R18HCn+JCwHM9Oegdfya3lEkGCdaRkSyc10hDA==}
+ engines: {node: '>=0.10.0'}
+
+ global-prefix@0.1.5:
+ resolution: {integrity: sha512-gOPiyxcD9dJGCEArAhF4Hd0BAqvAe/JzERP7tYumE4yIkmIedPUVXcJFWbV3/p/ovIIvKjkrTk+f1UVkq7vvbw==}
+ engines: {node: '>=0.10.0'}
+
globals@16.4.0:
resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==}
engines: {node: '>=18'}
@@ -5092,6 +5659,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+ graphql@16.12.0:
+ resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==}
+ engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+
groq-js@1.26.0:
resolution: {integrity: sha512-1WxWfmeownBbB2UhvFGyLT3yl/NFGF2qUoev+650Or2qDnoyXjvL83lwspUFuG4piWdDRh2iETljXKoDxacH+w==}
engines: {node: '>= 14'}
@@ -5140,6 +5711,10 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
+ hasha@5.2.2:
+ resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==}
+ engines: {node: '>=8'}
+
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -5154,6 +5729,9 @@ packages:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
+ headers-polyfill@4.0.3:
+ resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==}
+
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@@ -5169,12 +5747,26 @@ packages:
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
+ homedir-polyfill@1.0.3:
+ resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
+ engines: {node: '>=0.10.0'}
+
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
hotscript@1.0.13:
resolution: {integrity: sha512-C++tTF1GqkGYecL+2S1wJTfoH6APGAsbb7PAWQ3iVIwgG/EFseAfEVOKFgAFq4yK3+6j1EjUD4UQ9dRJHX/sSQ==}
+ hsl-to-hex@1.0.0:
+ resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
+
+ hsl-to-rgb-for-reals@1.1.1:
+ resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
+
+ html-encoding-sniffer@3.0.0:
+ resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
+ engines: {node: '>=12'}
+
html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
@@ -5197,6 +5789,9 @@ packages:
resolution: {integrity: sha512-5mRhTXZhv4B0kIcsn3bFBjol2o8vzP35mhtxdXBGPA3V3gZd6Sa2PIIFbT//DiqAX8UuywlcJit5jRKej4nV4Q==}
engines: {node: '>=16.0.0'}
+ htmlparser2@3.10.1:
+ resolution: {integrity: sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==}
+
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
@@ -5204,9 +5799,18 @@ packages:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
- https-proxy-agent@7.0.6:
- resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
- engines: {node: '>= 14'}
+ http-proxy@1.18.1:
+ resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
+ engines: {node: '>=8.0.0'}
+
+ http-server@14.1.1:
+ resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==}
+ engines: {node: '>=12'}
+ hasBin: true
+
+ https-proxy-agent@7.0.6:
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+ engines: {node: '>= 14'}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
@@ -5215,6 +5819,9 @@ packages:
humanize-list@1.0.1:
resolution: {integrity: sha512-4+p3fCRF21oUqxhK0yZ6yaSP/H5/wZumc7q1fH99RkW7Q13aAxDeP78BKjoR+6y+kaHqKF/JWuQhsNuuI2NKtA==}
+ hyphen@1.14.1:
+ resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
+
i18next@23.16.8:
resolution: {integrity: sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==}
@@ -5241,6 +5848,11 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
+ image-size@2.0.2:
+ resolution: {integrity: sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==}
+ engines: {node: '>=16.x'}
+ hasBin: true
+
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -5312,6 +5924,9 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
+ is-arrayish@0.3.4:
+ resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
+
is-async-function@2.1.1:
resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
engines: {node: '>= 0.4'}
@@ -5362,6 +5977,11 @@ packages:
engines: {node: '>=8'}
hasBin: true
+ is-docker@3.0.0:
+ resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
+ engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ hasBin: true
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -5399,6 +6019,11 @@ packages:
is-hotkey@0.2.0:
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
+ is-inside-container@1.0.0:
+ resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
+ engines: {node: '>=14.16'}
+ hasBin: true
+
is-interactive@2.0.0:
resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==}
engines: {node: '>=12'}
@@ -5480,6 +6105,9 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
+ is-url@1.2.4:
+ resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
+
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -5492,10 +6120,22 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
+ is-windows@0.2.0:
+ resolution: {integrity: sha512-n67eJYmXbniZB7RF4I/FTjK1s6RPOCTxhYrVYLRaCt3lF0mpWZPKr3T2LSZAqyjQsxR2qMmGYXXzK0YWwcPM1Q==}
+ engines: {node: '>=0.10.0'}
+
+ is-windows@1.0.2:
+ resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
+ engines: {node: '>=0.10.0'}
+
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
+ is-wsl@3.1.0:
+ resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
+ engines: {node: '>=16'}
+
isarray@1.0.0:
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
@@ -5525,14 +6165,30 @@ packages:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
+ istanbul-lib-hook@3.0.0:
+ resolution: {integrity: sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-instrument@4.0.3:
+ resolution: {integrity: sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==}
+ engines: {node: '>=8'}
+
istanbul-lib-instrument@6.0.3:
resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==}
engines: {node: '>=10'}
+ istanbul-lib-processinfo@2.0.3:
+ resolution: {integrity: sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==}
+ engines: {node: '>=8'}
+
istanbul-lib-report@3.0.1:
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
engines: {node: '>=10'}
+ istanbul-lib-source-maps@4.0.1:
+ resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==}
+ engines: {node: '>=10'}
+
istanbul-lib-source-maps@5.0.6:
resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
engines: {node: '>=10'}
@@ -5553,6 +6209,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ jay-peg@1.1.1:
+ resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
+
jest-changed-files@30.2.0:
resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -5615,6 +6274,10 @@ packages:
resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ jest-junit@16.0.0:
+ resolution: {integrity: sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==}
+ engines: {node: '>=10.12.0'}
+
jest-leak-detector@30.2.0:
resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -5640,6 +6303,10 @@ packages:
jest-resolve:
optional: true
+ jest-process-manager@0.4.0:
+ resolution: {integrity: sha512-80Y6snDyb0p8GG83pDxGI/kQzwVTkCxc7ep5FPe/F6JYdvRDhwr6RzRmPSP7SEwuLhxo80lBS/NqOdUIbHIfhw==}
+ deprecated: ⚠️ The 'jest-process-manager' package is deprecated. Please migrate to Playwright's built-in test runner (@playwright/test) which now includes full Jest-style features and parallel testing. See https://playwright.dev/docs/intro for details.
+
jest-regex-util@30.0.1:
resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -5660,6 +6327,9 @@ packages:
resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ jest-serializer-html@7.1.0:
+ resolution: {integrity: sha512-xYL2qC7kmoYHJo8MYqJkzrl/Fdlx+fat4U1AqYg+kafqwcKPiMkOcjWHPKhueuNEgr+uemhGc+jqXYiwCyRyLA==}
+
jest-snapshot@30.2.0:
resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -5672,6 +6342,12 @@ packages:
resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
+ jest-watch-typeahead@3.0.1:
+ resolution: {integrity: sha512-SFmHcvdueTswZlVhPCWfLXMazvwZlA2UZTrcE7MC3NwEVeWvEcOx6HUe+igMbnmA6qowuBSW4in8iC6J2EYsgQ==}
+ engines: {node: '>=18.0.0'}
+ peerDependencies:
+ jest: ^30.0.0
+
jest-watcher@30.2.0:
resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -5694,6 +6370,13 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ joi@17.13.3:
+ resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
+
+ joi@18.0.2:
+ resolution: {integrity: sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==}
+ engines: {node: '>= 20'}
+
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
@@ -5817,6 +6500,10 @@ packages:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
+ kleur@3.0.3:
+ resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
+ engines: {node: '>=6'}
+
knip@5.83.1:
resolution: {integrity: sha512-av3ZG/Nui6S/BNL8Tmj12yGxYfTnwWnslouW97m40him7o8MwiMjZBY9TPvlEWUci45aVId0/HbgTwSKIDGpMw==}
engines: {node: '>=18.18.0'}
@@ -5945,6 +6632,9 @@ packages:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
+ linebreak@1.1.0:
+ resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
+
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -5977,6 +6667,9 @@ packages:
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
+ lodash.flattendeep@4.4.0:
+ resolution: {integrity: sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==}
+
lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
@@ -5991,10 +6684,17 @@ packages:
resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==}
engines: {node: '>=18'}
+ loglevel@1.9.2:
+ resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==}
+ engines: {node: '>= 0.6.0'}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
+ loupe@3.2.1:
+ resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
+
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -6063,6 +6763,9 @@ packages:
media-chrome@4.17.2:
resolution: {integrity: sha512-o/IgiHx0tdSVwRxxqF5H12FK31A/A8T71sv3KdAvh7b6XeBS9dXwqvIFwlR9kdEuqg3n7xpmRIuL83rmYq8FTg==}
+ media-engine@1.0.3:
+ resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
+
media-tracks@0.3.4:
resolution: {integrity: sha512-5SUElzGMYXA7bcyZBL1YzLTxH9Iyw1AeYNJxzByqbestrrtB0F3wfiWUr7aROpwodO4fwnxOt78Xjb3o3ONNQg==}
@@ -6097,6 +6800,11 @@ packages:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
+ mime@1.6.0:
+ resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
+ engines: {node: '>=4'}
+ hasBin: true
+
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -6142,6 +6850,14 @@ packages:
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
+ mkdirp@1.0.4:
+ resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ module-alias@2.3.4:
+ resolution: {integrity: sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==}
+
motion-dom@12.33.0:
resolution: {integrity: sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==}
@@ -6165,6 +6881,25 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ msw-storybook-addon@2.0.6:
+ resolution: {integrity: sha512-ExCwDbcJoM2V3iQU+fZNp+axVfNc7DWMRh4lyTXebDO8IbpUNYKGFUrA8UqaeWiRGKVuS7+fU+KXEa9b0OP6uA==}
+ peerDependencies:
+ msw: ^2.0.0
+
+ msw@2.12.10:
+ resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==}
+ engines: {node: '>=18'}
+ hasBin: true
+ peerDependencies:
+ typescript: '>= 4.8.x'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
+ mute-stream@2.0.0:
+ resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
+ engines: {node: ^18.17.0 || >=20.5.0}
+
mute-stream@3.0.0:
resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -6264,6 +6999,10 @@ packages:
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
+ node-preload@0.2.1:
+ resolution: {integrity: sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==}
+ engines: {node: '>=8'}
+
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
@@ -6278,6 +7017,9 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
+ normalize-svg-path@1.1.0:
+ resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
+
npm-run-path@3.1.0:
resolution: {integrity: sha512-Dbl4A/VfiVGLgQv29URL9xshU8XDY1GeLy+fsaZ1AA8JDSfjvr5P5+pzRbWqRSBxk6/DW7MIh8lTM/PaGnP2kg==}
engines: {node: '>=8'}
@@ -6292,6 +7034,11 @@ packages:
nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
+ nyc@15.1.0:
+ resolution: {integrity: sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==}
+ engines: {node: '>=8.9'}
+ hasBin: true
+
oauth4webapi@3.8.4:
resolution: {integrity: sha512-EKlVEgav8zH31IXxvhCqjEgQws6S9QmnmJyLXmeV5REf59g7VmqRVa5l/rhGWtUqGm2rLVTNwukn9hla5kJ2WQ==}
@@ -6348,10 +7095,18 @@ packages:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
+ open@10.2.0:
+ resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
+ engines: {node: '>=18'}
+
open@8.4.2:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
+ opener@1.5.2:
+ resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
+ hasBin: true
+
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -6360,6 +7115,10 @@ packages:
resolution: {integrity: sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==}
engines: {node: '>=20'}
+ os-homedir@1.0.2:
+ resolution: {integrity: sha512-B5JU3cabzk8c67mRRd3ECmROafjYMXbuzlwtqdM8IbS8ktlTix8aFGb2bAGKrSRIlnfKwovGUUr72JUPyOb6kQ==}
+ engines: {node: '>=0.10.0'}
+
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
@@ -6402,6 +7161,10 @@ packages:
resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
+ p-map@3.0.0:
+ resolution: {integrity: sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==}
+ engines: {node: '>=8'}
+
p-map@7.0.4:
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
engines: {node: '>=18'}
@@ -6418,12 +7181,19 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
+ package-hash@4.0.0:
+ resolution: {integrity: sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==}
+ engines: {node: '>=8'}
+
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
+ pako@1.0.11:
+ resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -6443,6 +7213,13 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
+ parse-passwd@1.0.0:
+ resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==}
+ engines: {node: '>=0.10.0'}
+
+ parse-svg-path@0.1.2:
+ resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
+
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@@ -6475,6 +7252,10 @@ packages:
resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
engines: {node: '>=16 || 14 >=14.18'}
+ path-scurry@2.0.1:
+ resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==}
+ engines: {node: 20 || >=22}
+
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
@@ -6485,6 +7266,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pathval@2.0.1:
+ resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
+ engines: {node: '>= 14.16'}
+
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
@@ -6531,6 +7316,16 @@ packages:
player.style@0.3.1:
resolution: {integrity: sha512-z/T8hJGaTkHT9vdXgWdOgF37eB1FV7/j52VXQZ2lgEhpru9oT8TaUWIxp6GoxTnhPBM4X6nSbpkAHrT7UTjUKg==}
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ engines: {node: '>=18'}
+ hasBin: true
+
pluralize-esm@9.0.5:
resolution: {integrity: sha512-Kb2dcpMsIutFw2hYrN0EhsAXOUJTd6FVMIxvNAkZCMQLVt9NGZqQczvGpYDxNWCZeCWLHUPxQIBudWzt1h7VVA==}
engines: {node: '>=14.0.0'}
@@ -6543,6 +7338,10 @@ packages:
resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==}
engines: {node: '>=10'}
+ portfinder@1.0.38:
+ resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==}
+ engines: {node: '>= 10.12'}
+
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -6657,10 +7456,18 @@ packages:
process-nextick-args@2.0.1:
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
+ process-on-spawn@1.1.0:
+ resolution: {integrity: sha512-JOnOPQ/8TZgjs1JIH/m9ni7FfimjNa/PRx7y/Wb5qdItsnhO0jE4AT7fC0HjC28DUQWDr50dwSYZLdRMlqDq3Q==}
+ engines: {node: '>=8'}
+
process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
+ prompts@2.4.2:
+ resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
+ engines: {node: '>= 6'}
+
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -6674,6 +7481,9 @@ packages:
proto-list@1.2.4:
resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==}
+ proxy-from-env@1.1.0:
+ resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
+
pump@2.0.1:
resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==}
@@ -6720,6 +7530,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ queue@6.0.2:
+ resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
+
quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
@@ -6755,6 +7568,15 @@ packages:
peerDependencies:
react: ^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental
+ react-docgen-typescript@2.4.0:
+ resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==}
+ peerDependencies:
+ typescript: '>= 4.3.x'
+
+ react-docgen@8.0.2:
+ resolution: {integrity: sha512-+NRMYs2DyTP4/tqWz371Oo50JqmWltR1h2gcdgUMAWZJIAvrd0/SqlCfx7tpzpl/s36rzw6qH2MjoNrxtRNYhA==}
+ engines: {node: ^20.9.0 || >=22}
+
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
@@ -6852,6 +7674,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
+ recast@0.23.11:
+ resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
+ engines: {node: '>= 4'}
+
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -6896,6 +7722,10 @@ packages:
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
hasBin: true
+ release-zalgo@1.0.0:
+ resolution: {integrity: sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==}
+ engines: {node: '>=4'}
+
remeda@2.33.5:
resolution: {integrity: sha512-FqmpPA9i9T5EGcqgyHf9kHjefnyCZM1M3kSdZjPk1j2StGNoJyoYp0807RYcjNkQ1UpsEQa5qzgsjLY4vYtT8g==}
@@ -6910,6 +7740,9 @@ packages:
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
+ requires-port@1.0.0:
+ resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
+
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
@@ -6926,6 +7759,10 @@ packages:
resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
engines: {node: '>=8'}
+ resolve-dir@0.1.1:
+ resolution: {integrity: sha512-QxMPqI6le2u0dCLyiGzgy92kjkkL6zO0XyvHzjdTNH3zM6e5Hz3BwG6+aEyNgiQ5Xz6PwTwgQEj3U50dByPKIA==}
+ engines: {node: '>=0.10.0'}
+
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -6950,14 +7787,25 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
+ restructure@3.0.2:
+ resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
+
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
+ rettime@0.10.1:
+ resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==}
+
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ rimraf@3.0.2:
+ resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
+ deprecated: Rimraf versions prior to v4 are no longer supported
+ hasBin: true
+
rimraf@5.0.10:
resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
hasBin: true
@@ -6970,6 +7818,10 @@ packages:
rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
+ run-applescript@7.1.0:
+ resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
+ engines: {node: '>=18'}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -7021,6 +7873,9 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
+ scheduler@0.25.0-rc-603e6108-20241029:
+ resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -7030,6 +7885,9 @@ packages:
scrollmirror@1.2.4:
resolution: {integrity: sha512-UkEHHOV6j5cE3IsObQRK6vO4twSuhE4vtLD4UmX+doHgrtg2jRwXkz4O6cz0jcoxK5NGU7rFjyvLcWHzw7eQ5A==}
+ secure-compare@3.0.1:
+ resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
+
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
@@ -7092,6 +7950,10 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
+ shell-quote@1.8.3:
+ resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
+ engines: {node: '>= 0.4'}
+
side-channel-list@1.0.0:
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
engines: {node: '>= 0.4'}
@@ -7115,13 +7977,23 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
+ simple-swizzle@0.2.4:
+ resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
+
simple-wcswidth@1.1.2:
resolution: {integrity: sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==}
+ sisteransi@1.0.5:
+ resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
+ slash@5.1.0:
+ resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
+ engines: {node: '>=14.16'}
+
slate-dom@0.119.0:
resolution: {integrity: sha512-foc8a2NkE+1SldDIYaoqjhVKupt8RSuvHI868rfYOcypD4we5TT7qunjRKJ852EIRh/Ql8sSTepXgXKOUJnt1w==}
peerDependencies:
@@ -7159,6 +8031,13 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+ spawn-wrap@2.0.0:
+ resolution: {integrity: sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==}
+ engines: {node: '>=8'}
+
+ spawnd@5.0.0:
+ resolution: {integrity: sha512-28+AJr82moMVWolQvlAIv3JcYDkjkFTEmfDc503wxrF5l2rQ3dFz6DpbXp3kD4zmgGGldfM4xM4v1sFj/ZaIOA==}
+
spdx-correct@3.2.0:
resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==}
@@ -7192,6 +8071,10 @@ packages:
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
+ statuses@2.0.2:
+ resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+ engines: {node: '>= 0.8'}
+
stdin-discarder@0.3.1:
resolution: {integrity: sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==}
engines: {node: '>=18'}
@@ -7200,6 +8083,15 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
+ storybook@10.2.8:
+ resolution: {integrity: sha512-885uSIn8NQw2ZG7vy84K45lHCOSyz1DVsDV8pHiHQj3J0riCuWLNeO50lK9z98zE8kjhgTtxAAkMTy5nkmNRKQ==}
+ hasBin: true
+ peerDependencies:
+ prettier: ^2 || ^3
+ peerDependenciesMeta:
+ prettier:
+ optional: true
+
stream-shift@1.0.3:
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
@@ -7213,6 +8105,10 @@ packages:
resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==}
engines: {node: '>=10'}
+ string-length@6.0.0:
+ resolution: {integrity: sha512-1U361pxZHEQ+FeSjzqRpV+cu2vTzYeWeafXFLykiFlv4Vc0n3njgU8HrMbyik5uwm77naWMuVG8fhEF+Ovb1Kg==}
+ engines: {node: '>=16'}
+
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -7282,6 +8178,10 @@ packages:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
+ strip-indent@4.1.1:
+ resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==}
+ engines: {node: '>=12'}
+
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
@@ -7345,6 +8245,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
+ svg-arc-to-cubic-bezier@3.2.0:
+ resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
+
svix@1.84.1:
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
@@ -7358,6 +8261,10 @@ packages:
tabbable@6.4.0:
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
+ tagged-tag@1.0.0:
+ resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
+ engines: {node: '>=20'}
+
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@@ -7398,13 +8305,27 @@ packages:
through2@4.0.2:
resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
+ tiny-inflate@1.0.3:
+ resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
+
tiny-invariant@1.3.1:
resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==}
+ tiny-invariant@1.3.3:
+ resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
+ tinyrainbow@2.0.0:
+ resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
+ engines: {node: '>=14.0.0'}
+
+ tinyspy@4.0.4:
+ resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
+ engines: {node: '>=14.0.0'}
+
tlds@1.261.0:
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
hasBin: true
@@ -7446,6 +8367,10 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
+ tree-kill@1.2.2:
+ resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+ hasBin: true
+
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
@@ -7455,6 +8380,10 @@ packages:
ts-brand@0.2.0:
resolution: {integrity: sha512-H5uo7OqMvd91D2EefFmltBP9oeNInNzWLAZUSt6coGDn8b814Eis6SnEtzyXORr9ccEb38PfzyiRVDacdkycSQ==}
+ ts-dedent@2.2.0:
+ resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==}
+ engines: {node: '>=6.10'}
+
ts-jest@29.4.6:
resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==}
engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0}
@@ -7552,6 +8481,10 @@ packages:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
+ type-fest@5.4.4:
+ resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==}
+ engines: {node: '>=20'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -7627,14 +8560,24 @@ packages:
resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==}
engines: {node: '>=4'}
+ unicode-properties@1.4.1:
+ resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
+
unicode-property-aliases-ecmascript@2.2.0:
resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==}
engines: {node: '>=4'}
+ unicode-trie@2.0.0:
+ resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
+
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
+ union@0.5.0:
+ resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==}
+ engines: {node: '>= 0.8.0'}
+
unique-string@2.0.0:
resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==}
engines: {node: '>=8'}
@@ -7651,9 +8594,16 @@ packages:
universal-user-agent@7.0.3:
resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==}
+ unplugin@2.3.11:
+ resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
+ engines: {node: '>=18.12.0'}
+
unrs-resolver@1.11.1:
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
+ until-async@3.0.2:
+ resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==}
+
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -7663,6 +8613,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ url-join@4.0.1:
+ resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
+
urlpattern-polyfill@10.1.0:
resolution: {integrity: sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==}
@@ -7759,11 +8712,30 @@ packages:
validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
+ vite-compatible-readable-stream@3.6.1:
+ resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
+ engines: {node: '>= 6'}
+
vite-node@3.2.4:
resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
+ vite-plugin-storybook-nextjs@3.1.12:
+ resolution: {integrity: sha512-/9qKlYWHVXz6AnQBngxD+v26fbdfVaTXeQqVQOvWocOE03FV+sdkwVsallJbmb8iMrkO36G7iLd2T1J0BXUIVQ==}
+ peerDependencies:
+ next: ^14.1.0 || ^15.0.0 || ^16.0.0
+ storybook: ^0.0.0-0 || ^9.0.0 || ^10.0.0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ vite-tsconfig-paths@5.1.4:
+ resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==}
+ peerDependencies:
+ vite: '*'
+ peerDependenciesMeta:
+ vite:
+ optional: true
+
vite-tsconfig-paths@6.1.0:
resolution: {integrity: sha512-kpd3sY9glHIDaq4V/Tlc1Y8WaKtutoc3B525GHxEVKWX42FKfQsXvjFOemu1I8VIN8pNbrMLWVTbW79JaRUxKg==}
peerDependencies:
@@ -7820,6 +8792,21 @@ packages:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
+ wait-on@7.2.0:
+ resolution: {integrity: sha512-wCQcHkRazgjG5XoAq9jbTMLpNIjoSlZslrJ2+N9MxDsGEv1HnFoVjOCexL0ESva7Y9cu350j+DWADdk54s4AFQ==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+
+ wait-on@8.0.5:
+ resolution: {integrity: sha512-J3WlS0txVHkhLRb2FsmRg3dkMTCV1+M6Xra3Ho7HzZDHpE7DCOnoSoCJsZotrmW3uRMhvIJGSKUKrh/MeF4iag==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+
+ wait-port@0.2.14:
+ resolution: {integrity: sha512-kIzjWcr6ykl7WFbZd0TMae8xovwqcqbx6FM9l+7agOgUByhzdjfzZBPK2CPufldTOMxbUivss//Sh9MFawmPRQ==}
+ engines: {node: '>=8'}
+ hasBin: true
+
walk-up-path@4.0.0:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
@@ -7841,6 +8828,14 @@ packages:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
+ webpack-virtual-modules@0.6.2:
+ resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+
+ whatwg-encoding@2.0.0:
+ resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
+ engines: {node: '>=12'}
+ deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
+
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
@@ -7892,6 +8887,10 @@ packages:
resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==}
engines: {node: '>= 0.4'}
+ which@1.3.1:
+ resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
+ hasBin: true
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -7955,6 +8954,10 @@ packages:
utf-8-validate:
optional: true
+ wsl-utils@0.1.0:
+ resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
+ engines: {node: '>=18'}
+
xdg-basedir@4.0.0:
resolution: {integrity: sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==}
engines: {node: '>=8'}
@@ -7967,6 +8970,9 @@ packages:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
+ xml@1.0.1:
+ resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==}
+
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
@@ -8023,10 +9029,17 @@ packages:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
+ yoctocolors-cjs@2.1.3:
+ resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
+ engines: {node: '>=18'}
+
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
+ yoga-layout@3.2.1:
+ resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
+
zip-stream@6.0.1:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
@@ -9382,6 +10395,28 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
+ '@hapi/address@5.1.1':
+ dependencies:
+ '@hapi/hoek': 11.0.7
+
+ '@hapi/formula@3.0.2': {}
+
+ '@hapi/hoek@11.0.7': {}
+
+ '@hapi/hoek@9.3.0': {}
+
+ '@hapi/pinpoint@2.0.1': {}
+
+ '@hapi/tlds@1.1.5': {}
+
+ '@hapi/topo@5.1.0':
+ dependencies:
+ '@hapi/hoek': 9.3.0
+
+ '@hapi/topo@6.0.2':
+ dependencies:
+ '@hapi/hoek': 11.0.7
+
'@headlessui/react@2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@floating-ui/react': 0.26.28(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -9508,6 +10543,8 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
+ '@inquirer/ansi@1.0.2': {}
+
'@inquirer/ansi@2.0.3': {}
'@inquirer/checkbox@5.0.4(@types/node@25.2.2)':
@@ -9519,6 +10556,13 @@ snapshots:
optionalDependencies:
'@types/node': 25.2.2
+ '@inquirer/confirm@5.1.21(@types/node@25.2.2)':
+ dependencies:
+ '@inquirer/core': 10.3.2(@types/node@25.2.2)
+ '@inquirer/type': 3.0.10(@types/node@25.2.2)
+ optionalDependencies:
+ '@types/node': 25.2.2
+
'@inquirer/confirm@6.0.4(@types/node@25.2.2)':
dependencies:
'@inquirer/core': 11.1.1(@types/node@25.2.2)
@@ -9526,6 +10570,19 @@ snapshots:
optionalDependencies:
'@types/node': 25.2.2
+ '@inquirer/core@10.3.2(@types/node@25.2.2)':
+ dependencies:
+ '@inquirer/ansi': 1.0.2
+ '@inquirer/figures': 1.0.15
+ '@inquirer/type': 3.0.10(@types/node@25.2.2)
+ cli-width: 4.1.0
+ mute-stream: 2.0.0
+ signal-exit: 4.1.0
+ wrap-ansi: 6.2.0
+ yoctocolors-cjs: 2.1.3
+ optionalDependencies:
+ '@types/node': 25.2.2
+
'@inquirer/core@11.1.1(@types/node@25.2.2)':
dependencies:
'@inquirer/ansi': 2.0.3
@@ -9560,6 +10617,8 @@ snapshots:
optionalDependencies:
'@types/node': 25.2.2
+ '@inquirer/figures@1.0.15': {}
+
'@inquirer/figures@2.0.3': {}
'@inquirer/input@5.0.4(@types/node@25.2.2)':
@@ -9623,6 +10682,10 @@ snapshots:
optionalDependencies:
'@types/node': 25.2.2
+ '@inquirer/type@3.0.10(@types/node@25.2.2)':
+ optionalDependencies:
+ '@types/node': 25.2.2
+
'@inquirer/type@4.0.3(@types/node@25.2.2)':
optionalDependencies:
'@types/node': 25.2.2
@@ -9699,6 +10762,10 @@ snapshots:
- supports-color
- ts-node
+ '@jest/create-cache-key-function@30.2.0':
+ dependencies:
+ '@jest/types': 30.2.0
+
'@jest/diff-sequences@30.0.1': {}
'@jest/environment-jsdom-abstract@30.2.0(jsdom@26.1.0)':
@@ -9844,6 +10911,14 @@ snapshots:
'@types/yargs': 17.0.35
chalk: 4.1.2
+ '@joshwooding/vite-plugin-react-docgen-typescript@0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ glob: 13.0.2
+ react-docgen-typescript: 2.4.0(typescript@5.9.3)
+ vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ optionalDependencies:
+ typescript: 5.9.3
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -9888,6 +10963,12 @@ snapshots:
'@marijn/find-cluster-break@1.0.2': {}
+ '@mdx-js/react@3.1.1(@types/react@19.2.13)(react@19.2.4)':
+ dependencies:
+ '@types/mdx': 2.0.13
+ '@types/react': 19.2.13
+ react: 19.2.4
+
'@mswjs/interceptors@0.39.8':
dependencies:
'@open-draft/deferred-promise': 2.2.0
@@ -9897,6 +10978,15 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
+ '@mswjs/interceptors@0.41.2':
+ dependencies:
+ '@open-draft/deferred-promise': 2.2.0
+ '@open-draft/logger': 0.3.0
+ '@open-draft/until': 2.1.0
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ strict-event-emitter: 0.5.1
+
'@mux/mux-data-google-ima@0.2.8':
dependencies:
mux-embed: 5.9.0
@@ -9948,6 +11038,8 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
+ '@next/env@16.0.0': {}
+
'@next/env@16.1.6': {}
'@next/eslint-plugin-next@16.1.6':
@@ -10344,32 +11436,141 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- '@react-stately/flags@3.1.2':
- dependencies:
- '@swc/helpers': 0.5.18
+ '@react-pdf/fns@3.1.2': {}
- '@react-stately/utils@3.11.0(react@19.2.4)':
+ '@react-pdf/font@4.0.4':
dependencies:
- '@swc/helpers': 0.5.18
- react: 19.2.4
+ '@react-pdf/pdfkit': 4.1.0
+ '@react-pdf/types': 2.9.2
+ fontkit: 2.0.4
+ is-url: 1.2.4
- '@react-types/shared@3.33.0(react@19.2.4)':
+ '@react-pdf/image@3.0.4':
dependencies:
- react: 19.2.4
+ '@react-pdf/png-js': 3.0.0
+ jay-peg: 1.1.1
- '@rexxars/react-json-inspector@9.0.1(react@19.2.4)':
+ '@react-pdf/layout@4.4.2':
dependencies:
- debounce: 1.2.1
- md5-o-matic: 0.1.1
- react: 19.2.4
+ '@react-pdf/fns': 3.1.2
+ '@react-pdf/image': 3.0.4
+ '@react-pdf/primitives': 4.1.1
+ '@react-pdf/stylesheet': 6.1.2
+ '@react-pdf/textkit': 6.1.0
+ '@react-pdf/types': 2.9.2
+ emoji-regex-xs: 1.0.0
+ queue: 6.0.2
+ yoga-layout: 3.2.1
- '@rexxars/react-split-pane@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ '@react-pdf/pdfkit@4.1.0':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@react-pdf/png-js': 3.0.0
+ browserify-zlib: 0.2.0
+ crypto-js: 4.2.0
+ fontkit: 2.0.4
+ jay-peg: 1.1.1
+ linebreak: 1.1.0
+ vite-compatible-readable-stream: 3.6.1
+
+ '@react-pdf/png-js@3.0.0':
+ dependencies:
+ browserify-zlib: 0.2.0
+
+ '@react-pdf/primitives@4.1.1': {}
+
+ '@react-pdf/reconciler@2.0.0(react@19.2.4)':
+ dependencies:
+ object-assign: 4.1.1
+ react: 19.2.4
+ scheduler: 0.25.0-rc-603e6108-20241029
+
+ '@react-pdf/render@4.3.2':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@react-pdf/fns': 3.1.2
+ '@react-pdf/primitives': 4.1.1
+ '@react-pdf/textkit': 6.1.0
+ '@react-pdf/types': 2.9.2
+ abs-svg-path: 0.1.1
+ color-string: 1.9.1
+ normalize-svg-path: 1.1.0
+ parse-svg-path: 0.1.2
+ svg-arc-to-cubic-bezier: 3.2.0
+
+ '@react-pdf/renderer@4.3.2(react@19.2.4)':
+ dependencies:
+ '@babel/runtime': 7.28.6
+ '@react-pdf/fns': 3.1.2
+ '@react-pdf/font': 4.0.4
+ '@react-pdf/layout': 4.4.2
+ '@react-pdf/pdfkit': 4.1.0
+ '@react-pdf/primitives': 4.1.1
+ '@react-pdf/reconciler': 2.0.0(react@19.2.4)
+ '@react-pdf/render': 4.3.2
+ '@react-pdf/types': 2.9.2
+ events: 3.3.0
+ object-assign: 4.1.1
+ prop-types: 15.8.1
+ queue: 6.0.2
+ react: 19.2.4
+
+ '@react-pdf/stylesheet@6.1.2':
+ dependencies:
+ '@react-pdf/fns': 3.1.2
+ '@react-pdf/types': 2.9.2
+ color-string: 1.9.1
+ hsl-to-hex: 1.0.0
+ media-engine: 1.0.3
+ postcss-value-parser: 4.2.0
+
+ '@react-pdf/textkit@6.1.0':
+ dependencies:
+ '@react-pdf/fns': 3.1.2
+ bidi-js: 1.0.3
+ hyphen: 1.14.1
+ unicode-properties: 1.4.1
+
+ '@react-pdf/types@2.9.2':
+ dependencies:
+ '@react-pdf/font': 4.0.4
+ '@react-pdf/primitives': 4.1.1
+ '@react-pdf/stylesheet': 6.1.2
+
+ '@react-stately/flags@3.1.2':
+ dependencies:
+ '@swc/helpers': 0.5.18
+
+ '@react-stately/utils@3.11.0(react@19.2.4)':
+ dependencies:
+ '@swc/helpers': 0.5.18
+ react: 19.2.4
+
+ '@react-types/shared@3.33.0(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+
+ '@rexxars/react-json-inspector@9.0.1(react@19.2.4)':
+ dependencies:
+ debounce: 1.2.1
+ md5-o-matic: 0.1.1
+ react: 19.2.4
+
+ '@rexxars/react-split-pane@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@rolldown/pluginutils@1.0.0-rc.2': {}
+ '@rollup/pluginutils@5.3.0(rollup@4.57.1)':
+ dependencies:
+ '@types/estree': 1.0.8
+ estree-walker: 2.0.2
+ picomatch: 4.0.3
+ optionalDependencies:
+ rollup: 4.57.1
+
'@rollup/rollup-android-arm-eabi@4.57.1':
optional: true
@@ -11173,6 +12374,14 @@ snapshots:
hoist-non-react-statics: 3.3.2
react: 19.2.4
+ '@sideway/address@4.1.5':
+ dependencies:
+ '@hapi/hoek': 9.3.0
+
+ '@sideway/formula@3.0.1': {}
+
+ '@sideway/pinpoint@2.0.0': {}
+
'@sinclair/typebox@0.34.48': {}
'@sinonjs/commons@3.0.1':
@@ -11185,6 +12394,8 @@ snapshots:
'@stablelib/base64@1.0.1': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@starefossen/sanity-plugin-inline-svg-input@1.3.7(@noble/hashes@2.0.1)(@sanity/ui@3.1.11(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(styled-components@6.3.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(sanity@5.8.1(@emotion/is-prop-valid@1.4.0)(@noble/hashes@2.0.1)(@swc/helpers@0.5.18)(@types/node@25.2.2)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(babel-plugin-react-compiler@1.0.0)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(styled-components@6.3.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2))(styled-components@6.3.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
dependencies:
'@sanity/incompatible-plugin': 1.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -11199,6 +12410,147 @@ snapshots:
- canvas
- supports-color
+ '@storybook/addon-docs@10.2.8(@types/react@19.2.13)(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@19.2.4)
+ '@storybook/csf-plugin': 10.2.8(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@storybook/react-dom-shim': 10.2.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ ts-dedent: 2.2.0
+ transitivePeerDependencies:
+ - '@types/react'
+ - esbuild
+ - rollup
+ - vite
+ - webpack
+
+ '@storybook/builder-vite@10.2.8(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@storybook/csf-plugin': 10.2.8(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ ts-dedent: 2.2.0
+ vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ transitivePeerDependencies:
+ - esbuild
+ - rollup
+ - webpack
+
+ '@storybook/csf-plugin@10.2.8(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ unplugin: 2.3.11
+ optionalDependencies:
+ esbuild: 0.27.3
+ rollup: 4.57.1
+ vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+
+ '@storybook/global@5.0.0': {}
+
+ '@storybook/icons@2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
+ '@storybook/nextjs-vite@10.2.8(@babel/core@7.29.0)(esbuild@0.27.3)(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@storybook/builder-vite': 10.2.8(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@storybook/react': 10.2.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
+ '@storybook/react-vite': 10.2.8(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ next: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
+ vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ vite-plugin-storybook-nextjs: 3.1.12(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@babel/core'
+ - babel-plugin-macros
+ - esbuild
+ - rollup
+ - supports-color
+ - webpack
+
+ '@storybook/react-dom-shim@10.2.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))':
+ dependencies:
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+
+ '@storybook/react-vite@10.2.8(esbuild@0.27.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@joshwooding/vite-plugin-react-docgen-typescript': 0.6.4(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@rollup/pluginutils': 5.3.0(rollup@4.57.1)
+ '@storybook/builder-vite': 10.2.8(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ '@storybook/react': 10.2.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)
+ empathic: 2.0.0
+ magic-string: 0.30.21
+ react: 19.2.4
+ react-docgen: 8.0.2
+ react-dom: 19.2.4(react@19.2.4)
+ resolve: 1.22.11
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ tsconfig-paths: 4.2.0
+ vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ transitivePeerDependencies:
+ - esbuild
+ - rollup
+ - supports-color
+ - typescript
+ - webpack
+
+ '@storybook/react@10.2.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)':
+ dependencies:
+ '@storybook/global': 5.0.0
+ '@storybook/react-dom-shim': 10.2.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
+ react: 19.2.4
+ react-docgen: 8.0.2
+ react-dom: 19.2.4(react@19.2.4)
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@storybook/test-runner@0.24.2(@swc/helpers@0.5.18)(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3))':
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/generator': 7.29.1
+ '@babel/template': 7.28.6
+ '@babel/types': 7.29.0
+ '@jest/types': 30.2.0
+ '@swc/core': 1.15.11(@swc/helpers@0.5.18)
+ '@swc/jest': 0.2.39(@swc/core@1.15.11(@swc/helpers@0.5.18))
+ expect-playwright: 0.8.0
+ jest: 30.2.0(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3))
+ jest-circus: 30.2.0
+ jest-environment-node: 30.2.0
+ jest-junit: 16.0.0
+ jest-process-manager: 0.4.0
+ jest-runner: 30.2.0
+ jest-serializer-html: 7.1.0
+ jest-watch-typeahead: 3.0.1(jest@30.2.0(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3)))
+ nyc: 15.1.0
+ playwright: 1.58.2
+ playwright-core: 1.58.2
+ rimraf: 3.0.2
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ uuid: 8.3.2
+ transitivePeerDependencies:
+ - '@swc/helpers'
+ - '@types/node'
+ - babel-plugin-macros
+ - debug
+ - esbuild-register
+ - node-notifier
+ - supports-color
+ - ts-node
+
'@svgdotjs/svg.draggable.js@3.0.6(@svgdotjs/svg.js@3.2.5)':
dependencies:
'@svgdotjs/svg.js': 3.2.5
@@ -11275,6 +12627,13 @@ snapshots:
dependencies:
tslib: 2.8.1
+ '@swc/jest@0.2.39(@swc/core@1.15.11(@swc/helpers@0.5.18))':
+ dependencies:
+ '@jest/create-cache-key-function': 30.2.0
+ '@swc/core': 1.15.11(@swc/helpers@0.5.18)
+ '@swc/counter': 0.1.3
+ jsonc-parser: 3.3.1
+
'@swc/types@0.1.25':
dependencies:
'@swc/counter': 0.1.3
@@ -11414,6 +12773,10 @@ snapshots:
'@types/react': 19.2.13
'@types/react-dom': 19.2.3(@types/react@19.2.13)
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@trpc/client@11.9.0(@trpc/server@11.9.0(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@trpc/server': 11.9.0(typescript@5.9.3)
@@ -11477,6 +12840,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/node': 25.2.2
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/connect@3.4.38':
dependencies:
'@types/node': 25.2.2
@@ -11492,6 +12860,10 @@ snapshots:
'@types/keygrip': 1.0.6
'@types/node': 25.2.2
+ '@types/deep-eql@4.0.2': {}
+
+ '@types/doctrine@0.0.9': {}
+
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {}
@@ -11565,6 +12937,8 @@ snapshots:
'@types/koa-compose': 3.2.9
'@types/node': 25.2.2
+ '@types/mdx@2.0.13': {}
+
'@types/mime@1.3.5': {}
'@types/node@17.0.45': {}
@@ -11597,6 +12971,8 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/resolve@1.20.6': {}
+
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
@@ -11618,6 +12994,8 @@ snapshots:
'@types/stack-utils@2.0.3': {}
+ '@types/statuses@2.0.6': {}
+
'@types/stylis@4.2.7': {}
'@types/tar-stream@3.1.4':
@@ -11637,6 +13015,10 @@ snapshots:
'@types/uuid@8.3.4': {}
+ '@types/wait-on@5.3.4':
+ dependencies:
+ '@types/node': 25.2.2
+
'@types/which@3.0.4': {}
'@types/yargs-parser@21.0.3': {}
@@ -11857,6 +13239,28 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vitest/expect@3.2.4':
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/spy': 3.2.4
+ '@vitest/utils': 3.2.4
+ chai: 5.3.3
+ tinyrainbow: 2.0.0
+
+ '@vitest/pretty-format@3.2.4':
+ dependencies:
+ tinyrainbow: 2.0.0
+
+ '@vitest/spy@3.2.4':
+ dependencies:
+ tinyspy: 4.0.4
+
+ '@vitest/utils@3.2.4':
+ dependencies:
+ '@vitest/pretty-format': 3.2.4
+ loupe: 3.2.1
+ tinyrainbow: 2.0.0
+
'@workos-inc/authkit-nextjs@2.13.0(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@workos-inc/node': 7.82.0(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
@@ -11903,6 +13307,8 @@ snapshots:
dependencies:
event-target-shim: 5.0.1
+ abs-svg-path@0.1.1: {}
+
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -11921,6 +13327,11 @@ snapshots:
agent-base@7.1.4: {}
+ aggregate-error@3.1.0:
+ dependencies:
+ clean-stack: 2.2.0
+ indent-string: 4.0.0
+
ajv-formats@3.0.1(ajv@8.17.1):
optionalDependencies:
ajv: 8.17.1
@@ -11947,6 +13358,10 @@ snapshots:
dependencies:
type-fest: 0.21.3
+ ansi-escapes@7.3.0:
+ dependencies:
+ environment: 1.1.0
+
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
@@ -11983,6 +13398,10 @@ snapshots:
'@svgdotjs/svg.select.js': 4.0.3(@svgdotjs/svg.js@3.2.5)
'@yr/monotone-cubic-spline': 1.0.3
+ append-transform@2.0.0:
+ dependencies:
+ default-require-extensions: 3.0.1
+
archiver-utils@5.0.2:
dependencies:
glob: 10.5.0
@@ -12006,6 +13425,8 @@ snapshots:
- bare-abort-controller
- react-native-b4a
+ archy@1.0.0: {}
+
arg@4.1.3: {}
argparse@1.0.10:
@@ -12099,8 +13520,14 @@ snapshots:
pvutils: 1.1.5
tslib: 2.8.1
+ assertion-error@2.0.1: {}
+
ast-types-flow@0.0.8: {}
+ ast-types@0.16.1:
+ dependencies:
+ tslib: 2.8.1
+
async-function@1.0.0: {}
async-mutex@0.5.0:
@@ -12139,6 +13566,14 @@ snapshots:
axe-core@4.11.1: {}
+ axios@1.13.5:
+ dependencies:
+ follow-redirects: 1.15.11(debug@4.4.3)
+ form-data: 4.0.5
+ proxy-from-env: 1.1.0
+ transitivePeerDependencies:
+ - debug
+
axobject-query@4.1.0: {}
b4a@1.7.3: {}
@@ -12231,10 +13666,16 @@ snapshots:
base64-arraybuffer@1.0.2: {}
+ base64-js@0.0.8: {}
+
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.19: {}
+ basic-auth@2.0.1:
+ dependencies:
+ safe-buffer: 5.1.2
+
basic-ftp@5.1.0: {}
before-after-hook@4.0.0: {}
@@ -12277,10 +13718,18 @@ snapshots:
dependencies:
fill-range: 7.1.1
+ brotli@1.3.3:
+ dependencies:
+ base64-js: 1.5.1
+
browserify-zlib@0.1.4:
dependencies:
pako: 0.2.9
+ browserify-zlib@0.2.0:
+ dependencies:
+ pako: 1.0.11
+
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.9.19
@@ -12315,8 +13764,19 @@ snapshots:
base64-js: 1.5.1
ieee754: 1.2.1
+ bundle-name@4.1.0:
+ dependencies:
+ run-applescript: 7.1.0
+
cac@6.7.14: {}
+ caching-transform@4.0.0:
+ dependencies:
+ hasha: 5.2.2
+ make-dir: 3.1.0
+ package-hash: 4.0.0
+ write-file-atomic: 3.0.3
+
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -12361,6 +13821,14 @@ snapshots:
dependencies:
react: 19.2.4
+ chai@5.3.3:
+ dependencies:
+ assertion-error: 2.0.1
+ check-error: 2.1.3
+ deep-eql: 5.0.2
+ loupe: 3.2.1
+ pathval: 2.0.1
+
chalk@2.4.2:
dependencies:
ansi-styles: 3.2.1
@@ -12384,6 +13852,8 @@ snapshots:
chardet@2.1.1: {}
+ check-error@2.1.3: {}
+
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
@@ -12398,12 +13868,16 @@ snapshots:
chownr@1.1.4: {}
+ chromatic@12.2.0: {}
+
ci-info@4.4.0: {}
cjs-module-lexer@2.2.0: {}
classnames@2.5.1: {}
+ clean-stack@2.2.0: {}
+
clean-stack@3.0.1:
dependencies:
escape-string-regexp: 4.0.0
@@ -12440,6 +13914,8 @@ snapshots:
kind-of: 6.0.3
shallow-clone: 3.0.1
+ clone@2.1.2: {}
+
clsx@2.1.1: {}
co@4.6.0: {}
@@ -12468,6 +13944,11 @@ snapshots:
color-name@1.1.4: {}
+ color-string@1.9.1:
+ dependencies:
+ color-name: 1.1.4
+ simple-swizzle: 0.2.4
+
color2k@2.0.3: {}
combined-stream@1.0.8:
@@ -12476,6 +13957,10 @@ snapshots:
comma-separated-tokens@2.0.3: {}
+ commander@12.1.0: {}
+
+ commander@3.0.2: {}
+
commondir@1.0.1: {}
compress-commons@6.0.2:
@@ -12490,6 +13975,15 @@ snapshots:
concat-map@0.0.1: {}
+ concurrently@9.2.1:
+ dependencies:
+ chalk: 4.1.2
+ rxjs: 7.8.2
+ shell-quote: 1.8.3
+ supports-color: 8.1.1
+ tree-kill: 1.2.2
+ yargs: 17.7.2
+
config-chain@1.1.13:
dependencies:
ini: 1.3.8
@@ -12515,18 +14009,24 @@ snapshots:
dependencies:
simple-wcswidth: 1.1.2
+ convert-source-map@1.9.0: {}
+
convert-source-map@2.0.0: {}
cookie@0.5.0: {}
cookie@0.7.2: {}
+ cookie@1.1.1: {}
+
core-js-compat@3.48.0:
dependencies:
browserslist: 4.28.1
core-util-is@1.0.3: {}
+ corser@2.0.1: {}
+
crc-32@1.2.2: {}
crc32-stream@6.0.0:
@@ -12544,6 +14044,8 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ crypto-js@4.2.0: {}
+
crypto-random-string@2.0.0: {}
css-color-keywords@1.0.0: {}
@@ -12591,6 +14093,11 @@ snapshots:
custom-media-element@1.4.5: {}
+ cwd@0.10.0:
+ dependencies:
+ find-pkg: 0.1.2
+ fs-exists-sync: 0.1.0
+
damerau-levenshtein@1.0.8: {}
data-uri-to-buffer@6.0.2: {}
@@ -12662,12 +14169,25 @@ snapshots:
deeks@3.1.0: {}
+ deep-eql@5.0.2: {}
+
deep-extend@0.6.0: {}
deep-is@0.1.4: {}
deepmerge@4.3.1: {}
+ default-browser-id@5.0.1: {}
+
+ default-browser@5.5.0:
+ dependencies:
+ bundle-name: 4.1.0
+ default-browser-id: 5.0.1
+
+ default-require-extensions@3.0.1:
+ dependencies:
+ strip-bom: 4.0.0
+
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@@ -12676,6 +14196,8 @@ snapshots:
define-lazy-prop@2.0.0: {}
+ define-lazy-prop@3.0.0: {}
+
define-properties@1.2.1:
dependencies:
define-data-property: 1.1.4
@@ -12692,8 +14214,14 @@ snapshots:
detect-node-es@1.1.0: {}
+ dfa@1.2.0: {}
+
diff@4.0.4: {}
+ diffable-html@4.1.0:
+ dependencies:
+ htmlparser2: 3.10.1
+
dijkstrajs@1.0.3: {}
dir-glob@3.0.1:
@@ -12708,18 +14236,33 @@ snapshots:
dependencies:
esutils: 2.0.3
+ doctrine@3.0.0:
+ dependencies:
+ esutils: 2.0.3
+
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
+ dom-serializer@0.2.2:
+ dependencies:
+ domelementtype: 2.3.0
+ entities: 2.2.0
+
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
+ domelementtype@1.3.1: {}
+
domelementtype@2.3.0: {}
+ domhandler@2.4.2:
+ dependencies:
+ domelementtype: 1.3.1
+
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
@@ -12728,6 +14271,11 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
+ domutils@1.7.0:
+ dependencies:
+ dom-serializer: 0.2.2
+ domelementtype: 1.3.1
+
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
@@ -12767,12 +14315,16 @@ snapshots:
emittery@0.13.1: {}
+ emoji-regex-xs@1.0.0: {}
+
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
+ empathic@2.0.0: {}
+
encoding-japanese@2.2.0: {}
end-of-stream@1.4.5:
@@ -12784,10 +14336,16 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ entities@1.1.2: {}
+
+ entities@2.2.0: {}
+
entities@4.5.0: {}
entities@6.0.1: {}
+ environment@1.1.0: {}
+
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@@ -12895,6 +14453,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
+ es6-error@4.1.1: {}
+
esbuild-register@3.6.0(esbuild@0.27.2):
dependencies:
debug: 4.4.3(supports-color@8.1.1)
@@ -13108,6 +14668,15 @@ snapshots:
string.prototype.matchall: 4.0.12
string.prototype.repeat: 1.0.0
+ eslint-plugin-storybook@10.2.8(eslint@10.0.0(jiti@2.6.1))(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3):
+ dependencies:
+ '@typescript-eslint/utils': 8.54.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3)
+ eslint: 10.0.0(jiti@2.6.1)
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
eslint-scope@9.1.0:
dependencies:
'@types/esrecurse': 4.3.1
@@ -13180,12 +14749,16 @@ snapshots:
estraverse@5.3.0: {}
+ estree-walker@2.0.2: {}
+
esutils@2.0.3: {}
event-source-polyfill@1.0.31: {}
event-target-shim@5.0.1: {}
+ eventemitter3@4.0.7: {}
+
eventemitter3@5.0.4: {}
events-universal@1.0.1:
@@ -13232,6 +14805,14 @@ snapshots:
exit-x@0.2.2: {}
+ exit@0.1.2: {}
+
+ expand-tilde@1.2.2:
+ dependencies:
+ os-homedir: 1.0.2
+
+ expect-playwright@0.8.0: {}
+
expect@30.2.0:
dependencies:
'@jest/expect-utils': 30.2.0
@@ -13309,6 +14890,27 @@ snapshots:
make-dir: 2.1.0
pkg-dir: 3.0.0
+ find-cache-dir@3.3.2:
+ dependencies:
+ commondir: 1.0.1
+ make-dir: 3.1.0
+ pkg-dir: 4.2.0
+
+ find-file-up@0.1.3:
+ dependencies:
+ fs-exists-sync: 0.1.0
+ resolve-dir: 0.1.1
+
+ find-pkg@0.1.2:
+ dependencies:
+ find-file-up: 0.1.3
+
+ find-process@1.4.11:
+ dependencies:
+ chalk: 4.1.2
+ commander: 12.1.0
+ loglevel: 1.9.2
+
find-up-simple@1.0.1: {}
find-up@3.0.0:
@@ -13350,10 +14952,27 @@ snapshots:
optionalDependencies:
debug: 4.4.3(supports-color@8.1.1)
+ fontkit@2.0.4:
+ dependencies:
+ '@swc/helpers': 0.5.18
+ brotli: 1.3.3
+ clone: 2.1.2
+ dfa: 1.2.0
+ fast-deep-equal: 3.1.3
+ restructure: 3.0.2
+ tiny-inflate: 1.0.3
+ unicode-properties: 1.4.1
+ unicode-trie: 2.0.0
+
for-each@0.3.5:
dependencies:
is-callable: 1.2.7
+ foreground-child@2.0.0:
+ dependencies:
+ cross-spawn: 7.0.6
+ signal-exit: 3.0.7
+
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -13383,10 +15002,17 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
+ fromentries@1.3.2: {}
+
fs-constants@1.0.0: {}
+ fs-exists-sync@0.1.0: {}
+
fs.realpath@1.0.0: {}
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -13494,6 +15120,12 @@ snapshots:
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
+ glob@13.0.2:
+ dependencies:
+ minimatch: 10.1.2
+ minipass: 7.1.2
+ path-scurry: 2.0.1
+
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
@@ -13503,6 +15135,18 @@ snapshots:
once: 1.4.0
path-is-absolute: 1.0.1
+ global-modules@0.2.3:
+ dependencies:
+ global-prefix: 0.1.5
+ is-windows: 0.2.0
+
+ global-prefix@0.1.5:
+ dependencies:
+ homedir-polyfill: 1.0.3
+ ini: 1.3.8
+ is-windows: 0.2.0
+ which: 1.3.1
+
globals@16.4.0: {}
globalthis@1.0.4:
@@ -13527,6 +15171,8 @@ snapshots:
graceful-fs@4.2.11: {}
+ graphql@16.12.0: {}
+
groq-js@1.26.0:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
@@ -13575,6 +15221,11 @@ snapshots:
dependencies:
has-symbols: 1.1.0
+ hasha@5.2.2:
+ dependencies:
+ is-stream: 2.0.1
+ type-fest: 0.8.1
+
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -13593,6 +15244,8 @@ snapshots:
he@1.2.0: {}
+ headers-polyfill@4.0.3: {}
+
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:
@@ -13609,10 +15262,24 @@ snapshots:
dependencies:
react-is: 16.13.1
+ homedir-polyfill@1.0.3:
+ dependencies:
+ parse-passwd: 1.0.0
+
hosted-git-info@2.8.9: {}
hotscript@1.0.13: {}
+ hsl-to-hex@1.0.0:
+ dependencies:
+ hsl-to-rgb-for-reals: 1.1.1
+
+ hsl-to-rgb-for-reals@1.1.1: {}
+
+ html-encoding-sniffer@3.0.0:
+ dependencies:
+ whatwg-encoding: 2.0.0
+
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
@@ -13642,6 +15309,15 @@ snapshots:
css-line-break: 2.1.0
text-segmentation: 1.0.3
+ htmlparser2@3.10.1:
+ dependencies:
+ domelementtype: 1.3.1
+ domhandler: 2.4.2
+ domutils: 1.7.0
+ entities: 1.1.2
+ inherits: 2.0.4
+ readable-stream: 3.6.2
+
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
@@ -13656,6 +15332,33 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ http-proxy@1.18.1:
+ dependencies:
+ eventemitter3: 4.0.7
+ follow-redirects: 1.15.11(debug@4.4.3)
+ requires-port: 1.0.0
+ transitivePeerDependencies:
+ - debug
+
+ http-server@14.1.1:
+ dependencies:
+ basic-auth: 2.0.1
+ chalk: 4.1.2
+ corser: 2.0.1
+ he: 1.2.0
+ html-encoding-sniffer: 3.0.0
+ http-proxy: 1.18.1
+ mime: 1.6.0
+ minimist: 1.2.8
+ opener: 1.5.2
+ portfinder: 1.0.38
+ secure-compare: 3.0.1
+ union: 0.5.0
+ url-join: 4.0.1
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -13667,6 +15370,8 @@ snapshots:
humanize-list@1.0.1: {}
+ hyphen@1.14.1: {}
+
i18next@23.16.8:
dependencies:
'@babel/runtime': 7.28.6
@@ -13689,6 +15394,8 @@ snapshots:
ignore@7.0.5: {}
+ image-size@2.0.2: {}
+
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -13759,6 +15466,8 @@ snapshots:
is-arrayish@0.2.1: {}
+ is-arrayish@0.3.4: {}
+
is-async-function@2.1.1:
dependencies:
async-function: 1.0.0
@@ -13809,6 +15518,8 @@ snapshots:
is-docker@2.2.1: {}
+ is-docker@3.0.0: {}
+
is-extglob@2.1.1: {}
is-finalizationregistry@1.1.1:
@@ -13839,6 +15550,10 @@ snapshots:
is-hotkey@0.2.0: {}
+ is-inside-container@1.0.0:
+ dependencies:
+ is-docker: 3.0.0
+
is-interactive@2.0.0: {}
is-map@2.0.3: {}
@@ -13902,6 +15617,8 @@ snapshots:
is-unicode-supported@2.1.0: {}
+ is-url@1.2.4: {}
+
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -13913,10 +15630,18 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
+ is-windows@0.2.0: {}
+
+ is-windows@1.0.2: {}
+
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
+ is-wsl@3.1.0:
+ dependencies:
+ is-inside-container: 1.0.0
+
isarray@1.0.0: {}
isarray@2.0.5: {}
@@ -13948,6 +15673,19 @@ snapshots:
istanbul-lib-coverage@3.2.2: {}
+ istanbul-lib-hook@3.0.0:
+ dependencies:
+ append-transform: 2.0.0
+
+ istanbul-lib-instrument@4.0.3:
+ dependencies:
+ '@babel/core': 7.29.0
+ '@istanbuljs/schema': 0.1.3
+ istanbul-lib-coverage: 3.2.2
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
istanbul-lib-instrument@6.0.3:
dependencies:
'@babel/core': 7.29.0
@@ -13958,12 +15696,29 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ istanbul-lib-processinfo@2.0.3:
+ dependencies:
+ archy: 1.0.0
+ cross-spawn: 7.0.6
+ istanbul-lib-coverage: 3.2.2
+ p-map: 3.0.0
+ rimraf: 3.0.2
+ uuid: 8.3.2
+
istanbul-lib-report@3.0.1:
dependencies:
istanbul-lib-coverage: 3.2.2
make-dir: 4.0.0
supports-color: 7.2.0
+ istanbul-lib-source-maps@4.0.1:
+ dependencies:
+ debug: 4.4.3(supports-color@8.1.1)
+ istanbul-lib-coverage: 3.2.2
+ source-map: 0.6.1
+ transitivePeerDependencies:
+ - supports-color
+
istanbul-lib-source-maps@5.0.6:
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -13998,6 +15753,10 @@ snapshots:
filelist: 1.0.4
picocolors: 1.1.1
+ jay-peg@1.1.1:
+ dependencies:
+ restructure: 3.0.2
+
jest-changed-files@30.2.0:
dependencies:
execa: 5.1.1
@@ -14139,6 +15898,13 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ jest-junit@16.0.0:
+ dependencies:
+ mkdirp: 1.0.4
+ strip-ansi: 6.0.1
+ uuid: 8.3.2
+ xml: 1.0.1
+
jest-leak-detector@30.2.0:
dependencies:
'@jest/get-type': 30.1.0
@@ -14173,6 +15939,22 @@ snapshots:
optionalDependencies:
jest-resolve: 30.2.0
+ jest-process-manager@0.4.0:
+ dependencies:
+ '@types/wait-on': 5.3.4
+ chalk: 4.1.2
+ cwd: 0.10.0
+ exit: 0.1.2
+ find-process: 1.4.11
+ prompts: 2.4.2
+ signal-exit: 3.0.7
+ spawnd: 5.0.0
+ tree-kill: 1.2.2
+ wait-on: 7.2.0
+ transitivePeerDependencies:
+ - debug
+ - supports-color
+
jest-regex-util@30.0.1: {}
jest-resolve-dependencies@30.2.0:
@@ -14247,6 +16029,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ jest-serializer-html@7.1.0:
+ dependencies:
+ diffable-html: 4.1.0
+
jest-snapshot@30.2.0:
dependencies:
'@babel/core': 7.29.0
@@ -14291,6 +16077,17 @@ snapshots:
leven: 3.1.0
pretty-format: 30.2.0
+ jest-watch-typeahead@3.0.1(jest@30.2.0(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3))):
+ dependencies:
+ ansi-escapes: 7.3.0
+ chalk: 5.6.2
+ jest: 30.2.0(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3))
+ jest-regex-util: 30.0.1
+ jest-watcher: 30.2.0
+ slash: 5.1.0
+ string-length: 6.0.0
+ strip-ansi: 7.1.2
+
jest-watcher@30.2.0:
dependencies:
'@jest/test-result': 30.2.0
@@ -14325,6 +16122,24 @@ snapshots:
jiti@2.6.1: {}
+ joi@17.13.3:
+ dependencies:
+ '@hapi/hoek': 9.3.0
+ '@hapi/topo': 5.1.0
+ '@sideway/address': 4.1.5
+ '@sideway/formula': 3.0.1
+ '@sideway/pinpoint': 2.0.0
+
+ joi@18.0.2:
+ dependencies:
+ '@hapi/address': 5.1.1
+ '@hapi/formula': 3.0.2
+ '@hapi/hoek': 11.0.7
+ '@hapi/pinpoint': 2.0.1
+ '@hapi/tlds': 1.1.5
+ '@hapi/topo': 6.0.2
+ '@standard-schema/spec': 1.1.0
+
jose@5.10.0: {}
jose@5.6.3: {}
@@ -14490,6 +16305,8 @@ snapshots:
kind-of@6.0.3: {}
+ kleur@3.0.3: {}
+
knip@5.83.1(@types/node@25.2.2)(typescript@5.9.3):
dependencies:
'@nodelib/fs.walk': 1.2.8
@@ -14594,6 +16411,11 @@ snapshots:
lilconfig@3.1.3: {}
+ linebreak@1.1.0:
+ dependencies:
+ base64-js: 0.0.8
+ unicode-trie: 2.0.0
+
lines-and-columns@1.2.4: {}
linkify-it@5.0.0:
@@ -14628,6 +16450,8 @@ snapshots:
lodash.debounce@4.0.8: {}
+ lodash.flattendeep@4.4.0: {}
+
lodash.memoize@4.1.2: {}
lodash@4.17.23: {}
@@ -14641,10 +16465,14 @@ snapshots:
is-unicode-supported: 2.1.0
yoctocolors: 2.1.2
+ loglevel@1.9.2: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
+ loupe@3.2.1: {}
+
lru-cache@10.4.3: {}
lru-cache@11.2.5: {}
@@ -14731,6 +16559,8 @@ snapshots:
transitivePeerDependencies:
- react
+ media-engine@1.0.3: {}
+
media-tracks@0.3.4: {}
mendoza@3.0.8: {}
@@ -14756,6 +16586,8 @@ snapshots:
dependencies:
mime-db: 1.54.0
+ mime@1.6.0: {}
+
mimic-fn@2.1.0: {}
mimic-function@5.0.1: {}
@@ -14788,6 +16620,10 @@ snapshots:
mkdirp-classic@0.5.3: {}
+ mkdirp@1.0.4: {}
+
+ module-alias@2.3.4: {}
+
motion-dom@12.33.0:
dependencies:
motion-utils: 12.29.2
@@ -14805,6 +16641,38 @@ snapshots:
ms@2.1.3: {}
+ msw-storybook-addon@2.0.6(msw@2.12.10(@types/node@25.2.2)(typescript@5.9.3)):
+ dependencies:
+ is-node-process: 1.2.0
+ msw: 2.12.10(@types/node@25.2.2)(typescript@5.9.3)
+
+ msw@2.12.10(@types/node@25.2.2)(typescript@5.9.3):
+ dependencies:
+ '@inquirer/confirm': 5.1.21(@types/node@25.2.2)
+ '@mswjs/interceptors': 0.41.2
+ '@open-draft/deferred-promise': 2.2.0
+ '@types/statuses': 2.0.6
+ cookie: 1.1.1
+ graphql: 16.12.0
+ headers-polyfill: 4.0.3
+ is-node-process: 1.2.0
+ outvariant: 1.4.3
+ path-to-regexp: 6.3.0
+ picocolors: 1.1.1
+ rettime: 0.10.1
+ statuses: 2.0.2
+ strict-event-emitter: 0.5.1
+ tough-cookie: 6.0.0
+ type-fest: 5.4.4
+ until-async: 3.0.2
+ yargs: 17.7.2
+ optionalDependencies:
+ typescript: 5.9.3
+ transitivePeerDependencies:
+ - '@types/node'
+
+ mute-stream@2.0.0: {}
+
mute-stream@3.0.0: {}
mux-embed@5.16.1: {}
@@ -14900,6 +16768,10 @@ snapshots:
node-int64@0.4.0: {}
+ node-preload@0.2.1:
+ dependencies:
+ process-on-spawn: 1.1.0
+
node-releases@2.0.27: {}
nodemailer@7.0.11: {}
@@ -14913,6 +16785,10 @@ snapshots:
normalize-path@3.0.0: {}
+ normalize-svg-path@1.1.0:
+ dependencies:
+ svg-arc-to-cubic-bezier: 3.2.0
+
npm-run-path@3.1.0:
dependencies:
path-key: 3.1.1
@@ -14927,6 +16803,38 @@ snapshots:
nwsapi@2.2.23: {}
+ nyc@15.1.0:
+ dependencies:
+ '@istanbuljs/load-nyc-config': 1.1.0
+ '@istanbuljs/schema': 0.1.3
+ caching-transform: 4.0.0
+ convert-source-map: 1.9.0
+ decamelize: 1.2.0
+ find-cache-dir: 3.3.2
+ find-up: 4.1.0
+ foreground-child: 2.0.0
+ get-package-type: 0.1.0
+ glob: 7.2.3
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-hook: 3.0.0
+ istanbul-lib-instrument: 4.0.3
+ istanbul-lib-processinfo: 2.0.3
+ istanbul-lib-report: 3.0.1
+ istanbul-lib-source-maps: 4.0.1
+ istanbul-reports: 3.2.0
+ make-dir: 3.1.0
+ node-preload: 0.2.1
+ p-map: 3.0.0
+ process-on-spawn: 1.1.0
+ resolve-from: 5.0.0
+ rimraf: 3.0.2
+ signal-exit: 3.0.7
+ spawn-wrap: 2.0.0
+ test-exclude: 6.0.0
+ yargs: 15.4.1
+ transitivePeerDependencies:
+ - supports-color
+
oauth4webapi@3.8.4: {}
object-assign@4.1.1: {}
@@ -14989,12 +16897,21 @@ snapshots:
dependencies:
mimic-function: 5.0.1
+ open@10.2.0:
+ dependencies:
+ default-browser: 5.5.0
+ define-lazy-prop: 3.0.0
+ is-inside-container: 1.0.0
+ wsl-utils: 0.1.0
+
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
+ opener@1.5.2: {}
+
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -15015,6 +16932,8 @@ snapshots:
stdin-discarder: 0.3.1
string-width: 8.1.1
+ os-homedir@1.0.2: {}
+
outvariant@1.4.3: {}
own-keys@1.0.1:
@@ -15076,6 +16995,10 @@ snapshots:
dependencies:
p-limit: 4.0.0
+ p-map@3.0.0:
+ dependencies:
+ aggregate-error: 3.1.0
+
p-map@7.0.4: {}
p-queue@9.1.0:
@@ -15087,10 +17010,19 @@ snapshots:
p-try@2.2.0: {}
+ package-hash@4.0.0:
+ dependencies:
+ graceful-fs: 4.2.11
+ hasha: 5.2.2
+ lodash.flattendeep: 4.4.0
+ release-zalgo: 1.0.0
+
package-json-from-dist@1.0.1: {}
pako@0.2.9: {}
+ pako@1.0.11: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -15116,6 +17048,10 @@ snapshots:
parse-ms@4.0.0: {}
+ parse-passwd@1.0.0: {}
+
+ parse-svg-path@0.1.2: {}
+
parse5@7.3.0:
dependencies:
entities: 6.0.1
@@ -15144,12 +17080,19 @@ snapshots:
lru-cache: 10.4.3
minipass: 7.1.2
+ path-scurry@2.0.1:
+ dependencies:
+ lru-cache: 11.2.5
+ minipass: 7.1.2
+
path-to-regexp@6.3.0: {}
path-type@4.0.0: {}
pathe@2.0.3: {}
+ pathval@2.0.1: {}
+
peberminta@0.9.0: {}
peek-stream@1.1.3:
@@ -15194,6 +17137,14 @@ snapshots:
transitivePeerDependencies:
- react
+ playwright-core@1.58.2: {}
+
+ playwright@1.58.2:
+ dependencies:
+ playwright-core: 1.58.2
+ optionalDependencies:
+ fsevents: 2.3.2
+
pluralize-esm@9.0.5: {}
pngjs@5.0.0: {}
@@ -15202,6 +17153,13 @@ snapshots:
dependencies:
'@babel/runtime': 7.28.6
+ portfinder@1.0.38:
+ dependencies:
+ async: 3.2.6
+ debug: 4.4.3(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - supports-color
+
possible-typed-array-names@1.1.0: {}
postcss-value-parser@4.2.0: {}
@@ -15266,8 +17224,17 @@ snapshots:
process-nextick-args@2.0.1: {}
+ process-on-spawn@1.1.0:
+ dependencies:
+ fromentries: 1.3.2
+
process@0.11.10: {}
+ prompts@2.4.2:
+ dependencies:
+ kleur: 3.0.3
+ sisteransi: 1.0.5
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -15280,6 +17247,8 @@ snapshots:
proto-list@1.2.4: {}
+ proxy-from-env@1.1.0: {}
+
pump@2.0.1:
dependencies:
end-of-stream: 1.4.5
@@ -15326,6 +17295,10 @@ snapshots:
queue-microtask@1.2.3: {}
+ queue@6.0.2:
+ dependencies:
+ inherits: 2.0.4
+
quick-lru@5.1.1: {}
quick-lru@7.3.0: {}
@@ -15360,6 +17333,25 @@ snapshots:
dependencies:
react: 19.2.4
+ react-docgen-typescript@2.4.0(typescript@5.9.3):
+ dependencies:
+ typescript: 5.9.3
+
+ react-docgen@8.0.2:
+ dependencies:
+ '@babel/core': 7.29.0
+ '@babel/traverse': 7.29.0
+ '@babel/types': 7.29.0
+ '@types/babel__core': 7.20.5
+ '@types/babel__traverse': 7.28.0
+ '@types/doctrine': 0.0.9
+ '@types/resolve': 1.20.6
+ doctrine: 3.0.0
+ resolve: 1.22.11
+ strip-indent: 4.1.1
+ transitivePeerDependencies:
+ - supports-color
+
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4
@@ -15468,6 +17460,14 @@ snapshots:
dependencies:
picomatch: 2.3.1
+ recast@0.23.11:
+ dependencies:
+ ast-types: 0.16.1
+ esprima: 4.0.1
+ source-map: 0.6.1
+ tiny-invariant: 1.3.3
+ tslib: 2.8.1
+
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@@ -15533,6 +17533,10 @@ snapshots:
dependencies:
jsesc: 3.1.0
+ release-zalgo@1.0.0:
+ dependencies:
+ es6-error: 4.1.1
+
remeda@2.33.5: {}
require-directory@2.1.1: {}
@@ -15541,6 +17545,8 @@ snapshots:
require-main-filename@2.0.0: {}
+ requires-port@1.0.0: {}
+
reselect@5.1.1: {}
resend@6.9.1(@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)):
@@ -15554,6 +17560,11 @@ snapshots:
dependencies:
resolve-from: 5.0.0
+ resolve-dir@0.1.1:
+ dependencies:
+ expand-tilde: 1.2.2
+ global-modules: 0.2.3
+
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
@@ -15577,10 +17588,18 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
+ restructure@3.0.2: {}
+
retry@0.13.1: {}
+ rettime@0.10.1: {}
+
reusify@1.1.0: {}
+ rimraf@3.0.2:
+ dependencies:
+ glob: 7.2.3
+
rimraf@5.0.10:
dependencies:
glob: 10.5.0
@@ -15618,6 +17637,8 @@ snapshots:
rrweb-cssom@0.8.0: {}
+ run-applescript@7.1.0: {}
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -15845,6 +17866,8 @@ snapshots:
dependencies:
xmlchars: 2.2.0
+ scheduler@0.25.0-rc-603e6108-20241029: {}
+
scheduler@0.27.0: {}
scroll-into-view-if-needed@3.1.0:
@@ -15853,6 +17876,8 @@ snapshots:
scrollmirror@1.2.4: {}
+ secure-compare@3.0.1: {}
+
selderee@0.11.0:
dependencies:
parseley: 0.12.1
@@ -15939,6 +17964,8 @@ snapshots:
shebang-regex@3.0.0: {}
+ shell-quote@1.8.3: {}
+
side-channel-list@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -15971,10 +17998,18 @@ snapshots:
signal-exit@4.1.0: {}
+ simple-swizzle@0.2.4:
+ dependencies:
+ is-arrayish: 0.3.4
+
simple-wcswidth@1.1.2: {}
+ sisteransi@1.0.5: {}
+
slash@3.0.0: {}
+ slash@5.1.0: {}
+
slate-dom@0.119.0(slate@0.120.0):
dependencies:
'@juggle/resize-observer': 3.4.0
@@ -16019,6 +18054,24 @@ snapshots:
space-separated-tokens@2.0.2: {}
+ spawn-wrap@2.0.0:
+ dependencies:
+ foreground-child: 2.0.0
+ is-windows: 1.0.2
+ make-dir: 3.1.0
+ rimraf: 3.0.2
+ signal-exit: 3.0.7
+ which: 2.0.2
+
+ spawnd@5.0.0:
+ dependencies:
+ exit: 0.1.2
+ signal-exit: 3.0.7
+ tree-kill: 1.2.2
+ wait-port: 0.2.14
+ transitivePeerDependencies:
+ - supports-color
+
spdx-correct@3.2.0:
dependencies:
spdx-expression-parse: 3.0.1
@@ -16050,6 +18103,8 @@ snapshots:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
+ statuses@2.0.2: {}
+
stdin-discarder@0.3.1: {}
stop-iteration-iterator@1.1.0:
@@ -16057,6 +18112,29 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
+ storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ dependencies:
+ '@storybook/global': 5.0.0
+ '@storybook/icons': 2.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ '@testing-library/jest-dom': 6.9.1
+ '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
+ '@vitest/expect': 3.2.4
+ '@vitest/spy': 3.2.4
+ esbuild: 0.27.3
+ open: 10.2.0
+ recast: 0.23.11
+ semver: 7.7.4
+ use-sync-external-store: 1.6.0(react@19.2.4)
+ ws: 8.19.0
+ optionalDependencies:
+ prettier: 3.8.1
+ transitivePeerDependencies:
+ - '@testing-library/dom'
+ - bufferutil
+ - react
+ - react-dom
+ - utf-8-validate
+
stream-shift@1.0.3: {}
streamx@2.23.0:
@@ -16075,6 +18153,10 @@ snapshots:
char-regex: 1.0.2
strip-ansi: 6.0.1
+ string-length@6.0.0:
+ dependencies:
+ strip-ansi: 7.1.2
+
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -16174,6 +18256,8 @@ snapshots:
dependencies:
min-indent: 1.0.1
+ strip-indent@4.1.1: {}
+
strip-json-comments@2.0.1: {}
strip-json-comments@3.1.1: {}
@@ -16226,6 +18310,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
+ svg-arc-to-cubic-bezier@3.2.0: {}
+
svix@1.84.1:
dependencies:
standardwebhooks: 1.0.0
@@ -16239,6 +18325,8 @@ snapshots:
tabbable@6.4.0: {}
+ tagged-tag@1.0.0: {}
+
tailwind-merge@3.4.0: {}
tailwindcss@4.1.18: {}
@@ -16296,13 +18384,21 @@ snapshots:
dependencies:
readable-stream: 3.6.2
+ tiny-inflate@1.0.3: {}
+
tiny-invariant@1.3.1: {}
+ tiny-invariant@1.3.3: {}
+
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
+ tinyrainbow@2.0.0: {}
+
+ tinyspy@4.0.4: {}
+
tlds@1.261.0: {}
tldts-core@6.1.86: {}
@@ -16339,12 +18435,16 @@ snapshots:
dependencies:
punycode: 2.3.1
+ tree-kill@1.2.2: {}
+
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
ts-brand@0.2.0: {}
+ ts-dedent@2.2.0: {}
+
ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.29.0))(esbuild@0.27.3)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.2.2)(esbuild-register@3.6.0(esbuild@0.27.3))(ts-node@10.9.2(@swc/core@1.15.11(@swc/helpers@0.5.18))(@types/node@25.2.2)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
@@ -16432,6 +18532,10 @@ snapshots:
type-fest@4.41.0: {}
+ type-fest@5.4.4:
+ dependencies:
+ tagged-tag: 1.0.0
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -16519,10 +18623,24 @@ snapshots:
unicode-match-property-value-ecmascript@2.2.1: {}
+ unicode-properties@1.4.1:
+ dependencies:
+ base64-js: 1.5.1
+ unicode-trie: 2.0.0
+
unicode-property-aliases-ecmascript@2.2.0: {}
+ unicode-trie@2.0.0:
+ dependencies:
+ pako: 0.2.9
+ tiny-inflate: 1.0.3
+
unicorn-magic@0.3.0: {}
+ union@0.5.0:
+ dependencies:
+ qs: 6.14.1
+
unique-string@2.0.0:
dependencies:
crypto-random-string: 2.0.0
@@ -16544,6 +18662,13 @@ snapshots:
universal-user-agent@7.0.3: {}
+ unplugin@2.3.11:
+ dependencies:
+ '@jridgewell/remapping': 2.3.5
+ acorn: 8.15.0
+ picomatch: 4.0.3
+ webpack-virtual-modules: 0.6.2
+
unrs-resolver@1.11.1:
dependencies:
napi-postinstall: 0.3.4
@@ -16568,6 +18693,8 @@ snapshots:
'@unrs/resolver-binding-win32-ia32-msvc': 1.11.1
'@unrs/resolver-binding-win32-x64-msvc': 1.11.1
+ until-async@3.0.2: {}
+
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
@@ -16578,6 +18705,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ url-join@4.0.1: {}
+
urlpattern-polyfill@10.1.0: {}
use-callback-ref@1.3.3(@types/react@19.2.13)(react@19.2.4):
@@ -16650,6 +18779,12 @@ snapshots:
spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1
+ vite-compatible-readable-stream@3.6.1:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
vite-node@3.2.4(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
cac: 6.7.14
@@ -16671,6 +18806,32 @@ snapshots:
- tsx
- yaml
+ vite-plugin-storybook-nextjs@3.1.12(next@16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(storybook@10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)):
+ dependencies:
+ '@next/env': 16.0.0
+ image-size: 2.0.2
+ magic-string: 0.30.21
+ module-alias: 2.3.4
+ next: 16.1.6(@babel/core@7.29.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ storybook: 10.2.8(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ ts-dedent: 2.2.0
+ vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ vite-tsconfig-paths: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
+ vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)):
+ dependencies:
+ debug: 4.4.3(supports-color@8.1.1)
+ globrex: 0.1.2
+ tsconfck: 3.1.6(typescript@5.9.3)
+ optionalDependencies:
+ vite: 7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
+ transitivePeerDependencies:
+ - supports-color
+ - typescript
+
vite-tsconfig-paths@6.1.0(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
debug: 4.4.3(supports-color@8.1.1)
@@ -16705,6 +18866,34 @@ snapshots:
dependencies:
xml-name-validator: 5.0.0
+ wait-on@7.2.0:
+ dependencies:
+ axios: 1.13.5
+ joi: 17.13.3
+ lodash: 4.17.23
+ minimist: 1.2.8
+ rxjs: 7.8.2
+ transitivePeerDependencies:
+ - debug
+
+ wait-on@8.0.5:
+ dependencies:
+ axios: 1.13.5
+ joi: 18.0.2
+ lodash: 4.17.23
+ minimist: 1.2.8
+ rxjs: 7.8.2
+ transitivePeerDependencies:
+ - debug
+
+ wait-port@0.2.14:
+ dependencies:
+ chalk: 2.4.2
+ commander: 3.0.2
+ debug: 4.4.3(supports-color@8.1.1)
+ transitivePeerDependencies:
+ - supports-color
+
walk-up-path@4.0.0: {}
walker@1.0.8:
@@ -16725,6 +18914,12 @@ snapshots:
webidl-conversions@8.0.1: {}
+ webpack-virtual-modules@0.6.2: {}
+
+ whatwg-encoding@2.0.0:
+ dependencies:
+ iconv-lite: 0.6.3
+
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
@@ -16800,6 +18995,10 @@ snapshots:
gopd: 1.2.0
has-tostringtag: 1.0.2
+ which@1.3.1:
+ dependencies:
+ isexe: 2.0.0
+
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -16860,12 +19059,18 @@ snapshots:
ws@8.19.0: {}
+ wsl-utils@0.1.0:
+ dependencies:
+ is-wsl: 3.1.0
+
xdg-basedir@4.0.0: {}
xdg-basedir@5.1.0: {}
xml-name-validator@5.0.0: {}
+ xml@1.0.1: {}
+
xmlchars@2.2.0: {}
xstate@5.26.0: {}
@@ -16919,8 +19124,12 @@ snapshots:
yocto-queue@1.2.2: {}
+ yoctocolors-cjs@2.1.3: {}
+
yoctocolors@2.1.2: {}
+ yoga-layout@3.2.1: {}
+
zip-stream@6.0.1:
dependencies:
archiver-utils: 5.0.2
diff --git a/public/og/README.md b/public/og/README.md
deleted file mode 100644
index 07e2e7bb..00000000
--- a/public/og/README.md
+++ /dev/null
@@ -1,56 +0,0 @@
-# Static Open Graph Images
-
-## Status: Legacy
-
-These static OG images are maintained for specific use cases but are **no longer used for general social media previews**.
-
-## Files
-
-- `base.png` - Static conference logo image
-- `base.svg` - SVG version of static conference logo
-
-## Current Usage
-
-These files are currently referenced in:
-
-- **OpenBadges Issuer Profile** ([/src/lib/badge/config.ts](../../src/lib/badge/config.ts) and [/src/app/api/badge/issuer/route.ts](../../src/app/api/badge/issuer/route.ts))
- - Used as the issuer organization image in OpenBadges 3.0 metadata
- - Appropriate for this use case as it represents the issuing organization (Cloud Native Days Norway)
-
-## Dynamic OG Images
-
-All pages now use **dynamic Open Graph images** generated at build/request time:
-
-- **Homepage** (`/opengraph-image`) - Conference-specific with title, tagline, dates, location, logo
-- **Program** (`/program/opengraph-image`) - Program page with conference context
-- **CFP** (`/cfp/opengraph-image`) - Call for Papers with CFP deadline
-- **Sponsor** (`/sponsor/opengraph-image`) - Sponsorship information
-- **Tickets** (`/tickets/opengraph-image`) - Ticket information
-- **Speaker Profiles** (`/speaker/[slug]/opengraph-image`) - Individual speaker cards with talk details
-- **Badges** (`/badge/[badgeId]/opengraph-image`) - OpenBadges verification previews
-
-These dynamic images:
-
-- Automatically update when conference data changes in Sanity CMS
-- Support multi-tenant architecture (different images per domain/conference)
-- Use consistent branding from [/docs/BRANDING.md](../../docs/BRANDING.md)
-- Include conference-specific logos, dates, locations, and content
-
-## Implementation
-
-Dynamic OG images use:
-
-- `@vercel/og` with Edge Runtime
-- Shared utilities in [/src/lib/og/](../../src/lib/og/)
-- Conference data from Sanity CMS via `getConferenceForDomain()`
-- Cache strategy: `cacheLife('hours')` with page-specific cache tags
-
-## Migration Notes
-
-If you need to update the static badge issuer image:
-
-1. Replace `base.png` and `base.svg` in this directory
-2. No code changes required (references are stable)
-3. Consider whether a dynamic solution would be more appropriate
-
-For all other social preview use cases, use the dynamic OG image system.
diff --git a/sanity/schema.ts b/sanity/schema.ts
index 6d6bc6bd..9dfb9226 100644
--- a/sanity/schema.ts
+++ b/sanity/schema.ts
@@ -3,6 +3,7 @@ import { type SchemaTypeDefinition } from 'sanity'
import { fileAttachment, urlAttachment } from './schemaTypes/attachment'
import blockContent from './schemaTypes/blockContent'
import conference from './schemaTypes/conference'
+import contractTemplate from './schemaTypes/contractTemplate'
import coSpeakerInvitation from './schemaTypes/coSpeakerInvitation'
import dashboardConfig from './schemaTypes/dashboardConfig'
import dataProcessingConsent from './schemaTypes/dataProcessingConsent'
@@ -57,5 +58,6 @@ export const schema: { types: SchemaTypeDefinition[] } = {
sponsorForConference,
sponsorActivity,
sponsorEmailTemplate,
+ contractTemplate,
],
}
diff --git a/sanity/schemaTypes/conference.ts b/sanity/schemaTypes/conference.ts
index 2fa2f4ac..a9055c47 100644
--- a/sanity/schemaTypes/conference.ts
+++ b/sanity/schemaTypes/conference.ts
@@ -81,6 +81,22 @@ export default defineType({
fieldset: 'basicInfo',
validation: (Rule) => Rule.required(),
}),
+ defineField({
+ name: 'organizerOrgNumber',
+ title: 'Organizer Org Number',
+ type: 'string',
+ fieldset: 'basicInfo',
+ description:
+ 'Organization number of the organizer (used in contracts and invoices)',
+ }),
+ defineField({
+ name: 'organizerAddress',
+ title: 'Organizer Address',
+ type: 'string',
+ fieldset: 'basicInfo',
+ description:
+ 'Registered address of the organizer (used in contracts and invoices)',
+ }),
defineField({
name: 'city',
title: 'City',
diff --git a/sanity/schemaTypes/contractTemplate.ts b/sanity/schemaTypes/contractTemplate.ts
new file mode 100644
index 00000000..84320d45
--- /dev/null
+++ b/sanity/schemaTypes/contractTemplate.ts
@@ -0,0 +1,163 @@
+import { defineField, defineType } from 'sanity'
+import { CURRENCY_OPTIONS } from './constants'
+
+export default defineType({
+ name: 'contractTemplate',
+ title: 'Contract Template',
+ type: 'document',
+ fields: [
+ defineField({
+ name: 'title',
+ title: 'Title',
+ type: 'string',
+ description: 'Internal name for this contract template',
+ validation: (Rule) => Rule.required(),
+ }),
+ defineField({
+ name: 'conference',
+ title: 'Conference',
+ type: 'reference',
+ to: [{ type: 'conference' }],
+ description: 'Conference this template belongs to',
+ validation: (Rule) => Rule.required(),
+ }),
+ defineField({
+ name: 'tier',
+ title: 'Default Tier',
+ type: 'reference',
+ to: [{ type: 'sponsorTier' }],
+ description:
+ 'Optional: associate this template with a specific sponsor tier',
+ options: {
+ filter: ({ document }: { document: any }) => {
+ if (!document?.conference?._ref) return {}
+
+ return {
+ filter: 'conference._ref == $conferenceId',
+ params: { conferenceId: document.conference._ref },
+ }
+ },
+ },
+ }),
+ defineField({
+ name: 'language',
+ title: 'Language',
+ type: 'string',
+ options: {
+ list: [
+ { title: 'Norwegian (BokmĂĄl)', value: 'nb' },
+ { title: 'English', value: 'en' },
+ ],
+ layout: 'radio',
+ },
+ initialValue: 'nb',
+ validation: (Rule) => Rule.required(),
+ }),
+ defineField({
+ name: 'currency',
+ title: 'Default Currency',
+ type: 'string',
+ options: {
+ list: [...CURRENCY_OPTIONS],
+ layout: 'dropdown',
+ },
+ initialValue: 'NOK',
+ }),
+ defineField({
+ name: 'sections',
+ title: 'Contract Sections',
+ type: 'array',
+ description:
+ 'Ordered list of sections that make up the contract document',
+ of: [
+ {
+ type: 'object',
+ name: 'contractSection',
+ title: 'Section',
+ fields: [
+ {
+ name: 'heading',
+ title: 'Section Heading',
+ type: 'string',
+ validation: (Rule) => Rule.required(),
+ },
+ {
+ name: 'body',
+ title: 'Section Body',
+ type: 'blockContent',
+ description:
+ 'Use {{{VARIABLE_NAME}}} for dynamic values (e.g. {{{SPONSOR_NAME}}}, {{{TIER_NAME}}}, {{{CONTRACT_VALUE}}})',
+ },
+ ],
+ preview: {
+ select: {
+ title: 'heading',
+ },
+ },
+ },
+ ],
+ validation: (Rule) => Rule.required().min(1),
+ }),
+ defineField({
+ name: 'headerText',
+ title: 'Header Text',
+ type: 'string',
+ description: 'Text shown in the PDF header (e.g. organization name)',
+ initialValue: 'Cloud Native Days Norway',
+ }),
+ defineField({
+ name: 'footerText',
+ title: 'Footer Text',
+ type: 'string',
+ description:
+ 'Text shown in the PDF footer (e.g. org number, contact info)',
+ }),
+ defineField({
+ name: 'terms',
+ title: 'General Terms & Conditions',
+ type: 'blockContent',
+ description:
+ 'General terms and conditions included as Appendix 1 in the contract PDF and displayed on the public sponsor terms page',
+ }),
+ defineField({
+ name: 'isDefault',
+ title: 'Default Template',
+ type: 'boolean',
+ description:
+ 'Use this template as the default when no tier-specific template exists',
+ initialValue: false,
+ }),
+ defineField({
+ name: 'isActive',
+ title: 'Active',
+ type: 'boolean',
+ description: 'Whether this template is available for use',
+ initialValue: true,
+ }),
+ ],
+ preview: {
+ select: {
+ title: 'title',
+ conferenceName: 'conference.title',
+ tierName: 'tier.title',
+ language: 'language',
+ isActive: 'isActive',
+ },
+ prepare({ title, conferenceName, tierName, language, isActive }) {
+ const lang = language === 'nb' ? '🇳🇴' : '🇬🇧'
+ const status = isActive === false ? ' (inactive)' : ''
+ const tier = tierName ? ` — ${tierName}` : ''
+ return {
+ title: `${lang} ${title}${status}`,
+ subtitle: `${conferenceName || 'No Conference'}${tier}`,
+ }
+ },
+ },
+ orderings: [
+ {
+ title: 'Title',
+ name: 'title',
+ by: [{ field: 'title', direction: 'asc' }],
+ },
+ ],
+})
diff --git a/sanity/schemaTypes/sponsor.ts b/sanity/schemaTypes/sponsor.ts
index 49a58cf2..0ba037f4 100644
--- a/sanity/schemaTypes/sponsor.ts
+++ b/sanity/schemaTypes/sponsor.ts
@@ -43,6 +43,20 @@ export default defineType({
)
},
}),
+ defineField({
+ name: 'address',
+ title: 'Address',
+ type: 'string',
+ description: 'Registered company address (used in contracts)',
+ hidden: ({ currentUser }) => {
+ return !(
+ currentUser != null &&
+ currentUser.roles.find(
+ ({ name }) => name === 'administrator' || name === 'editor',
+ )
+ )
+ },
+ }),
],
preview: {
select: {
diff --git a/sanity/schemaTypes/sponsorForConference.ts b/sanity/schemaTypes/sponsorForConference.ts
index 04988313..92355ffe 100644
--- a/sanity/schemaTypes/sponsorForConference.ts
+++ b/sanity/schemaTypes/sponsorForConference.ts
@@ -87,6 +87,68 @@ export default defineType({
initialValue: 'none',
validation: (Rule) => Rule.required(),
}),
+ defineField({
+ name: 'signatureStatus',
+ title: 'Signature Status',
+ type: 'string',
+ description: 'Digital signature status from e-signing provider',
+ options: {
+ list: [
+ { title: 'Not Started', value: 'not-started' },
+ { title: 'Pending', value: 'pending' },
+ { title: 'Signed', value: 'signed' },
+ { title: 'Rejected', value: 'rejected' },
+ { title: 'Expired', value: 'expired' },
+ ],
+ layout: 'dropdown',
+ },
+ initialValue: 'not-started',
+ }),
+ defineField({
+ name: 'signatureId',
+ title: 'Signature ID',
+ type: 'string',
+ description: 'External ID from e-signing provider (e.g. Posten.no)',
+ readOnly: true,
+ }),
+ defineField({
+ name: 'signerEmail',
+ title: 'Signer Email',
+ type: 'string',
+ description: 'Email of the person who should sign the contract',
+ }),
+ defineField({
+ name: 'contractSentAt',
+ title: 'Contract Sent Date',
+ type: 'datetime',
+ description: 'When the contract was sent for signing',
+ readOnly: true,
+ }),
+ defineField({
+ name: 'contractDocument',
+ title: 'Contract Document',
+ type: 'file',
+ description: 'Generated PDF contract document',
+ options: {
+ accept: 'application/pdf',
+ },
+ }),
+ defineField({
+ name: 'reminderCount',
+ title: 'Reminder Count',
+ type: 'number',
+ description: 'Number of contract signing reminders sent',
+ initialValue: 0,
+ readOnly: true,
+ validation: (Rule) => Rule.min(0),
+ }),
+ defineField({
+ name: 'contractTemplate',
+ title: 'Contract Template',
+ type: 'reference',
+ to: [{ type: 'contractTemplate' }],
+ description: 'Template used to generate the contract',
+ }),
defineField({
name: 'status',
title: 'Status',
@@ -317,6 +379,28 @@ export default defineType({
)
},
}),
+ defineField({
+ name: 'onboardingToken',
+ title: 'Onboarding Token',
+ type: 'string',
+ description: 'Unique token for sponsor self-service onboarding portal',
+ readOnly: true,
+ }),
+ defineField({
+ name: 'onboardingComplete',
+ title: 'Onboarding Complete',
+ type: 'boolean',
+ description: 'Whether the sponsor has completed onboarding',
+ initialValue: false,
+ readOnly: true,
+ }),
+ defineField({
+ name: 'onboardingCompletedAt',
+ title: 'Onboarding Completed At',
+ type: 'datetime',
+ description: 'When the sponsor completed onboarding',
+ readOnly: true,
+ }),
],
preview: {
select: {
diff --git a/scripts/test-storybook.sh b/scripts/test-storybook.sh
new file mode 100755
index 00000000..4fe45ce1
--- /dev/null
+++ b/scripts/test-storybook.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+# Script to test all Storybook stories
+set -e
+
+echo "Starting Storybook server..."
+pnpm storybook --quiet --ci &
+STORYBOOK_PID=$!
+
+# Wait for Storybook to be ready
+echo "Waiting for Storybook to be ready..."
+max_attempts=30
+attempt=0
+until curl -s http://localhost:6006 > /dev/null; do
+ attempt=$((attempt + 1))
+ if [ $attempt -eq $max_attempts ]; then
+ echo "Storybook failed to start after $max_attempts attempts"
+ kill $STORYBOOK_PID 2>/dev/null || true
+ exit 1
+ fi
+ sleep 1
+done
+
+echo "Storybook is ready, running tests..."
+pnpm storybook:test --maxWorkers=2 || TEST_EXIT_CODE=$?
+
+# Cleanup
+echo "Stopping Storybook server..."
+kill $STORYBOOK_PID 2>/dev/null || true
+
+exit ${TEST_EXIT_CODE:-0}
diff --git a/scripts/validate-stories.ts b/scripts/validate-stories.ts
new file mode 100644
index 00000000..53d56972
--- /dev/null
+++ b/scripts/validate-stories.ts
@@ -0,0 +1,124 @@
+#!/usr/bin/env tsx
+/**
+ * Validate that all Storybook story files can be imported without errors
+ */
+
+import { glob } from 'glob'
+import { pathToFileURL } from 'node:url'
+import path from 'node:path'
+
+async function validateStories() {
+ console.log('🔍 Finding all story files...\n')
+
+ const storyFiles = await glob('src/**/*.stories.{ts,tsx}', {
+ cwd: process.cwd(),
+ absolute: true,
+ })
+
+ console.log(`Found ${storyFiles.length} story files\n`)
+
+ const errors: Array<{ file: string; error: Error }> = []
+ const warnings: Array<{ file: string; message: string }> = []
+
+ for (const file of storyFiles) {
+ const relativePath = path.relative(process.cwd(), file)
+ process.stdout.write(`Testing ${relativePath}... `)
+
+ try {
+ // Import the story file
+ const fileUrl = pathToFileURL(file).href
+ const storyModule = await import(fileUrl)
+
+ // Check if it has a default export (meta)
+ if (!storyModule.default) {
+ warnings.push({
+ file: relativePath,
+ message: 'Missing default export (story meta)',
+ })
+ console.log('⚠️ WARNING: Missing meta')
+ continue
+ }
+
+ // Check if meta has required properties
+ const meta = storyModule.default
+ if (!meta.title) {
+ warnings.push({
+ file: relativePath,
+ message: 'Meta missing title property',
+ })
+ console.log('⚠️ WARNING: Missing title')
+ continue
+ }
+
+ // Count stories (exports that aren't default or Meta)
+ const storyExports = Object.keys(storyModule).filter(
+ (key) => key !== 'default' && key !== '__esModule',
+ )
+
+ if (storyExports.length === 0) {
+ warnings.push({
+ file: relativePath,
+ message: 'No stories exported',
+ })
+ console.log(`⚠️ WARNING: No stories`)
+ continue
+ }
+
+ console.log(`âś… ${storyExports.length} stories`)
+ } catch (error) {
+ errors.push({
+ file: relativePath,
+ error: error as Error,
+ })
+ console.log('❌ ERROR')
+ }
+ }
+
+ // Print summary
+ console.log('\n' + '='.repeat(60))
+ console.log('SUMMARY')
+ console.log('='.repeat(60) + '\n')
+
+ console.log(`Total files: ${storyFiles.length}`)
+ console.log(
+ `âś… Passed: ${storyFiles.length - errors.length - warnings.length}`,
+ )
+ console.log(`⚠️ Warnings: ${warnings.length}`)
+ console.log(`❌ Errors: ${errors.length}`)
+
+ if (warnings.length > 0) {
+ console.log('\n' + '='.repeat(60))
+ console.log('WARNINGS')
+ console.log('='.repeat(60) + '\n')
+
+ for (const { file, message } of warnings) {
+ console.log(`⚠️ ${file}`)
+ console.log(` ${message}\n`)
+ }
+ }
+
+ if (errors.length > 0) {
+ console.log('\n' + '='.repeat(60))
+ console.log('ERRORS')
+ console.log('='.repeat(60) + '\n')
+
+ for (const { file, error } of errors) {
+ console.log(`❌ ${file}`)
+ console.log(` ${error.message}`)
+ if (error.stack) {
+ const stackLines = error.stack.split('\n').slice(1, 4)
+ stackLines.forEach((line) => console.log(` ${line.trim()}`))
+ }
+ console.log()
+ }
+
+ process.exit(1)
+ }
+
+ console.log('\n✨ All stories validated successfully!\n')
+}
+
+validateStories().catch((error) => {
+ console.error('Fatal error:', error)
+ process.exit(1)
+})
diff --git a/src/__mocks__/sponsor-data.ts b/src/__mocks__/sponsor-data.ts
new file mode 100644
index 00000000..21782a2e
--- /dev/null
+++ b/src/__mocks__/sponsor-data.ts
@@ -0,0 +1,182 @@
+import type {
+ SponsorForConferenceExpanded,
+ SponsorTag,
+ SponsorStatus,
+ ContractStatus,
+ InvoiceStatus,
+ SignatureStatus,
+} from '@/lib/sponsor-crm/types'
+import type {
+ ContactPerson,
+ BillingInfo,
+ SponsorTier,
+} from '@/lib/sponsor/types'
+import type {
+ ContractReadiness,
+ MissingField,
+} from '@/lib/sponsor-crm/contract-readiness'
+
+/**
+ * Mock data factories for sponsor-related components in Storybook
+ */
+
+export function mockContactPerson(
+ overrides: Partial = {},
+): ContactPerson {
+ return {
+ _key: `contact-${Math.random().toString(36).substr(2, 9)}`,
+ name: 'Jane Smith',
+ email: 'jane.smith@example.com',
+ phone: '+47 12 34 56 78',
+ role: 'Marketing Manager',
+ isPrimary: false,
+ ...overrides,
+ }
+}
+
+export function mockBillingInfo(
+ overrides: Partial = {},
+): BillingInfo {
+ return {
+ email: 'billing@example.com',
+ reference: 'PO-2026-001',
+ comments: 'Invoice quarterly',
+ ...overrides,
+ }
+}
+
+export function mockSponsorTier(
+ overrides: Partial = {},
+): SponsorTier {
+ return {
+ _id: 'tier-ingress',
+ _createdAt: '2026-01-01T00:00:00Z',
+ _updatedAt: '2026-01-01T00:00:00Z',
+ title: 'Ingress',
+ tagline: 'Premium sponsorship tier',
+ tierType: 'standard' as const,
+ price: [
+ {
+ _key: 'price-nok',
+ amount: 100000,
+ currency: 'NOK',
+ },
+ ],
+ soldOut: false,
+ mostPopular: false,
+ ...overrides,
+ } as SponsorTier
+}
+
+export function mockSponsor(
+ overrides: Partial = {},
+): SponsorForConferenceExpanded {
+ return {
+ _id: 'sfc-123',
+ _createdAt: '2026-01-15T10:00:00Z',
+ _updatedAt: '2026-02-10T14:30:00Z',
+ sponsor: {
+ _id: 'sponsor-123',
+ name: 'Acme Corporation',
+ website: 'https://acme.example.com',
+ logo: '... ',
+ logoBright: '... ',
+ orgNumber: '123456789',
+ address: 'Tech Street 42, 5020 Bergen',
+ },
+ conference: {
+ _id: 'conf-2026',
+ title: 'Cloud Native Days Norway 2026',
+ organizer: 'Cloud Native Bergen',
+ organizerOrgNumber: '987654321',
+ organizerAddress: 'Event Plaza 1, 5003 Bergen',
+ city: 'Bergen',
+ venueName: 'Bergen Conference Center',
+ venueAddress: 'Conference Way 10, 5010 Bergen',
+ startDate: '2026-06-10',
+ endDate: '2026-06-11',
+ sponsorEmail: 'sponsor@cloudnativedays.no',
+ },
+ tier: mockSponsorTier() as SponsorTier & {
+ tierType: 'standard' | 'special'
+ },
+ addons: [],
+ contractStatus: 'verbal-agreement' as ContractStatus,
+ signatureStatus: 'not-started' as SignatureStatus,
+ status: 'negotiating' as SponsorStatus,
+ contractValue: 100000,
+ contractCurrency: 'NOK',
+ invoiceStatus: 'not-sent' as InvoiceStatus,
+ contactPersons: [mockContactPerson({ isPrimary: true })],
+ billing: mockBillingInfo(),
+ tags: ['warm-lead', 'returning-sponsor'] as SponsorTag[],
+ notes: 'Very interested in premium package',
+ onboardingComplete: false,
+ ...overrides,
+ }
+}
+
+export function mockReadinessReady(): ContractReadiness {
+ return {
+ ready: true,
+ missing: [],
+ }
+}
+
+export function mockReadinessMissing(
+ fields?: MissingField[],
+): ContractReadiness {
+ const defaultMissing: MissingField[] = fields || [
+ {
+ field: 'sponsor.orgNumber',
+ label: 'Organization number',
+ source: 'sponsor',
+ },
+ { field: 'sponsor.address', label: 'Address', source: 'sponsor' },
+ {
+ field: 'conference.organizerOrgNumber',
+ label: 'Organizer org number',
+ source: 'organizer',
+ },
+ { field: 'tier', label: 'Sponsor tier', source: 'pipeline' },
+ ]
+
+ return {
+ ready: false,
+ missing: defaultMissing,
+ }
+}
+
+export const mockSponsors = {
+ prospect: mockSponsor({
+ status: 'prospect',
+ contractStatus: 'none',
+ tags: ['cold-outreach'],
+ }),
+ contacted: mockSponsor({
+ status: 'contacted',
+ contractStatus: 'none',
+ contactInitiatedAt: '2026-01-20T09:00:00Z',
+ tags: ['warm-lead'],
+ }),
+ negotiating: mockSponsor({
+ status: 'negotiating',
+ contractStatus: 'verbal-agreement',
+ tags: ['warm-lead', 'high-priority'],
+ }),
+ closedWon: mockSponsor({
+ status: 'closed-won',
+ contractStatus: 'contract-signed',
+ signatureStatus: 'signed',
+ invoiceStatus: 'paid',
+ contractSignedAt: '2026-02-01T12:00:00Z',
+ invoicePaidAt: '2026-02-15T10:30:00Z',
+ tags: ['returning-sponsor'],
+ }),
+ closedLost: mockSponsor({
+ status: 'closed-lost',
+ contractStatus: 'none',
+ tags: ['previously-declined'],
+ notes: 'Budget constraints for 2026',
+ }),
+}
diff --git a/src/app/(admin)/admin/marketing/page.tsx b/src/app/(admin)/admin/marketing/page.tsx
index b40be92a..5efc1b96 100644
--- a/src/app/(admin)/admin/marketing/page.tsx
+++ b/src/app/(admin)/admin/marketing/page.tsx
@@ -6,7 +6,7 @@ import { formatConferenceDateLong } from '@/lib/time'
import { Status } from '@/lib/proposal/types'
import { SpeakerShare } from '@/components/SpeakerShare'
import { SponsorThankYou } from '@/components/SponsorThankYou'
-import { DownloadSpeakerImage } from '@/components/branding/DownloadSpeakerImage'
+import { DownloadableImage } from '@/components/common/DownloadableImage'
import { AdminPageHeader } from '@/components/admin'
import { MarketingTabs } from '@/components/admin/MarketingTabs'
import { MemeGeneratorWithDownload } from '@/components/admin/MemeGeneratorWithDownload'
@@ -328,7 +328,7 @@ export default async function MarketingPage() {
{/* Conference Promotional Tab */}
-
+
{/* Photo Gallery Tab */}
@@ -456,7 +456,7 @@ export default async function MarketingPage() {
{speakersWithTalks.map(({ speaker, talks }) => (
-
+
))}
@@ -512,7 +512,7 @@ export default async function MarketingPage() {
return (
-
+
)
})}
diff --git a/src/app/(admin)/admin/sponsors/contracts/[id]/page.tsx b/src/app/(admin)/admin/sponsors/contracts/[id]/page.tsx
new file mode 100644
index 00000000..c90977a5
--- /dev/null
+++ b/src/app/(admin)/admin/sponsors/contracts/[id]/page.tsx
@@ -0,0 +1,34 @@
+import { getConferenceForCurrentDomain } from '@/lib/conference/sanity'
+import { ErrorDisplay } from '@/components/admin'
+import { ContractTemplateEditorPage } from '@/components/admin/sponsor/ContractTemplateEditorPage'
+
+export default async function EditContractTemplatePage({
+ params,
+}: {
+ params: Promise<{ id: string }>
+}) {
+ const { id } = await params
+
+ const { conference, error: conferenceError } =
+ await getConferenceForCurrentDomain({})
+
+ if (conferenceError) {
+ return (
+
+ )
+ }
+
+ if (!conference) {
+ return (
+
+ )
+ }
+
+ return
+}
diff --git a/src/app/(admin)/admin/sponsors/contracts/new/page.tsx b/src/app/(admin)/admin/sponsors/contracts/new/page.tsx
new file mode 100644
index 00000000..7e69d813
--- /dev/null
+++ b/src/app/(admin)/admin/sponsors/contracts/new/page.tsx
@@ -0,0 +1,28 @@
+import { getConferenceForCurrentDomain } from '@/lib/conference/sanity'
+import { ErrorDisplay } from '@/components/admin'
+import { ContractTemplateEditorPage } from '@/components/admin/sponsor/ContractTemplateEditorPage'
+
+export default async function NewContractTemplatePage() {
+ const { conference, error: conferenceError } =
+ await getConferenceForCurrentDomain({})
+
+ if (conferenceError) {
+ return (
+
+ )
+ }
+
+ if (!conference) {
+ return (
+
+ )
+ }
+
+ return
+}
diff --git a/src/app/(admin)/admin/sponsors/contracts/page.tsx b/src/app/(admin)/admin/sponsors/contracts/page.tsx
new file mode 100644
index 00000000..c75c992d
--- /dev/null
+++ b/src/app/(admin)/admin/sponsors/contracts/page.tsx
@@ -0,0 +1,28 @@
+import { getConferenceForCurrentDomain } from '@/lib/conference/sanity'
+import { ErrorDisplay } from '@/components/admin'
+import { ContractTemplateListPage } from '@/components/admin/sponsor/ContractTemplateListPage'
+
+export default async function AdminContractTemplates() {
+ const { conference, error: conferenceError } =
+ await getConferenceForCurrentDomain({})
+
+ if (conferenceError) {
+ return (
+
+ )
+ }
+
+ if (!conference) {
+ return (
+
+ )
+ }
+
+ return
+}
diff --git a/src/app/(main)/branding/page.tsx b/src/app/(main)/branding/page.tsx
deleted file mode 100644
index c23eddc3..00000000
--- a/src/app/(main)/branding/page.tsx
+++ /dev/null
@@ -1,4351 +0,0 @@
-import { Metadata } from 'next'
-import { Container } from '@/components/Container'
-import { DiamondIcon } from '@/components/DiamondIcon'
-import Image from 'next/image'
-import { cacheLife } from 'next/cache'
-
-// Import cloud native icons for the pattern examples
-import KubernetesIcon from '@/images/icons/kubernetes-icon-white.svg'
-import PrometheusIcon from '@/images/icons/prometheus-icon-white.svg'
-import IstioIcon from '@/images/icons/istio-icon-white.svg'
-import HelmIcon from '@/images/icons/helm-icon-white.svg'
-
-import {
- CloudIcon,
- ServerIcon,
- CubeIcon,
- CircleStackIcon,
- CommandLineIcon,
- ShieldCheckIcon,
- ChartBarIcon,
- CogIcon,
- BoltIcon,
- GlobeAltIcon,
- LinkIcon,
- ArrowPathIcon,
- QueueListIcon,
- WrenchScrewdriverIcon,
- EyeIcon,
- RocketLaunchIcon,
- LightBulbIcon,
-} from '@heroicons/react/24/outline'
-import {
- CloudIcon as CloudIconSolid,
- ServerIcon as ServerIconSolid,
- ShieldCheckIcon as ShieldCheckIconSolid,
-} from '@heroicons/react/24/solid'
-
-// Import branding components and data
-import {
- ColorSwatch,
- TypographyShowcase,
- IconShowcase,
- InteractivePatternPreview,
- BrandingHeroSection,
- BrandingExampleHeroSection,
- PatternExample,
- ButtonShowcase,
- DownloadSpeakerImage,
- ExpandableEmailTemplate,
-} from '@/components/branding'
-import { colorPalette, typography } from '@/lib/branding/data'
-
-import { TalkPromotionCard } from '@/components/TalkPromotionCard'
-import { SpeakerPromotionCard } from '@/components/SpeakerPromotionCard'
-import { SpeakerShare } from '@/components/SpeakerShare'
-import { TypewriterEffect } from '@/components/TypewriterEffect'
-import {
- ProposalAcceptTemplate,
- ProposalRejectTemplate,
- BroadcastTemplate,
- BaseEmailTemplate,
- CoSpeakerInvitationTemplate,
- CoSpeakerResponseTemplate,
-} from '@/components/email'
-import { portableTextToHTML } from '@/lib/email/portableTextToHTML'
-import { CallToAction } from '@/components/CallToAction'
-import {
- Format,
- Level,
- ProposalExisting,
- Status,
- Language,
- Audience,
-} from '@/lib/proposal/types'
-import { SpeakerWithTalks } from '@/lib/speaker/types'
-import { getConferenceForDomain } from '@/lib/conference/sanity'
-import { headers } from 'next/headers'
-
-export const metadata: Metadata = {
- title: 'Brand Guidelines - Cloud Native Days',
- description:
- 'Brand guidelines and design system for Cloud Native Days conferences',
-}
-
-async function CachedBrandingContent({ domain }: { domain: string }) {
- 'use cache'
- cacheLife('max')
-
- const conferenceData = await getConferenceForDomain(domain, {
- featuredSpeakers: true,
- organizers: true,
- })
- const { conference } = conferenceData
-
- // Use featured speakers if available, otherwise fall back to organizers
- const displaySpeakers =
- conference?.featuredSpeakers && conference.featuredSpeakers.length > 0
- ? conference.featuredSpeakers
- : (conference?.organizers ?? [])
-
- // Helper to create mock ProposalExisting objects for design examples
- function mockTalk(params: {
- id: string
- title: string
- format: Format
- level?: Level
- description?: string
- speakerNames?: string[]
- }): ProposalExisting {
- const now = new Date().toISOString()
- const speakers = (params.speakerNames || ['Example Speaker']).map(
- (name, idx) => ({
- _id: `sp-${params.id}-${idx}`,
- _rev: '1',
- _createdAt: now,
- _updatedAt: now,
- name,
- email: `${name.toLowerCase().replace(/[^a-z]/g, '')}@example.com`,
- title: 'Engineer',
- }),
- ) as SpeakerWithTalks[]
- return {
- _id: params.id,
- _rev: '1',
- _type: 'proposal',
- _createdAt: now,
- _updatedAt: now,
- status: Status.accepted,
- title: params.title,
- description: params.description
- ? [
- {
- _type: 'block',
- _key: `b-${params.id}`,
- style: 'normal',
- children: [
- {
- _type: 'span',
- _key: `s-${params.id}`,
- text: params.description,
- marks: [],
- },
- ],
- markDefs: [],
- },
- ]
- : [],
- language: Language.english,
- format: params.format,
- level: params.level || Level.intermediate,
- audiences: [Audience.developer],
- outline: '',
- tos: true,
- speakers,
- conference: { _ref: 'conf-mock', _type: 'reference' as const },
- }
- }
-
- return (
-
- {/* Hero Section */}
-
-
- {/* Navigation Menu */}
-
-
-
-
-
-
- {/* Brand Story */}
-
-
-
-
- Our Brand Story
-
-
-
-
- Cloud Native Days embodies the spirit of the global tech
- community: innovative yet grounded, collaborative yet
- independent, modern yet respectful of tradition.
-
-
- Our visual identity draws inspiration from Nordic
- landscapes—the meeting of mountains and sea, the interplay of
- mist and clarity, the harmony of natural and urban elements.
-
-
- We celebrate the "nerdy and proud" developer culture
- while maintaining accessibility and inclusivity for all
- members of our community.
-
-
-
-
- Brand Values
-
-
-
-
-
- Open Source Spirit
-
-
-
-
-
- Technical Excellence
-
-
-
-
-
- Community First
-
-
-
-
-
- Accessibility & Inclusion
-
-
-
-
-
-
- {/* Design Principles */}
-
-
- Design Principles
-
-
- These principles guide every design decision and ensure our
- brand remains consistent, accessible, and true to our community
- values.
-
-
-
- {[
- {
- title: 'Developer-First',
- description:
- 'Every design choice considers the developer experience and technical audience.',
- icon: CommandLineIcon,
- },
- {
- title: 'Accessible by Design',
- description:
- 'We prioritize accessibility and inclusive design in all brand applications.',
- icon: EyeIcon,
- },
- {
- title: 'Nordic Minimalism',
- description:
- 'Clean, functional design that lets content shine without unnecessary complexity.',
- icon: BoltIcon,
- },
- {
- title: 'Community Driven',
- description:
- 'Our brand reflects the collaborative spirit of the open source community.',
- icon: GlobeAltIcon,
- },
- ].map((principle) => (
-
-
-
- {principle.title}
-
-
- {principle.description}
-
-
- ))}
-
-
-
-
-
-
- {/* Color Palette */}
-
-
-
-
- Color Palette
-
-
- Our colors reflect Nordic natural beauty—from the deep blues of
- Norwegian fjords to the fresh greens of nordic forests, balanced
- with modern tech-inspired accents.
-
-
-
- {Object.entries(colorPalette).map(([category, colors]) => (
-
-
- {category} Colors
-
-
- {colors.map((color) => (
-
- ))}
-
-
- ))}
-
- {/* Background Utilities */}
-
-
- Background Utilities
-
-
- Our background system includes solid colors and gradients that
- work seamlessly across all brand applications and maintain proper
- contrast ratios.
-
-
- {/* Gradient Backgrounds */}
-
-
- Gradient Backgrounds
-
-
-
-
-
-
- Aqua Gradient
-
-
- Primary hero gradient
-
-
- bg-aqua-gradient
-
-
-
-
-
-
-
-
- Brand Gradient
-
-
- Enhanced brand gradient
-
-
- bg-brand-gradient
-
-
-
-
-
-
-
-
- Nordic Gradient
-
-
- Accent gradient
-
-
- bg-nordic-gradient
-
-
-
-
-
-
- {/* Solid Color Backgrounds */}
-
-
- Solid Color Backgrounds
-
-
- {/* Primary Colors */}
-
-
-
- Cloud Blue
-
-
- bg-brand-cloud-blue
-
-
-
-
-
-
- Fresh Green
-
-
- bg-brand-fresh-green
-
-
-
-
-
-
- Nordic Purple
-
-
- bg-brand-nordic-purple
-
-
-
-
-
-
- Sunbeam Yellow
-
-
- bg-brand-sunbeam-yellow
-
-
-
- {/* Neutral Colors */}
-
-
-
- Sky Mist
-
-
- bg-brand-sky-mist
-
-
-
-
-
-
- Glacier White
-
-
- bg-brand-glacier-white
-
-
-
-
-
-
- Slate Gray
-
-
- bg-brand-slate-gray
-
-
-
-
-
-
- Frosted Steel
-
-
- bg-brand-frosted-steel
-
-
-
-
-
- {/* Usage Guidelines */}
-
-
- Background Usage Guidelines
-
-
-
-
- When to Use Gradients
-
-
-
-
- Hero sections and primary call-to-actions
-
-
-
- Section dividers and visual breaks
-
-
-
- Digital badges and highlighting
-
-
-
-
-
- When to Use Solid Colors
-
-
-
-
- Content sections and cards
-
-
-
- UI components and interactive elements
-
-
-
- Status indicators and alerts
-
-
-
-
-
-
-
-
-
- {/* Typography */}
-
-
-
-
- Typography
-
-
- Our typography system balances developer-friendly monospace fonts
- with clean, readable sans-serifs to create a distinctive yet
- accessible visual voice.
-
-
-
-
-
- Primary Fonts (Headings & Branding)
-
-
- {typography.primary.map((font) => (
-
- ))}
-
-
-
-
-
- Secondary Fonts (Body & UI Text)
-
-
- {typography.secondary.map((font) => (
-
- ))}
-
-
-
-
-
- {/* Typewriter Effect */}
-
-
-
-
- Typewriter Effect
-
-
- An accessible animated typing effect for hero taglines. Cycles
- through words with a blinking cursor, respecting user motion
- preferences.
-
-
-
-
- {/* Live Demo */}
-
-
- Live Demo
-
-
-
-
-
-
-
-
- {/* Speed Variations */}
-
-
- Speed Variations
-
-
-
-
- Fast (60ms typing)
-
-
-
-
-
-
-
- Slow (150ms typing)
-
-
-
-
-
-
-
-
- {/* Static (Animation Disabled) */}
-
-
- Animation Disabled
-
-
-
- animation=false (or prefers-reduced-motion)
-
-
-
-
-
-
-
- {/* Accessibility Features */}
-
-
- Accessibility Features
-
-
-
-
-
- Screen readers: Full text always available
- via aria-label and sr-only span
-
-
-
-
-
- SEO: Complete text in DOM from the start
- for crawlers
-
-
-
-
-
- Reduced motion: Automatically respects
- prefers-reduced-motion preference
-
-
-
-
-
- Animation toggle: Can be disabled via
- animation=false prop
-
-
-
-
-
- {/* UX Considerations */}
-
-
- UX Considerations
-
-
-
-
- Keep words short - users don't wait for long sentences
-
-
-
- Don't rely on the effect alone - support with static
- content
-
-
-
- Consider if animation adds value or is just decoration
-
-
-
-
- {/* Code Example */}
-
-
- {/* Auto-detection Note */}
-
-
- Auto-detection in Hero
-
-
- The Hero component automatically uses the typewriter effect when
- the conference tagline starts with “Real ”. This
- allows easy toggling by simply changing the tagline in Sanity
- CMS.
-
-
-
-
-
-
- {/* Icon Library */}
-
-
-
-
- Icon Library
-
-
- A comprehensive set of cloud native and Kubernetes-inspired icons
- designed to align with our brand principles and represent key
- technology concepts.
-
-
-
-
- {/* Platform Icons */}
-
-
- Platform Icons
-
-
-
- }
- usage="Cloud sections, infrastructure topics, platform overviews"
- />
-
- }
- usage="Compute sections, server management, infrastructure diagrams"
- />
-
- }
- usage="Containerization topics, Docker sections, packaging concepts"
- />
-
- }
- usage="Queue management, task processing, workflow systems"
- />
-
-
-
- {/* Data & Storage Icons */}
-
-
- Data & Storage Icons
-
-
-
- }
- usage="Database sections, storage topics, persistence patterns"
- />
-
- }
- usage="Developer tools, CLI sections, terminal operations"
- />
-
- }
- usage="Configuration topics, settings panels, system management"
- />
-
- }
- usage="DevTools, utilities, maintenance, system operations"
- />
-
-
-
- {/* Operations Icons */}
-
-
- Operations Icons
-
-
-
- }
- usage="Security sections, compliance topics, trust and safety"
- />
-
- }
- usage="Observability topics, monitoring sections, analytics dashboards"
- />
-
- }
- usage="Performance topics, speed optimization, rapid deployment"
- />
-
- }
- usage="Observability, monitoring, system insights, visibility"
- />
-
-
-
- {/* Network & Connectivity Icons */}
-
-
- Network & Connectivity Icons
-
-
-
- }
- usage="Global topics, multi-region, international deployment"
- />
-
- }
- usage="Service mesh, microservices communication, API connections"
- />
-
- }
- usage="CI/CD sections, automation topics, workflow management"
- />
-
- }
- usage="Deployment topics, launch processes, go-live activities"
- />
-
-
-
- {/* Icon Styles & Usage */}
-
-
- Icon Styles & Usage
-
-
-
-
-
-
-
- }
- usage="General UI, content sections, navigation, feature lists"
- />
-
-
-
-
-
- }
- usage="Status indicators, CTAs, highlights, success states"
- />
-
-
-
- {/* Usage Guidelines */}
-
-
- Heroicons Usage Guidelines
-
-
-
-
- Sizing & Scale
-
-
-
-
-
- 16px (w-4 h-4) - Inline with text
-
-
-
-
-
- 24px (w-6 h-6) - Standard UI elements
-
-
-
-
-
- 32px (w-8 h-8) - Section headers
-
-
-
-
-
- 48px (w-12 h-12) - Hero sections
-
-
-
-
-
-
- Color Application
-
-
-
- Heroicons inherit text color and work with our full brand
- palette:
-
-
-
-
-
-
-
-
-
- Use brand colors to create visual hierarchy and context
-
-
-
-
-
-
-
- Code Examples
-
-
-
-
- Import from Heroicons:
-
-
- {`import { CloudIcon, ServerIcon } from '@heroicons/react/24/outline'`}
-
-
-
-
- Usage with Tailwind sizing:
-
-
- {` `}
-
-
-
-
-
-
-
-
-
- {/* Cloud Native Pattern System */}
-
-
-
-
- Cloud Native Pattern System
-
-
- Our animated background patterns incorporate authentic cloud
- native project logos with intelligent focus/diffusion effects.
- Smaller icons appear sharp and vibrant (in focus), while larger
- icons become more diffuse and subtle, creating natural visual
- depth that enhances readability and engagement.
-
-
-
-
- {/* Interactive Pattern Preview - Pattern Controls */}
-
-
- Interactive Pattern Preview
-
-
-
-
-
-
- {/* Configuration Guidelines */}
-
-
- Configuration Guidelines
-
-
-
-
-
- Icon Size
-
-
-
-
- Content sections: 15-35px icons
-
-
-
- Hero sections: 25-70px icons
-
-
-
- Background fills: 20-50px icons
-
-
-
-
-
- Icon Count
-
-
-
-
- Subtle: 10-30 icons for content backgrounds
-
-
-
- Balanced: 30-60 icons for hero sections
-
-
-
- Dense: 60-120 icons for dramatic effects
-
-
-
-
-
-
- Focus/Diffusion System
-
-
-
-
- Small icons (20-30px): High opacity, vibrant colors, sharp
- focus
-
-
-
- Medium icons (30-50px): Balanced opacity and slight blur
-
-
-
- Large icons (50-70px): Lower opacity, subtle colors, soft
- blur
-
-
-
-
-
- Adjust opacity (0.08-0.15) based on content readability
-
-
-
- Use slow movement animation for engaging backgrounds
-
-
-
- Disable animation for static contexts or better
- performance
-
-
-
- Combine with gradient backgrounds for optimal contrast
-
-
-
-
-
-
-
- {/* Pattern Elements Section - Moved below main grid */}
-
-
- Pattern Elements
-
-
-
-
-
-
-
- Container Orchestration
-
-
- Kubernetes, containerd, and etcd - the foundation of modern
- container orchestration.
-
-
-
-
-
-
-
-
- Observability & Monitoring
-
-
- Prometheus, Jaeger, and Falco for comprehensive system
- observability and security.
-
-
-
-
-
-
-
-
- Service Mesh & Networking
-
-
- Istio, Envoy, and Cilium for secure, observable
- service-to-service communication.
-
-
-
-
-
-
-
-
- Packaging & GitOps
-
-
- Helm, Argo, and Crossplane for application packaging and
- deployment automation.
-
-
-
-
-
-
-
- {/* Configuration Examples Section */}
-
-
-
-
- Configuration Examples
-
-
- See how different settings create unique visual effects for
- various use cases
-
-
-
-
- {/* Light variant with custom sizing */}
-
-
- {/* Default hero pattern - improved with larger icons */}
-
-
- {/* Dense dramatic pattern */}
-
-
-
- {/* Technical Details */}
-
-
- Focus/Diffusion Technology
-
-
-
-
-
- S
-
-
-
- Small Icons (Sharp Focus)
-
-
- Higher opacity, vibrant colors, no blur. Draw attention as
- foreground elements.
-
-
-
-
-
- M
-
-
-
- Medium Icons (Balanced)
-
-
- Moderate opacity and subtle blur. Provide visual texture
- without distraction.
-
-
-
-
-
- L
-
-
-
- Large Icons (Soft Diffusion)
-
-
- Lower opacity, muted colors, soft blur. Create atmospheric
- background depth.
-
-
-
-
-
-
- {/* Button Showcase */}
-
-
- {/* Hero Examples */}
-
-
-
-
- Hero Examples
-
-
- Hero sections showcase our brand's visual impact through
- compelling combinations of typography, color, and cloud native
- patterns.
-
-
-
-
-
-
-
- {/* Speaker Examples */}
-
-
-
-
- Speaker Examples
-
-
- Showcase conference speakers with flexible, brand-consistent
- layouts. From keynote heroes to compact grids, these examples
- demonstrate various ways to highlight our community experts using
- real conference data.
-
-
-
-
- {/* Featured Speaker */}
- {displaySpeakers.length > 0 && (
-
-
-
- Featured Speaker
-
-
- Streamlined presentation for featured speakers with
- essential information and clean visual design. Perfect for
- homepage highlights and key announcements.
-
-
-
-
-
- )}
-
- {/* Three Featured Speakers */}
- {displaySpeakers.length >= 3 && (
-
-
-
- Three Featured Speakers
-
-
- Perfect for highlighting key speakers with balanced visual
- weight. Ideal for homepage features and conference
- announcements.
-
-
-
-
- {displaySpeakers.slice(0, 3).map((speaker) => (
-
- ))}
-
-
- )}
-
- {/* Compact Speaker List */}
- {displaySpeakers.length >= 4 && (
-
-
-
- Compact Speaker List
-
-
- Space-efficient format for agenda pages and speaker
- directories. Shows essential information with talk details
- prominently featured.
-
-
-
-
- {displaySpeakers.slice(0, 6).map((speaker, index) => (
-
- ))}
-
-
- )}
-
- {/* Speaker Share Component Showcase */}
- {displaySpeakers.length >= 2 && (
-
-
-
- Speaker Share Component Showcase
-
-
- The SpeakerShare component creates branded social media
- cards that speakers can use to promote their participation.
- Features QR codes for easy profile access, responsive
- design, and optional Cloud Native pattern backgrounds.
-
-
-
-
-
- Interactive Download Feature!
-
-
- Click “Download as PNG” below any speaker card
- to save high-quality social media images. The download may
- take a few seconds to process as it waits for all content
- (including QR codes) to load properly.
-
-
-
-
- {/* Full Size Variants - Speaker Share vs Speaker Spotlight */}
-
-
- Component Variants (Full Size)
-
-
- Compare the two main variants: speaker-share for speakers to
- promote themselves, and speaker-spotlight for conference
- organizers to highlight speakers.
-
-
-
- {/* Speaker Share Variant */}
-
-
-
- Speaker Share
-
-
- “I'm speaking at” message
-
-
-
-
-
-
-
- {/* Speaker Spotlight Variant */}
-
-
-
- Speaker Spotlight
-
-
- “Featured Speaker” message
-
-
-
-
-
-
-
-
-
- {/* Size Variations */}
-
-
- Size Variations & Responsive Design
-
-
- The component uses container queries to maintain perfect
- square proportions and readability across all sizes. Always
- maintains aspect ratio for optimal social media sharing.
-
-
- {/* Large Grid */}
-
-
- Large (For Feature Sections)
-
-
-
-
-
-
-
-
- {/* Small Grid */}
-
-
- Small (For Compact Grids)
-
-
- {displaySpeakers.slice(0, 6).map((speaker, index) => (
-
- ))}
-
-
-
- {/* Extra Small Grid */}
-
-
- Extra Small (Thumbnail Size)
-
-
- {displaySpeakers.slice(0, 10).map((speaker, index) => (
-
- ))}
-
-
-
-
- {/* Technical Features */}
-
-
- Technical Features & Capabilities
-
-
-
-
-
- QR Code Integration
-
-
- • Automatically generated for each speaker
- • Links to speaker profile page
- • High contrast for reliable scanning
- • Optimized for mobile cameras
- • Error correction for damaged prints
-
-
-
-
-
- Responsive Design
-
-
- • Container queries for perfect scaling
- • Fluid typography and spacing
- • Aspect ratio preservation
- • Optimized for social media platforms
- • Works from thumbnails to hero images
-
-
-
-
-
- Cloud Native Pattern
-
-
- • 50+ authentic project logos
- • Intelligent depth layering
- • Smooth animations and movement
- • Performance optimized
- • Works with both variants
-
-
-
-
-
-
- Usage Guidelines
-
-
-
-
- Speaker Share Variant
-
-
-
- • For speakers to share on their own social media
-
-
- • “I'm speaking at” messaging
-
- • Personal branding focus
- • Include speaker's primary talk
-
-
-
-
- Speaker Spotlight Variant
-
-
-
- • For conference organizers to promote speakers
-
- • “Featured Speaker” messaging
- • Conference branding focus
- • Highlight keynote and featured speakers
-
-
-
-
-
-
-
- Development API
-
-
-
-
- Basic Usage
-
-
- {` `}
-
-
-
-
- With Cloud Native Pattern
-
-
- {` `}
-
-
-
-
-
-
- )}
-
- {/* Speaker Directory */}
- {displaySpeakers.length >= 8 && (
-
-
-
- Speaker Directory
-
-
- Comprehensive speaker listing for conference programs and
- attendee guides. Maximizes information density while
- maintaining readability.
-
-
-
-
- {displaySpeakers.slice(0, 10).map((speaker) => (
-
- ))}
-
-
- )}
-
- {/* Mixed Layout Example */}
- {displaySpeakers.length >= 6 && (
-
-
-
- Mixed Layout
-
-
- Combines different speaker presentation styles for dynamic,
- engaging layouts. Perfect for conference websites that need
- visual variety and clear hierarchy.
-
-
-
-
- {/* Featured speaker at top */}
-
-
-
-
- {/* Three card speakers */}
-
- {displaySpeakers.slice(1, 4).map((speaker) => (
-
- ))}
-
-
- {/* Compact list for additional speakers */}
-
- {displaySpeakers.slice(4, 10).map((speaker) => (
-
- ))}
-
-
-
- )}
-
- {/* Design Guidelines */}
-
-
- Speaker Display Guidelines
-
-
-
-
-
- Layout Recommendations
-
-
-
-
-
- Featured Layout: Compact yet impactful
- design for keynote speakers and main announcements
-
-
-
-
-
- 3-Speaker Grid: Perfect for homepage
- highlights and featured speaker sections
-
-
-
-
-
- 6-Speaker Grid: Ideal for complete
- conference lineups and speaker pages
-
-
-
-
-
- Compact Format: Use for agenda pages
- and speaker directories
-
-
-
-
-
- Speaker Share Images: Branded 4:5 ratio
- images for speakers to share “I’m speaking
- at [event]” with QR codes
-
-
-
-
-
- Speaker Spotlight Images: {' '}
- Conference-branded 4:5 ratio promotional images with QR
- codes for speaker promotion
-
-
-
-
-
-
-
- Content Hierarchy
-
-
-
-
-
- Name: Primary focus with largest text
- size
-
-
-
-
-
- Title & Company: Secondary information
- for context
-
-
-
-
-
- Talk Information: Shows format badges
- and talk titles when available
-
-
-
-
-
- Biography: Included in featured and
- card layouts for depth
-
-
-
-
-
- Keynote Badge: Special highlighting for
- keynote speakers
-
-
-
-
-
-
-
-
- QR Code Integration
-
-
-
- • Automatically generated for social image variants
- • Links to full speaker profile and talk details
- • High contrast design for reliable scanning
- • Optimized size for mobile camera scanning
-
-
- • White background with dark QR pattern
- • Rounded corners to match design language
- • Positioned for easy access without UI overlap
-
- • Error correction level ensures scanning reliability
-
-
-
-
-
-
-
- Accessibility & Performance
-
-
-
- • High contrast ratios for all text elements
- • Keyboard navigation support throughout
- • Screen reader optimized alt text and labels
- • Focus indicators on all interactive elements
-
-
- • Optimized images with proper sizing
- • Lazy loading for large speaker grids
- • Responsive layouts for all screen sizes
- • Progressive enhancement for slower connections
-
-
-
-
-
-
-
-
- {/* Talk Examples */}
-
-
-
-
- Talk Examples
-
-
- Talk cards and promotions showcase conference presentations with
- format-specific styling and clear visual hierarchy.
-
-
-
-
- {/* Talk Card Examples */}
-
-
- Talk Card Examples
-
-
- Talk cards showcase conference presentations with
- format-specific styling and branding elements. The
- TalkPromotionCard component features modular architecture with
- guaranteed footer positioning.
-
-
-
- {/* Card Variants */}
-
-
- Card Variants
-
-
-
-
-
-
-
-
-
-
- {/* Format Showcase */}
-
-
- Talk Format Showcase
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Talk Promotion Examples */}
-
-
- Talk Promotion Examples
-
-
- Promotional components for highlighting featured talks and
- driving engagement.
-
-
-
- {/* Banner Promotion */}
-
-
- Banner Promotion
-
-
-
-
- {/* Card and Social Promotions */}
-
-
- Card & Social Promotions
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Component Features and Migration Guide */}
-
-
- Component Features & Architecture
-
-
- The TalkPromotionCard component features a modular
- header-body-footer architecture with guaranteed footer
- positioning using flexbox. This component provides improved
- maintainability and consistent styling across all variants.
-
-
-
- {/* Component Features */}
-
-
- Component Features
-
-
-
-
- Architecture Improvements
-
-
-
-
-
- Modular Structure: Separated
- TalkHeader, TalkBody, and TalkFooter components for
- better maintainability
-
-
-
-
-
- Guaranteed Footer Positioning: Uses
- flexbox with{' '}
-
- mt-auto
- {' '}
- for perfect footer alignment
-
-
-
-
-
- Grid Integration: Works seamlessly
- with{' '}
-
- auto-rows-fr
- {' '}
- for equal height cards
-
-
-
-
-
- TypeScript Support: Full type
- safety with comprehensive prop interfaces
-
-
-
-
-
-
- Variant System
-
-
-
-
-
- Default: Balanced presentation with
- full talk information and description
-
-
-
-
-
- Featured: Enhanced styling with
- larger text and prominent visual treatment
-
-
-
-
-
- Compact: Space-efficient design for
- listings and dense grids
-
-
-
-
-
- Consistent Styling: All variants
- maintain footer alignment and responsive behavior
-
-
-
-
-
-
-
-
- Migration Benefits
-
-
-
-
- Better Maintainability
-
-
- Separated concerns make it easier to update styling,
- add features, and fix bugs
-
-
-
-
- Consistent Layout
-
-
- Flexbox architecture ensures footers always align
- properly regardless of content length
-
-
-
-
- Future-Proof Design
-
-
- Modular structure allows for easy extension and
- customization as requirements evolve
-
-
-
-
-
-
-
-
- {/* Alert Example */}
-
-
- Alert/Notice Example
-
-
-
-
-
-
- Early Bird Special Ending Soon!
-
-
- Register before January 31st to secure your spot at 40%
- off the regular price. Limited seats available for this
- community-driven event.
-
-
-
-
-
-
-
-
-
- {/* Call to Action Examples */}
-
-
-
-
- Call to Action Examples
-
-
- Call to action components drive engagement and conversions across
- the conference website. These reusable components can be
- customized for different contexts while maintaining consistent
- branding and accessibility standards.
-
-
-
-
- {/* Standard Call to Action */}
-
-
- Standard Call to Action
-
-
- The default configuration encourages both speaker submissions
- and ticket reservations with balanced messaging.
-
-
-
-
-
-
- {/* Organizers Context */}
-
-
- Organizers Context
-
-
- When used in organizer-facing contexts, the messaging and button
- styling adapt to focus on community engagement.
-
-
-
-
-
-
- {/* Speaker Focus */}
-
-
- Speaker Submission Focus
-
-
- Configuration that emphasizes speaker submissions while hiding
- ticket reservations for CFP-focused pages.
-
-
-
-
-
-
- {/* Ticket Focus */}
-
-
- Ticket Reservation Focus
-
-
- Configuration that focuses solely on ticket sales when the CFP
- period has ended.
-
-
-
-
-
-
- {/* Custom Messaging */}
-
-
- Custom Messaging
-
-
- Fully customizable title and description for specific campaigns
- or landing pages.
-
-
-
-
-
-
- {/* Component Documentation */}
-
-
- Component Features & Usage
-
-
-
-
- Customizable Props
-
-
-
-
-
-
- isOrganizers
- {' '}
- - Changes messaging and button styles
-
-
-
-
-
-
- title
- {' '}
- - Custom headline text
-
-
-
-
-
-
- description
- {' '}
- - Custom description text
-
-
-
-
-
-
- showSpeakerSubmission
- {' '}
- - Toggle CFP button
-
-
-
-
-
-
- showTicketReservation
- {' '}
- - Toggle ticket button
-
-
-
-
-
-
- Design Features
-
-
-
-
- Gradient background with brand colors
-
-
-
-
- Responsive button layout (stacked to horizontal)
-
-
-
-
- Accessible ARIA labels and semantic markup
-
-
-
- Icons from Heroicons for visual clarity
-
-
-
- Conditional urgency messaging
-
-
-
-
-
-
-
- Usage Guidelines
-
-
-
-
- Placement
-
-
- Use at natural break points in content flow, typically
- after speaker showcases or information sections.
-
-
-
-
- Frequency
-
-
- Limit to 1-2 instances per page to avoid overwhelming
- users while maintaining conversion opportunities.
-
-
-
-
- Context
-
-
- Adapt button visibility and messaging based on page
- purpose (CFP pages vs. general conference info).
-
-
-
-
-
-
-
-
-
- {/* Email Templates */}
-
-
-
-
- Email Templates
-
-
- Professional email templates for all conference communications.
- These templates maintain consistent branding, provide clear
- communication, and ensure accessibility across different email
- clients. Our template system includes automated proposal
- responses, speaker communications, and community updates.
-
-
-
- {/* Template Architecture Overview */}
-
-
- Template Architecture
-
-
-
-
-
-
- 1
-
-
-
- Base Template
-
-
- Foundation layout with consistent branding, responsive
- design, and email client compatibility.
-
-
-
-
-
- 2
-
-
-
- Specialized Templates
-
-
- Purpose-built templates for proposals, speaker
- communications, and community broadcasts.
-
-
-
-
-
- 3
-
-
-
- Automated System
-
-
- Integrated with Resend service for reliable delivery, rate
- limiting, and audience management.
-
-
-
-
-
-
- {/* Proposal Response Templates */}
-
-
- Proposal Response Templates
-
-
- Automated responses for Call for Papers submissions with
- appropriate tone and clear next steps for both accepted and
- rejected proposals.
-
-
-
- {/* Proposal Acceptance Email */}
-
-
-
-
- {/* Proposal Rejection Email */}
-
-
-
-
-
-
- {/* Speaker Communication Templates */}
-
-
- Speaker Communication Templates
-
-
- Direct communication templates for speaker outreach and
- speaker-specific broadcasts with rich content support.
-
-
-
- {/* Speaker Email */}
-
-
- {/* Rich PortableText Content Example */}
-
-
- Dear Demo Testson, Sample McExample, and Fictitious
- Speaker,
-
-
-
- Since you're presenting together, we wanted to
- share some{' '}
-
- important guidelines
- {' '}
- for coordinating your presentation.
-
-
-
- đź“‹ Key Points to Consider
-
-
-
-
- Please coordinate who will handle which sections
-
-
- Ensure your combined presentation fits within the
- allocated{' '}
-
- 45-minute slot
- {' '}
- including Q&A
-
-
- Practice smooth transitions between speakers
-
-
-
-
- We've also arranged for a{' '}
-
- shared rehearsal space
- {' '}
- the day before the conference.
-
-
-
- ⚙️ Technical Requirements
-
-
-
-
- Each speaker should test their laptop with our AV
- equipment
-
-
- Have backup slides on a USB drive
-
-
- Consider using a shared slide deck for seamless
- transitions
-
-
-
-
- Looking forward to your presentation!
-
-
-
- Best regards,
-
- Conference Team
-
-
-
- {/* Proposal Section */}
-
-
- Your Proposal
-
-
- Building Cloud Native Applications with
- Microservices
-
-
- Submitted for {conference.title}
-
-
-
- All speakers:
-
-
-
- Demo Testson
- (demo.testson@fictional-examples.com)
-
-
- Sample McExample
- (sample.mcexample@demo-samples.com)
-
-
- Fictitious Speaker
- (fictitious.speaker@example-demo.com)
-
-
-
-
-
- {/* Action Button */}
-
- >
- ),
- }}
- />
-
-
- {/* Speaker Broadcast Email */}
-
-
-
-
- }
- />
-
-
-
-
- {/* Co-Speaker Templates */}
-
-
- 🚀 Pair Programming for Presentations
-
-
- Just like scaling microservices, great talks scale better with
- collaboration! Our co-speaker invitation system orchestrates
- seamless partnerships between speakers, enabling distributed
- expertise and fault-tolerant presentations. Deploy these templates
- to invite, coordinate, and celebrate speaker collaborations.
-
-
-
- {/* Co-Speaker Invitation Email */}
-
-
-
-
- {/* Co-Speaker Response Email - Accepted */}
-
-
-
-
- {/* Co-Speaker Response Declined Example */}
-
-
-
-
-
- {/* Co-Speaker Template Guidelines */}
-
-
- Co-Speaker Template Guidelines
-
-
-
-
- Security & Token Management
-
-
-
-
-
- Secure Tokens: HMAC-SHA256 signed
- tokens with 14-day expiration for invitation security
-
-
-
-
-
- Email Verification: Case-insensitive
- email matching ensures invitations reach the correct
- recipient
-
-
-
-
-
- One-Time Use: Tokens become invalid
- after response to prevent replay attacks
-
-
-
-
-
- Test Mode: Development environment
- supports testing without sending actual emails
-
-
-
-
-
-
-
- User Experience Design
-
-
-
-
-
- Clear Context: Invitations include full
- proposal details and inviter information
-
-
-
-
-
- Expiration Awareness: Real-time
- expiration checking with countdown displays
-
-
-
-
-
- Response Feedback: Immediate
- confirmation and next steps for both parties
-
-
-
-
-
- Mobile Optimized: Responsive design
- ensures accessibility across all devices
-
-
-
-
-
-
-
-
- Integration with Existing Systems
-
-
-
-
- Authentication
-
-
- Integrates with NextAuth.js for LinkedIn and GitHub OAuth2
- authentication flows
-
-
-
-
- Sanity CMS
-
-
- Co-speaker invitations stored as documents with full audit
- trail and status tracking
-
-
-
-
- Email Service
-
-
- Powered by Resend with retry logic, rate limiting, and
- delivery tracking
-
-
-
-
-
-
-
- {/* General Communication Templates */}
-
-
- General Communication Templates
-
-
- Flexible templates for community announcements and general
- broadcasts with customizable content and unsubscribe management.
-
-
-
- {/* Broadcast Email Template */}
-
-
-
- We're thrilled to announce that early bird tickets
- for {conference.title} are now available!
-
-
-
- 🎟️ Ticket Information
-
-
-
- Early Bird Price: 299 NOK (Regular:
- 499 NOK)
-
-
- Available Until: March 31st, 2025
-
-
- Includes: Full conference access,
- lunch, and networking reception
-
-
-
-
- 🎤 Confirmed Speakers
-
-
- We have an amazing lineup including experts from Google,
- Microsoft, and the Cloud Native Computing Foundation.
-
-
-
-
- }
- />
-
-
- {/* Base Email Template Preview */}
-
-
-
- Thank you for joining our cloud native community! This is
- the foundation template that provides consistent branding
- and structure for all our email communications.
-
-
- This template includes responsive design, social links,
- event details, and accessibility features that work across
- all major email clients.
-
-
-
-
-
-
- {/* Template Comparison Grid */}
-
-
- Template Feature Comparison
-
-
-
-
-
-
-
-
- Template
-
-
-
-
- Purpose
-
-
-
-
- Key Features
-
-
-
-
- Automation
-
-
-
-
-
-
-
-
- Base Template
-
-
-
-
- Foundation for all emails
-
-
-
-
- Responsive layout
- Brand consistency
- Social links
- Event details
-
-
-
-
- Manual
-
-
-
-
-
-
- Proposal Accept
-
-
-
-
- CFP acceptance notifications
-
-
-
-
- Celebration tone
- Confirmation button
- Next steps
- Organizer comments
-
-
-
-
- Automated
-
-
-
-
-
-
- Proposal Reject
-
-
-
-
- CFP rejection notifications
-
-
-
-
- Professional tone
- Constructive feedback
- Future opportunities
- Community connection
-
-
-
-
- Automated
-
-
-
-
-
-
- Single Speaker
-
-
-
-
- Individual speaker outreach
-
-
-
-
- Personal messaging
- Proposal context
- Direct communication
- Action buttons
-
-
-
-
- Admin Tool
-
-
-
-
-
-
- Multi-Speaker
-
-
-
-
- CC all speakers on proposal
-
-
-
-
- CC all participants
- Shared context
- Collaboration focused
- Speaker list display
-
-
-
-
- Admin Tool
-
-
-
-
-
-
- Speaker Broadcast
-
-
-
-
- Speaker group communications
-
-
-
-
- Rich content support
- Group messaging
- Speaker-specific info
- Event updates
-
-
-
-
- Admin Tool
-
-
-
-
-
-
- General Broadcast
-
-
-
-
- Community announcements
-
-
-
-
- HTML content support
- Unsubscribe management
- General audience
- Marketing campaigns
-
-
-
-
- Admin Tool
-
-
-
-
-
-
- Co-Speaker Invitation
-
-
-
-
- Invite speakers to collaborate
-
-
-
-
- Secure token system
- Talk context details
- Accept/decline options
- Professional tone
-
-
-
-
- Automated
-
-
-
-
-
-
- Co-Speaker Response
-
-
-
-
- Notify of invitation response
-
-
-
-
- Accept/decline status
- Professional language
- Next steps guidance
- Relationship preservation
-
-
-
-
- Automated
-
-
-
-
-
-
-
-
- {/* Technical Implementation */}
-
-
- Technical Implementation
-
-
-
-
- Email Service Integration
-
-
- • Resend service for reliable delivery
- • Rate limiting and retry logic
- • Audience management and segmentation
- • Bounce and unsubscribe handling
- • Template validation and testing
-
-
-
-
- Design Standards
-
-
- • Responsive table-based layouts
- • Email client compatibility testing
- • Accessible color contrast ratios
- • Consistent typography hierarchy
- • Brand-aligned visual elements
-
-
-
-
-
-
- Development Guidelines
-
-
-
-
- Component Architecture
-
-
- Modular React components with TypeScript props for type
- safety and reusability across different email contexts.
-
-
-
-
- Testing Strategy
-
-
- Comprehensive testing across email clients, accessibility
- validation, and content rendering verification.
-
-
-
-
- Maintenance
-
-
- Centralized configuration, shared components, and
- documentation for consistent updates and brand evolution.
-
-
-
-
-
-
-
-
- )
-}
-
-export default async function BrandingPage() {
- const headersList = await headers()
- const domain = headersList.get('host') || ''
-
- return
-}
diff --git a/src/app/(main)/sponsor/onboarding/[token]/page.tsx b/src/app/(main)/sponsor/onboarding/[token]/page.tsx
new file mode 100644
index 00000000..808e240e
--- /dev/null
+++ b/src/app/(main)/sponsor/onboarding/[token]/page.tsx
@@ -0,0 +1,17 @@
+import { SponsorOnboardingForm } from '@/components/sponsor/SponsorOnboardingForm'
+
+interface OnboardingPageProps {
+ params: Promise<{ token: string }>
+}
+
+export default async function SponsorOnboardingPage({
+ params,
+}: OnboardingPageProps) {
+ const { token } = await params
+
+ return (
+
+
+
+ )
+}
diff --git a/src/app/(main)/sponsor/terms/page.tsx b/src/app/(main)/sponsor/terms/page.tsx
new file mode 100644
index 00000000..0d9abb82
--- /dev/null
+++ b/src/app/(main)/sponsor/terms/page.tsx
@@ -0,0 +1,113 @@
+import { Container } from '@/components/Container'
+import { Button } from '@/components/Button'
+import { getConferenceForDomain } from '@/lib/conference/sanity'
+import { getTermsForConference } from '@/lib/sponsor-crm/contract-templates'
+import { PortableText } from '@portabletext/react'
+import { portableTextComponents } from '@/lib/portabletext/components'
+import { cacheLife, cacheTag } from 'next/cache'
+import { headers } from 'next/headers'
+
+export const metadata = {
+ title: 'Sponsorship Terms & Conditions - Cloud Native Days Norway',
+ description:
+ 'General terms and conditions for sponsorship of Cloud Native Days Norway',
+}
+
+async function CachedTermsContent({ domain }: { domain: string }) {
+ 'use cache'
+ cacheLife('days')
+ cacheTag('content:sponsor-terms')
+
+ const { conference, error: confError } = await getConferenceForDomain(domain)
+
+ if (confError || !conference) {
+ return (
+
+
+ Unable to load terms
+
+
+ We're experiencing technical difficulties. Please try again
+ later.
+
+
+ Return to Home
+
+
+ )
+ }
+
+ const {
+ terms,
+ conferenceName,
+ error: termsError,
+ } = await getTermsForConference(conference._id)
+
+ if (termsError || !terms) {
+ return (
+
+
+
+
+ Sponsorship Terms & Conditions
+
+
+ Terms and conditions for {conference.title} sponsorship are not
+ yet available. Please contact us at{' '}
+
+ {conference.sponsorEmail}
+ {' '}
+ for more information.
+
+
+
+ View Sponsorship Options
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+ General Terms & Conditions
+
+
+ {conferenceName || conference.title} Sponsorship Agreement
+
+
+
+
+
+
+
+
+ )
+}
+
+export default async function SponsorTermsPage() {
+ const headersList = await headers()
+ const domain = headersList.get('host') || ''
+
+ return
+}
diff --git a/src/components/BackLink.stories.tsx b/src/components/BackLink.stories.tsx
new file mode 100644
index 00000000..71f0e63f
--- /dev/null
+++ b/src/components/BackLink.stories.tsx
@@ -0,0 +1,91 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { BackLink } from './BackButton'
+
+const meta = {
+ title: 'Components/Layout/BackLink',
+ component: BackLink,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'A smart back navigation component that uses browser history when available, with a fallback URL. Available in link and button variants.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: ['link', 'button'],
+ description: 'Visual style - link (minimal) or button (prominent)',
+ },
+ fallbackUrl: {
+ control: 'text',
+ description: 'URL to navigate to if no browser history exists',
+ },
+ children: {
+ control: 'text',
+ description: 'Button text content',
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Link: Story = {
+ args: {
+ variant: 'link',
+ children: 'Back',
+ fallbackUrl: '/',
+ },
+}
+
+export const Button: Story = {
+ args: {
+ variant: 'button',
+ children: 'Back',
+ fallbackUrl: '/',
+ },
+}
+
+export const CustomText: Story = {
+ args: {
+ variant: 'link',
+ children: 'Return to speakers',
+ fallbackUrl: '/speakers',
+ },
+}
+
+export const InContext: Story = {
+ render: () => (
+
+
+
+ Back to speakers
+
+
+ Speaker Detail Page
+
+
+ The back link navigates using browser history when available.
+
+
+
+
+
+
+ Form Page
+
+
+ Cancel
+
+
+
+ Button variant is more prominent for actions like cancel.
+
+
+
+ ),
+}
diff --git a/src/components/Button.stories.tsx b/src/components/Button.stories.tsx
new file mode 100644
index 00000000..76ab5075
--- /dev/null
+++ b/src/components/Button.stories.tsx
@@ -0,0 +1,232 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { expect, fn, userEvent, within } from 'storybook/test'
+import { Button } from './Button'
+import { CalendarIcon, UserIcon } from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Components/Layout/Button',
+ component: Button,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Consistent, accessible button system with clear visual hierarchy. Supports multiple variants (primary, secondary, success, warning, info, outline, icon) following the brand color system. Available in four sizes (sm, md, lg, icon) with loading states and icon support. Maintains WCAG 2.1 AA compliance with proper focus states.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: [
+ 'primary',
+ 'secondary',
+ 'success',
+ 'warning',
+ 'info',
+ 'outline',
+ 'icon',
+ ],
+ description: 'Visual style variant following brand color system',
+ },
+ size: {
+ control: 'select',
+ options: ['sm', 'md', 'lg', 'icon'],
+ description: 'Button size - use "icon" size for icon-only buttons',
+ },
+ disabled: {
+ control: 'boolean',
+ description: 'Disable button interaction',
+ },
+ children: {
+ control: 'text',
+ description: 'Button content - text or JSX elements',
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Primary: Story = {
+ args: {
+ variant: 'primary',
+ children: 'Primary Button',
+ },
+}
+
+export const Secondary: Story = {
+ args: {
+ variant: 'secondary',
+ children: 'Secondary Button',
+ },
+}
+
+export const Success: Story = {
+ args: {
+ variant: 'success',
+ children: 'Success Button',
+ },
+}
+
+export const Warning: Story = {
+ args: {
+ variant: 'warning',
+ children: 'Warning Button',
+ },
+}
+
+export const Info: Story = {
+ args: {
+ variant: 'info',
+ children: 'Info Button',
+ },
+}
+
+export const Outline: Story = {
+ args: {
+ variant: 'outline',
+ children: 'Outline Button',
+ },
+}
+
+export const WithIcon: Story = {
+ args: {
+ variant: 'primary',
+ children: (
+ <>
+
+ Schedule Event
+ >
+ ),
+ },
+}
+
+export const IconOnly: Story = {
+ args: {
+ variant: 'icon',
+ size: 'icon',
+ children: ,
+ },
+}
+
+export const Small: Story = {
+ args: {
+ variant: 'primary',
+ size: 'sm',
+ children: 'Small Button',
+ },
+}
+
+export const Medium: Story = {
+ args: {
+ variant: 'primary',
+ size: 'md',
+ children: 'Medium Button',
+ },
+}
+
+export const Large: Story = {
+ args: {
+ variant: 'primary',
+ size: 'lg',
+ children: 'Large Button',
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ variant: 'primary',
+ disabled: true,
+ children: 'Disabled Button',
+ },
+}
+
+// Interaction Tests
+export const ClickInteraction: Story = {
+ args: {
+ variant: 'primary',
+ children: 'Click Me',
+ onClick: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const button = canvas.getByRole('button', { name: /click me/i })
+
+ // Test button is visible and enabled
+ await expect(button).toBeVisible()
+ await expect(button).toBeEnabled()
+
+ // Test click interaction
+ await userEvent.click(button)
+ await expect(args.onClick).toHaveBeenCalledTimes(1)
+
+ // Test double click
+ await userEvent.click(button)
+ await expect(args.onClick).toHaveBeenCalledTimes(2)
+ },
+}
+
+export const DisabledInteraction: Story = {
+ args: {
+ variant: 'primary',
+ disabled: true,
+ children: 'Disabled Button',
+ onClick: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const button = canvas.getByRole('button', { name: /disabled button/i })
+
+ // Test button is visible but disabled
+ await expect(button).toBeVisible()
+ await expect(button).toBeDisabled()
+
+ // Attempt to click - should not trigger onClick
+ await userEvent.click(button)
+ await expect(args.onClick).not.toHaveBeenCalled()
+ },
+}
+
+export const KeyboardNavigation: Story = {
+ args: {
+ variant: 'primary',
+ children: 'Press Enter',
+ onClick: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const button = canvas.getByRole('button', { name: /press enter/i })
+
+ // Focus the button
+ button.focus()
+ await expect(button).toHaveFocus()
+
+ // Press Enter to activate
+ await userEvent.keyboard('{Enter}')
+ await expect(args.onClick).toHaveBeenCalledTimes(1)
+
+ // Press Space to activate
+ await userEvent.keyboard(' ')
+ await expect(args.onClick).toHaveBeenCalledTimes(2)
+ },
+}
+
+export const HoverState: Story = {
+ args: {
+ variant: 'primary',
+ children: 'Hover Over Me',
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+ const button = canvas.getByRole('button', { name: /hover over me/i })
+
+ // Test hover interaction (visual change verified by Chromatic)
+ await userEvent.hover(button)
+ await expect(button).toBeVisible()
+
+ await userEvent.unhover(button)
+ await expect(button).toBeVisible()
+ },
+}
diff --git a/src/components/ClickableSpeakerNames.stories.tsx b/src/components/ClickableSpeakerNames.stories.tsx
new file mode 100644
index 00000000..b67381db
--- /dev/null
+++ b/src/components/ClickableSpeakerNames.stories.tsx
@@ -0,0 +1,225 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ClickableSpeakerNames } from './ClickableSpeakerNames'
+import { Speaker, Flags } from '@/lib/speaker/types'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker],
+ },
+ {
+ _id: 'speaker-3',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Carol Williams',
+ email: 'carol@example.com',
+ slug: 'carol-williams',
+ title: 'Platform Architect at AWS',
+ flags: [],
+ },
+ {
+ _id: 'speaker-4',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'David Chen',
+ email: 'david@example.com',
+ slug: 'david-chen',
+ title: 'CTO at Startup Inc',
+ flags: [Flags.diverseSpeaker],
+ },
+]
+
+const meta = {
+ title: 'Systems/Speakers/ClickableSpeakerNames',
+ component: ClickableSpeakerNames,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Renders speaker names as clickable links to their profile pages. Handles multiple speakers with proper separators (commas and ampersands). Supports first name only mode for compact displays and a maxVisible option to limit displayed names.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+ argTypes: {
+ showFirstNameOnly: {
+ control: 'boolean',
+ description: 'Show only first names when multiple speakers',
+ },
+ maxVisible: {
+ control: { type: 'number', min: 1, max: 10 },
+ description: 'Maximum number of names to display',
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const SingleSpeaker: Story = {
+ args: {
+ speakers: [mockSpeakers[0]],
+ },
+}
+
+export const TwoSpeakers: Story = {
+ args: {
+ speakers: mockSpeakers.slice(0, 2),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Two speakers are joined with an ampersand (&).',
+ },
+ },
+ },
+}
+
+export const ThreeSpeakers: Story = {
+ args: {
+ speakers: mockSpeakers.slice(0, 3),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Multiple speakers use commas with an ampersand before the last name.',
+ },
+ },
+ },
+}
+
+export const ManySpeakers: Story = {
+ args: {
+ speakers: mockSpeakers,
+ },
+}
+
+export const FirstNameOnly: Story = {
+ args: {
+ speakers: mockSpeakers.slice(0, 3),
+ showFirstNameOnly: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Useful for compact displays where full names would be too long.',
+ },
+ },
+ },
+}
+
+export const MaxVisible: Story = {
+ args: {
+ speakers: mockSpeakers,
+ maxVisible: 2,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Limits displayed names and shows "+N more" for remaining.',
+ },
+ },
+ },
+}
+
+export const CustomStyling: Story = {
+ args: {
+ speakers: mockSpeakers.slice(0, 2),
+ className: 'text-lg font-semibold',
+ linkClassName:
+ 'text-brand-cloud-blue hover:text-brand-cloud-blue/80 hover:underline',
+ separatorClassName: 'text-gray-400',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Custom CSS classes can be applied to links and separators.',
+ },
+ },
+ },
+}
+
+export const InContext: Story = {
+ args: {
+ speakers: mockSpeakers,
+ },
+ render: () => (
+
+
+
+ In a talk card:
+
+
+
+
+
+
+
+
+ Compact mode (first names only):
+
+
+
+
+
+
+
+
+ With maxVisible limit:
+
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Examples of how ClickableSpeakerNames looks in various contexts.',
+ },
+ },
+ },
+}
diff --git a/src/components/CollapsibleDescription.stories.tsx b/src/components/CollapsibleDescription.stories.tsx
new file mode 100644
index 00000000..d3babf6a
--- /dev/null
+++ b/src/components/CollapsibleDescription.stories.tsx
@@ -0,0 +1,116 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { CollapsibleDescription } from './CollapsibleDescription'
+
+const meta = {
+ title: 'Components/Data Display/CollapsibleDescription',
+ component: CollapsibleDescription,
+ parameters: {
+ layout: 'padded',
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ paragraphs: {
+ control: 'object',
+ description: 'Array of paragraph strings to display',
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const SingleParagraph: Story = {
+ args: {
+ paragraphs: [
+ 'Cloud Native Days Norway is a premier conference for the cloud native community in the Nordic region. We bring together developers, operators, and business leaders to share knowledge and best practices.',
+ ],
+ },
+}
+
+export const TwoParagraphs: Story = {
+ args: {
+ paragraphs: [
+ 'Cloud Native Days Norway is a premier conference for the cloud native community in the Nordic region.',
+ 'Join us for two days of inspiring talks, hands-on workshops, and networking opportunities with industry experts.',
+ ],
+ },
+}
+
+export const MultipleParagraphs: Story = {
+ args: {
+ paragraphs: [
+ 'Cloud Native Days Norway is a premier conference for the cloud native community in the Nordic region. We bring together developers, operators, and business leaders to share knowledge and best practices.',
+ 'Our conference features world-class speakers from leading technology companies, sharing insights on Kubernetes, containers, serverless, and more.',
+ 'Whether you are just getting started with cloud native technologies or you are a seasoned expert, there is something for everyone at Cloud Native Days Norway.',
+ 'Join us and be part of the movement that is transforming how we build and deploy software across the globe.',
+ ],
+ },
+}
+
+export const Documentation: Story = {
+ args: {
+ paragraphs: ['Demo paragraph'],
+ },
+ render: () => (
+
+
+ CollapsibleDescription Component
+
+
+ A responsive description component that shows only the first paragraph
+ on mobile with a "Show more" button. On desktop, all
+ paragraphs are visible by default.
+
+
+
+
+ Responsive Behavior
+
+
+
+ Mobile: Shows first paragraph, others hidden behind
+ "Show more"
+
+
+ Desktop (sm+): All paragraphs visible, no toggle
+ needed
+
+
+ Single paragraph: No toggle shown, content always
+ visible
+
+
+
+
+
+
+ Example - Single Paragraph
+
+
+
+
+
+
+
+
+ Example - Multiple Paragraphs
+
+
+
+
+
+ Resize your browser to see the mobile "Show more" button
+ appear.
+
+
+
+ ),
+}
diff --git a/src/components/Container.stories.tsx b/src/components/Container.stories.tsx
new file mode 100644
index 00000000..7e8ef649
--- /dev/null
+++ b/src/components/Container.stories.tsx
@@ -0,0 +1,60 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { Container } from './Container'
+
+const meta = {
+ title: 'Components/Layout/Container',
+ component: Container,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ 'A responsive container component that centers content and applies consistent horizontal padding. Uses max-w-7xl (1280px) with responsive padding.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ children: (
+
+
+ Content is centered with max-w-7xl and responsive padding
+
+
+ ),
+ },
+}
+
+export const WithMultipleSections: Story = {
+ args: {
+ children: (
+
+
Section 1
+
Section 2
+
+ Section 3
+
+
+ ),
+ },
+}
+
+export const FullWidthBackground: Story = {
+ render: () => (
+
+
+
+
+ Container constrains content while background extends full-width
+
+
+
+
+ ),
+}
diff --git a/src/components/Form.stories.tsx b/src/components/Form.stories.tsx
new file mode 100644
index 00000000..643494e3
--- /dev/null
+++ b/src/components/Form.stories.tsx
@@ -0,0 +1,380 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { fn } from 'storybook/test'
+import { useState } from 'react'
+import {
+ Input,
+ LinkInput,
+ ErrorText,
+ HelpText,
+ Textarea,
+ Dropdown,
+ Checkbox,
+ Multiselect,
+} from './Form'
+
+const meta = {
+ title: 'Components/Forms/Form Elements',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+// --- Input ---
+
+export const InputDefault: Story = {
+ name: 'Input',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState('John Doe')
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+export const InputEmpty: Story = {
+ name: 'Input (Empty)',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+export const InputReadOnly: Story = {
+ name: 'Input (Read Only)',
+ render: () => ,
+}
+
+// --- Textarea ---
+
+export const TextareaDefault: Story = {
+ name: 'Textarea',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState(
+ 'A talk about building resilient microservices with Kubernetes.',
+ )
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+// --- Dropdown ---
+
+export const DropdownDefault: Story = {
+ name: 'Dropdown',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState('30')
+ const options = new Map([
+ ['15', '15 minutes (Lightning Talk)'],
+ ['30', '30 minutes'],
+ ['45', '45 minutes'],
+ ['60', '60 minutes (Workshop)'],
+ ])
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+export const DropdownNoSelection: Story = {
+ name: 'Dropdown (No Selection)',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState('')
+ const options = new Map([
+ ['beginner', 'Beginner'],
+ ['intermediate', 'Intermediate'],
+ ['advanced', 'Advanced'],
+ ])
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+// --- Checkbox ---
+
+export const CheckboxDefault: Story = {
+ name: 'Checkbox',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState(false)
+ return (
+
+ You must accept the terms before submitting.
+
+ )
+ }
+ return
+ },
+}
+
+export const CheckboxChecked: Story = {
+ name: 'Checkbox (Checked)',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState(true)
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+// --- Multiselect ---
+
+export const MultiselectDefault: Story = {
+ name: 'Multiselect',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState(['kubernetes'])
+ const options = [
+ { id: 'kubernetes', title: 'Kubernetes', color: '326CE5' },
+ { id: 'observability', title: 'Observability', color: 'E6522C' },
+ { id: 'security', title: 'Security', color: '00B39F' },
+ { id: 'serverless', title: 'Serverless', color: 'FF9900' },
+ { id: 'networking', title: 'Networking', color: '7B42BC' },
+ ]
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+export const MultiselectMaxReached: Story = {
+ name: 'Multiselect (Max Reached)',
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState(['kubernetes', 'security'])
+ const options = [
+ { id: 'kubernetes', title: 'Kubernetes', color: '326CE5' },
+ { id: 'security', title: 'Security', color: '00B39F' },
+ { id: 'serverless', title: 'Serverless', color: 'FF9900' },
+ ]
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+// --- LinkInput ---
+
+export const LinkInputDefault: Story = {
+ name: 'LinkInput',
+ render: () => {
+ const update = fn()
+ const add = fn()
+ const remove = fn()
+ return (
+
+
+ Social Links
+
+
+
+
+ )
+ },
+}
+
+// --- ErrorText & HelpText ---
+
+export const TextHelpers: Story = {
+ name: 'ErrorText & HelpText',
+ render: () => (
+
+
+
+ HelpText
+
+
+ This field is optional and will be displayed on your speaker profile.
+
+
+
+
+ ErrorText
+
+
+ This field is required. Please enter a valid email address.
+
+
+
+ ),
+}
+
+// --- Kitchen Sink ---
+
+export const FormExample: Story = {
+ render: () => {
+ const Wrapper = () => {
+ const [name, setName] = useState('Jane Smith')
+ const [bio, setBio] = useState('')
+ const [level, setLevel] = useState('')
+ const [topics, setTopics] = useState([])
+ const [agreed, setAgreed] = useState(false)
+
+ const levelOptions = new Map([
+ ['beginner', 'Beginner'],
+ ['intermediate', 'Intermediate'],
+ ['advanced', 'Advanced'],
+ ])
+
+ const topicOptions = [
+ { id: 'kubernetes', title: 'Kubernetes', color: '326CE5' },
+ { id: 'observability', title: 'Observability', color: 'E6522C' },
+ { id: 'security', title: 'Security', color: '00B39F' },
+ ]
+
+ return (
+
+
+
+
+ Brief description of your background and expertise.
+
+
+
+
+ Required before your talk can be scheduled.
+
+ {!agreed && (
+ You must accept the speaker agreement.
+ )}
+
+ )
+ }
+ return
+ },
+}
diff --git a/src/components/Logo.stories.tsx b/src/components/Logo.stories.tsx
new file mode 100644
index 00000000..2ba2496e
--- /dev/null
+++ b/src/components/Logo.stories.tsx
@@ -0,0 +1,185 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { Logo, Logomark } from './Logo'
+
+const logoMeta = {
+ title: 'Components/Layout/Logo',
+ component: Logo,
+ parameters: {
+ layout: 'centered',
+ backgrounds: {
+ default: 'light',
+ values: [
+ { name: 'light', value: '#ffffff' },
+ { name: 'dark', value: '#1a1a2e' },
+ ],
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ variant: {
+ control: 'radio',
+ options: ['gradient', 'monochrome'],
+ description: 'Logo color variant',
+ },
+ className: {
+ control: 'text',
+ description: 'Additional CSS classes',
+ },
+ },
+} satisfies Meta
+
+export default logoMeta
+type LogoStory = StoryObj
+
+export const GradientLogo: LogoStory = {
+ args: {
+ variant: 'gradient',
+ className: 'w-96',
+ },
+}
+
+export const MonochromeLogo: LogoStory = {
+ args: {
+ variant: 'monochrome',
+ className: 'w-96',
+ },
+}
+
+export const LogoSizes: LogoStory = {
+ args: { variant: 'gradient', className: 'w-96' },
+ render: () => (
+
+ ),
+}
+
+export const LogomarkDefault: LogoStory = {
+ args: { variant: 'gradient', className: 'w-96' },
+ render: () => (
+
+ ),
+}
+
+export const LogomarkSizes: LogoStory = {
+ args: { variant: 'gradient', className: 'w-96' },
+ render: () => (
+
+ ),
+}
+
+export const Documentation: LogoStory = {
+ args: { variant: 'gradient', className: 'w-96' },
+ render: () => (
+
+
+ Logo Components
+
+
+ The Cloud Native Days logo system includes both the full logo (Logo) and
+ a compact mark (Logomark) for various use cases.
+
+
+
+
+ Full Logo
+
+
+ Use the full logo in headers, hero sections, and contexts where brand
+ recognition is important.
+
+
+
+
+
+
+ Logomark
+
+
+ Use the logomark in compact spaces like navigation icons, favicons,
+ and social media avatars.
+
+
+
+
Gradient on Light
+
+
+
+
+
+
Monochrome on Dark
+
+
+
+
+
+
+
+
+
+ Usage Guidelines
+
+
+
+ Use variant="gradient"{' '}
+ for primary branding contexts
+
+
+ Use variant="monochrome"{' '}
+ for dark backgrounds or monochrome designs
+
+ Maintain minimum clear space around the logo
+
+ Do not stretch, rotate, or modify the logo's aspect ratio
+
+
+
+
+ ),
+}
diff --git a/src/components/PortableTextEditor.stories.tsx b/src/components/PortableTextEditor.stories.tsx
new file mode 100644
index 00000000..5dfe5230
--- /dev/null
+++ b/src/components/PortableTextEditor.stories.tsx
@@ -0,0 +1,97 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import type { PortableTextBlock } from '@portabletext/editor'
+import { useState } from 'react'
+import { PortableTextEditor } from './PortableTextEditor'
+
+const meta = {
+ title: 'Components/Forms/PortableTextEditor',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Empty: Story = {
+ render: () => {
+ const Wrapper = () => {
+ const [value, setValue] = useState([])
+ return (
+
+ )
+ }
+ return
+ },
+}
+
+export const WithContent: Story = {
+ render: () => {
+ const Wrapper = () => {
+ const initialValue: PortableTextBlock[] = [
+ {
+ _type: 'block',
+ _key: 'intro',
+ style: 'h2',
+ children: [
+ {
+ _type: 'span',
+ _key: 'intro-span',
+ text: 'About This Talk',
+ marks: [],
+ },
+ ],
+ markDefs: [],
+ },
+ {
+ _type: 'block',
+ _key: 'body',
+ style: 'normal',
+ children: [
+ {
+ _type: 'span',
+ _key: 'body-span',
+ text: 'This talk explores how to build ',
+ marks: [],
+ },
+ {
+ _type: 'span',
+ _key: 'body-bold',
+ text: 'resilient microservices',
+ marks: ['strong'],
+ },
+ {
+ _type: 'span',
+ _key: 'body-rest',
+ text: ' using Kubernetes and cloud native patterns.',
+ marks: [],
+ },
+ ],
+ markDefs: [],
+ },
+ ]
+ const [value, setValue] = useState(initialValue)
+ return (
+
+ )
+ }
+ return
+ },
+}
diff --git a/src/components/ShowMore.stories.tsx b/src/components/ShowMore.stories.tsx
new file mode 100644
index 00000000..88e3b412
--- /dev/null
+++ b/src/components/ShowMore.stories.tsx
@@ -0,0 +1,201 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { expect, userEvent, within } from 'storybook/test'
+import { ShowMore } from './ShowMore'
+
+const meta = {
+ title: 'Components/Data Display/ShowMore',
+ component: ShowMore,
+ parameters: {
+ layout: 'padded',
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const shortText = `This is a short paragraph that fits within the line clamp limit.`
+
+const longText = `Cloud Native Days Norway is a community-driven conference that brings together the best minds in cloud native technology. Our mission is to educate, inspire, and connect developers, operators, and business leaders who are building and running cloud native applications.
+
+We believe that the future of software is cloud native, and we're committed to helping our community navigate this exciting landscape. From Kubernetes and containers to serverless and GitOps, we cover the full spectrum of cloud native technologies.
+
+Our conference features world-class speakers, hands-on workshops, and networking opportunities that will help you level up your cloud native skills. Whether you're just getting started or you're a seasoned expert, there's something for everyone at Cloud Native Days Norway.
+
+Join us and be part of the movement that's transforming how we build and deploy software. Together, we can build a more resilient, scalable, and efficient future.
+
+This is additional text that demonstrates the expand/collapse functionality of the ShowMore component.`
+
+export const ShortContent: Story = {
+ args: {
+ children: {shortText}
,
+ },
+}
+
+export const LongContent: Story = {
+ args: {
+ children: (
+ <>
+ {longText.split('\n\n').map((paragraph, index) => (
+ 0 ? 'mt-4' : ''}>
+ {paragraph}
+
+ ))}
+ >
+ ),
+ },
+}
+
+export const WithClassName: Story = {
+ args: {
+ children: (
+ <>
+ {longText.split('\n\n').map((paragraph, index) => (
+ 0 ? 'mt-4' : ''}>
+ {paragraph}
+
+ ))}
+ >
+ ),
+ className: 'bg-gray-100 p-4 rounded-lg dark:bg-gray-800',
+ },
+}
+
+export const Documentation: Story = {
+ args: {
+ children: Demo content
,
+ },
+ render: () => (
+
+
+ ShowMore Component
+
+
+ A content container that automatically truncates long content with a
+ "Show more" / "Show less" toggle. Uses CSS
+ line-clamp for smooth truncation.
+
+
+
+
+ Features
+
+
+
+ Smart detection: Only shows toggle when content
+ overflows
+
+
+ Line clamp: Truncates content at 6 lines by default
+
+
+ Accessible: Toggle button is properly focusable
+
+
+ Brand styled: Uses brand cloud blue for the toggle
+ link
+
+
+
+
+
+
+ Example - Short Content
+
+
+ {shortText}
+
+
+
+
+
+ Example - Long Content
+
+
+ {longText.split('\n\n').map((paragraph, index) => (
+ 0 ? 'mt-4' : ''}>
+ {paragraph}
+
+ ))}
+
+
+
+ ),
+}
+
+/**
+ * Tests that clicking "Show more" expands the content and changes to "Show less"
+ */
+export const ExpandCollapseInteraction: Story = {
+ args: {
+ children: (
+ <>
+ {longText.split('\n\n').map((paragraph, index) => (
+ 0 ? 'mt-4' : ''}>
+ {paragraph}
+
+ ))}
+ >
+ ),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ // Wait for component to render and detect overflow
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Find and click "Show more" button
+ const showMoreButton = canvas.getByRole('button', { name: /show more/i })
+ await expect(showMoreButton).toBeVisible()
+ await userEvent.click(showMoreButton)
+
+ // After clicking, button should say "Show less"
+ const showLessButton = canvas.getByRole('button', { name: /show less/i })
+ await expect(showLessButton).toBeVisible()
+
+ // Click again to collapse
+ await userEvent.click(showLessButton)
+
+ // Button should be back to "Show more"
+ await expect(
+ canvas.getByRole('button', { name: /show more/i }),
+ ).toBeVisible()
+ },
+}
+
+/**
+ * Tests keyboard accessibility - can toggle with Enter key
+ */
+export const KeyboardToggle: Story = {
+ args: {
+ children: (
+ <>
+ {longText.split('\n\n').map((paragraph, index) => (
+ 0 ? 'mt-4' : ''}>
+ {paragraph}
+
+ ))}
+ >
+ ),
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement)
+
+ // Wait for component to render
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ const showMoreButton = canvas.getByRole('button', { name: /show more/i })
+
+ // Focus the button
+ await userEvent.tab()
+ await expect(showMoreButton).toHaveFocus()
+
+ // Press Enter to expand
+ await userEvent.keyboard('{Enter}')
+
+ // Should now show "Show less"
+ await expect(
+ canvas.getByRole('button', { name: /show less/i }),
+ ).toBeVisible()
+ },
+}
diff --git a/src/components/ShowMore.tsx b/src/components/ShowMore.tsx
index fc5c353c..bc6ec64e 100644
--- a/src/components/ShowMore.tsx
+++ b/src/components/ShowMore.tsx
@@ -23,7 +23,10 @@ export function ShowMore({
{children}
diff --git a/src/components/SocialIcons.stories.tsx b/src/components/SocialIcons.stories.tsx
new file mode 100644
index 00000000..3107133e
--- /dev/null
+++ b/src/components/SocialIcons.stories.tsx
@@ -0,0 +1,128 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ iconForLink,
+ titleForLink,
+ TwitterIcon,
+ InstagramIcon,
+ GitHubIcon,
+ LinkedInIcon,
+ BlueskyIcon,
+} from './SocialIcons'
+
+const meta = {
+ title: 'Components/Icons/SocialIcons',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const AllIcons: Story = {
+ render: () => (
+
+
+
+ Individual Icon Components
+
+
+ {[
+ { Icon: TwitterIcon, name: 'Twitter / X' },
+ { Icon: LinkedInIcon, name: 'LinkedIn' },
+ { Icon: GitHubIcon, name: 'GitHub' },
+ { Icon: BlueskyIcon, name: 'Bluesky' },
+ { Icon: InstagramIcon, name: 'Instagram' },
+ ].map(({ Icon, name }) => (
+
+
+
+ {name}
+
+
+ ))}
+
+
+
+
+
+ iconForLink() Helper
+
+
+ {[
+ 'https://twitter.com/cloudnative',
+ 'https://linkedin.com/in/speaker',
+ 'https://github.com/cloudnativebergen',
+ 'https://bsky.app/profile/speaker',
+ 'https://instagram.com/cloudnative',
+ 'https://example.com/blog',
+ ].map((url) => (
+
+
+ {iconForLink(url, 'h-8 w-8')}
+
+
+ {titleForLink(url)}
+
+
+ ))}
+
+
+
+
+
+ titleForLink() Results
+
+
+
+
+ URL
+ Title
+
+
+
+ {[
+ 'https://twitter.com/user',
+ 'https://x.com/user',
+ 'https://linkedin.com/in/user',
+ 'https://github.com/user',
+ 'https://bsky.app/profile/user',
+ 'https://instagram.com/user',
+ 'https://myblog.dev',
+ ].map((url) => (
+
+
+ {url}
+
+ {titleForLink(url)}
+
+ ))}
+
+
+
+
+ ),
+}
+
+export const Sizes: Story = {
+ name: 'Icon Sizes',
+ render: () => (
+
+ {[
+ { size: 'h-4 w-4', label: '16px' },
+ { size: 'h-6 w-6', label: '24px' },
+ { size: 'h-8 w-8', label: '32px' },
+ { size: 'h-10 w-10', label: '40px' },
+ { size: 'h-12 w-12', label: '48px' },
+ ].map(({ size, label }) => (
+
+
+
+ {label}
+
+
+ ))}
+
+ ),
+}
diff --git a/src/components/SpeakerAvatars.stories.tsx b/src/components/SpeakerAvatars.stories.tsx
new file mode 100644
index 00000000..949c7874
--- /dev/null
+++ b/src/components/SpeakerAvatars.stories.tsx
@@ -0,0 +1,230 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { SpeakerAvatars, SpeakerAvatarsWithNames } from './SpeakerAvatars'
+import { Speaker, Flags } from '@/lib/speaker/types'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker],
+ },
+ {
+ _id: 'speaker-3',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Carol Williams',
+ email: 'carol@example.com',
+ slug: 'carol-williams',
+ title: 'Platform Architect at AWS',
+ flags: [],
+ },
+ {
+ _id: 'speaker-4',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'David Chen',
+ email: 'david@example.com',
+ slug: 'david-chen',
+ title: 'CTO at Startup Inc',
+ flags: [Flags.diverseSpeaker],
+ },
+ {
+ _id: 'speaker-5',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Eva Martinez',
+ email: 'eva@example.com',
+ slug: 'eva-martinez',
+ title: 'Principal Engineer at Netflix',
+ flags: [],
+ },
+]
+
+const meta: Meta = {
+ title: 'Systems/Speakers/SpeakerAvatars',
+ component: SpeakerAvatars,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Displays stacked speaker avatars with hover animation to spread them out. Uses MissingAvatar component when no image is available. Supports different sizes and configurable maximum visible count.',
+ },
+ },
+ },
+ argTypes: {
+ size: {
+ control: 'select',
+ options: ['sm', 'md', 'lg'],
+ description: 'Size of the avatar stack',
+ },
+ maxVisible: {
+ control: { type: 'number', min: 1, max: 5 },
+ description: 'Maximum number of avatars visible before showing +N',
+ },
+ showTooltip: {
+ control: 'boolean',
+ description: 'Show speaker name on hover',
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const SingleSpeaker: Story = {
+ args: {
+ speakers: [mockSpeakers[0]],
+ size: 'md',
+ maxVisible: 3,
+ showTooltip: true,
+ },
+}
+
+export const TwoSpeakers: Story = {
+ args: {
+ speakers: mockSpeakers.slice(0, 2),
+ size: 'md',
+ maxVisible: 3,
+ showTooltip: true,
+ },
+}
+
+export const ThreeSpeakers: Story = {
+ args: {
+ speakers: mockSpeakers.slice(0, 3),
+ size: 'md',
+ maxVisible: 3,
+ showTooltip: true,
+ },
+}
+
+export const ManySpeakers: Story = {
+ args: {
+ speakers: mockSpeakers,
+ size: 'md',
+ maxVisible: 3,
+ showTooltip: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'When there are more speakers than maxVisible, shows a +N indicator.',
+ },
+ },
+ },
+}
+
+export const SmallSize: Story = {
+ args: {
+ speakers: mockSpeakers.slice(0, 3),
+ size: 'sm',
+ maxVisible: 3,
+ showTooltip: true,
+ },
+}
+
+export const LargeSize: Story = {
+ args: {
+ speakers: mockSpeakers.slice(0, 3),
+ size: 'lg',
+ maxVisible: 3,
+ showTooltip: true,
+ },
+}
+
+export const AllSizes: Story = {
+ render: () => (
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Comparison of all available avatar sizes. Hover over avatars to see the spread animation.',
+ },
+ },
+ },
+}
+
+export const WithNames: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'SpeakerAvatarsWithNames displays the avatar stack alongside formatted speaker names.',
+ },
+ },
+ },
+}
diff --git a/src/components/SpeakerProfilePreview.stories.tsx b/src/components/SpeakerProfilePreview.stories.tsx
new file mode 100644
index 00000000..14fcc5ba
--- /dev/null
+++ b/src/components/SpeakerProfilePreview.stories.tsx
@@ -0,0 +1,238 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { fn } from 'storybook/test'
+import { useState } from 'react'
+import SpeakerProfilePreview from './SpeakerProfilePreview'
+import { Speaker, Flags } from '@/lib/speaker/types'
+import {
+ ProposalExisting,
+ Format,
+ Language,
+ Level,
+ Audience,
+ Status,
+} from '@/lib/proposal/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+
+const mockSpeaker: Speaker = {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Platform Engineer at Google Cloud',
+ bio: 'Alice is a passionate advocate for cloud native technologies with over 10 years of experience in distributed systems and Kubernetes. She has contributed to several CNCF projects and regularly speaks at conferences worldwide about building resilient, scalable architectures.',
+ flags: [Flags.localSpeaker],
+ links: [
+ 'https://linkedin.com/in/alicejohnson',
+ 'https://twitter.com/alicejohnson',
+ 'https://github.com/alicejohnson',
+ ],
+}
+
+const mockSpeakerWithImage: Speaker = {
+ ...mockSpeaker,
+ image: 'image-abc123-200x200-jpg',
+ imageURL: 'https://placehold.co/400x400/EEE/31343C?text=Speaker',
+}
+
+const mockSpeakerMinimal: Speaker = {
+ _id: 'speaker-minimal',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+}
+
+const createMockTalk = (
+ id: string,
+ title: string,
+ format: Format = Format.presentation_45,
+): ProposalExisting => ({
+ _id: id,
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title,
+ description: convertStringToPortableTextBlocks(
+ 'This talk explores the key patterns and practices for building modern cloud native applications. We will cover containerization, orchestration, and observability strategies that help teams deliver reliable software at scale.',
+ ),
+ language: Language.english,
+ format,
+ level: Level.intermediate,
+ audiences: [Audience.developer, Audience.operator],
+ status: Status.confirmed,
+ outline: '',
+ topics: [],
+ tos: true,
+ speakers: [mockSpeaker],
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+})
+
+const mockTalks: ProposalExisting[] = [
+ createMockTalk(
+ 'talk-1',
+ 'Building Scalable Microservices with Kubernetes',
+ Format.presentation_45,
+ ),
+ createMockTalk('talk-2', 'Observability Best Practices', Format.lightning_10),
+]
+
+const meta = {
+ title: 'Systems/Speakers/SpeakerProfilePreview',
+ component: SpeakerProfilePreview,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ 'Modal dialog showing a full speaker profile preview. Displays speaker photo, bio, social links, Bluesky feed (if available), and their talks with detailed metadata.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ isOpen: true,
+ onClose: fn(),
+ speaker: mockSpeaker,
+ talks: mockTalks,
+ },
+}
+
+export const WithImage: Story = {
+ args: {
+ isOpen: true,
+ onClose: fn(),
+ speaker: mockSpeakerWithImage,
+ talks: mockTalks,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Speaker with a profile image. The image is loaded from Sanity CDN.',
+ },
+ },
+ },
+}
+
+export const MinimalProfile: Story = {
+ args: {
+ isOpen: true,
+ onClose: fn(),
+ speaker: mockSpeakerMinimal,
+ talks: [],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'A speaker with minimal information - just name and email, no bio, title, links, or talks.',
+ },
+ },
+ },
+}
+
+export const NoTalks: Story = {
+ args: {
+ isOpen: true,
+ onClose: fn(),
+ speaker: mockSpeaker,
+ talks: [],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Speaker profile without any scheduled talks.',
+ },
+ },
+ },
+}
+
+export const MultipleTalks: Story = {
+ args: {
+ isOpen: true,
+ onClose: fn(),
+ speaker: mockSpeaker,
+ talks: [
+ ...mockTalks,
+ createMockTalk(
+ 'talk-3',
+ 'Hands-on Kubernetes Workshop',
+ Format.workshop_120,
+ ),
+ ],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Speaker with three talks of different formats.',
+ },
+ },
+ },
+}
+
+export const Closed: Story = {
+ args: {
+ isOpen: false,
+ onClose: fn(),
+ speaker: mockSpeaker,
+ talks: mockTalks,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Modal in closed state - not visible.',
+ },
+ },
+ },
+}
+
+export const Interactive: Story = {
+ args: {
+ isOpen: false,
+ onClose: fn(),
+ speaker: mockSpeaker,
+ talks: mockTalks,
+ },
+ render: () => {
+ const InteractiveDemo = () => {
+ const [isOpen, setIsOpen] = useState(false)
+ return (
+
+ setIsOpen(true)}
+ className="rounded-lg bg-brand-cloud-blue px-4 py-2 text-white hover:bg-brand-cloud-blue/90"
+ >
+ Preview Speaker Profile
+
+ setIsOpen(false)}
+ speaker={mockSpeaker}
+ talks={mockTalks}
+ />
+
+ )
+ }
+ return
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Interactive demo showing how the modal opens and closes. Click the button to open the preview.',
+ },
+ },
+ },
+}
diff --git a/src/components/SponsorLogo.stories.tsx b/src/components/SponsorLogo.stories.tsx
new file mode 100644
index 00000000..161273fc
--- /dev/null
+++ b/src/components/SponsorLogo.stories.tsx
@@ -0,0 +1,108 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { SponsorLogo } from './SponsorLogo'
+
+const meta = {
+ title: 'Systems/Sponsors/Components/SponsorLogo',
+ component: SponsorLogo,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Responsive sponsor logo display with automatic dark mode support. Uses inline SVG logos with optional bright variant for dark backgrounds. Supports multiple sizes and maintains aspect ratio. Used in both admin CRM interface and public sponsor showcase.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ name: {
+ control: 'text',
+ description: 'Sponsor name (used for alt text)',
+ },
+ logo: {
+ control: 'text',
+ description: 'SVG logo markup (default variant)',
+ },
+ logoBright: {
+ control: 'text',
+ description: 'SVG logo markup (bright variant for dark mode)',
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const sampleSVG = `
+
+ ACME Corp
+ `
+
+const brightSVG = `
+
+ ACME Corp
+ `
+
+export const Default: Story = {
+ args: {
+ name: 'Acme Corporation',
+ logo: sampleSVG,
+ },
+}
+
+export const WithBrightVariant: Story = {
+ args: {
+ name: 'Acme Corporation',
+ logo: sampleSVG,
+ logoBright: brightSVG,
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export const Small: Story = {
+ args: {
+ name: 'Acme Corporation',
+ logo: sampleSVG,
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export const Medium: Story = {
+ args: {
+ name: 'Acme Corporation',
+ logo: sampleSVG,
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export const Large: Story = {
+ args: {
+ name: 'Acme Corporation',
+ logo: sampleSVG,
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
diff --git a/src/components/SponsorThankYou.stories.tsx b/src/components/SponsorThankYou.stories.tsx
new file mode 100644
index 00000000..c182f059
--- /dev/null
+++ b/src/components/SponsorThankYou.stories.tsx
@@ -0,0 +1,312 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ StarIcon,
+ RocketLaunchIcon,
+ CpuChipIcon,
+ CodeBracketIcon,
+ CommandLineIcon,
+ BoltIcon,
+} from '@heroicons/react/24/solid'
+import { QrCodeIcon } from '@heroicons/react/24/outline'
+import { InlineSvg } from './InlineSvg'
+
+// Mock data
+const mockSponsor = {
+ _id: 'sponsor-123',
+ name: 'Acme Corporation',
+ website: 'https://acme.example.com',
+ logo: 'ACME ',
+ logoBright:
+ 'ACME ',
+}
+
+const mockTier = {
+ title: 'Ingress',
+ tagline: 'Premium sponsorship tier',
+ tierType: 'standard' as const,
+}
+
+const FALLBACK_QR_CODE =
+ "data:image/svg+xml,%3csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3e%3crect width='120' height='120' fill='white'/%3e%3cpath d='M10,10 L20,10 L20,20 L10,20 Z M30,10 L40,10 L40,20 L30,20 Z M50,10 L60,10 L60,20 L50,20 Z M70,10 L80,10 L80,20 L70,20 Z M10,30 L20,30 L20,40 L10,40 Z M50,30 L60,30 L60,40 L50,40 Z M70,30 L80,30 L80,40 L70,40 Z M10,50 L20,50 L20,60 L10,60 Z M30,50 L40,50 L40,60 L30,60 Z M50,50 L60,50 L60,60 L50,60 Z M70,50 L80,50 L80,60 L70,60 Z M30,70 L40,70 L40,80 L30,80 Z M50,70 L60,70 L60,80 L50,80 Z' fill='black'/%3e%3c/svg%3e"
+
+type SponsorVariant =
+ | 'code-heroes'
+ | 'cloud-wizards'
+ | 'tech-ninjas'
+ | 'deploy-legends'
+ | 'kubernetes-masters'
+ | 'devops-rockstars'
+
+interface VariantConfig {
+ gradient: string
+ accentColor: string
+ icon: React.ComponentType<{ className?: string }>
+ headerText: string
+ footerText: string
+}
+
+const variantConfig: Record = {
+ 'code-heroes': {
+ gradient: 'from-brand-fresh-green to-brand-cloud-blue',
+ accentColor: 'text-white',
+ icon: CodeBracketIcon,
+ headerText: 'Code Heroes',
+ footerText:
+ 'Your support powers our community of cloud native developers and innovators',
+ },
+ 'cloud-wizards': {
+ gradient: 'from-brand-cloud-blue to-brand-sunbeam-yellow',
+ accentColor: 'text-white',
+ icon: RocketLaunchIcon,
+ headerText: 'Cloud Wizards',
+ footerText:
+ 'Casting spells in the cloud and making distributed systems magic happen',
+ },
+ 'tech-ninjas': {
+ gradient: 'from-purple-600 to-brand-fresh-green',
+ accentColor: 'text-white',
+ icon: CommandLineIcon,
+ headerText: 'Tech Ninjas',
+ footerText:
+ 'Stealthily deploying awesome tech and enabling developer superpowers',
+ },
+ 'deploy-legends': {
+ gradient: 'from-brand-nordic-purple to-brand-cloud-blue',
+ accentColor: 'text-white',
+ icon: RocketLaunchIcon,
+ headerText: 'Deploy Legends',
+ footerText: 'Legends who keep the cloud running and deployments flowing',
+ },
+ 'kubernetes-masters': {
+ gradient: 'from-brand-cloud-blue to-brand-fresh-green',
+ accentColor: 'text-white',
+ icon: CpuChipIcon,
+ headerText: 'Kubernetes Masters',
+ footerText: 'Orchestrating containers and mastering the cloud native way',
+ },
+ 'devops-rockstars': {
+ gradient: 'from-brand-sunbeam-yellow to-brand-nordic-purple',
+ accentColor: 'text-white',
+ icon: BoltIcon,
+ headerText: 'DevOps Rockstars',
+ footerText: 'Rocking automation and continuous delivery like true stars',
+ },
+}
+
+// Synchronous version for Storybook
+function SponsorThankYouStorybook({
+ sponsor,
+ tier,
+ variant = 'code-heroes',
+ className = '',
+ eventName = 'Cloud Native Days',
+ eventDate,
+ showCloudNativePattern = false,
+}: {
+ sponsor: typeof mockSponsor
+ tier: typeof mockTier
+ variant?: SponsorVariant
+ className?: string
+ eventName?: string
+ eventDate?: string
+ showCloudNativePattern?: boolean
+}) {
+ const config = variantConfig[variant]
+ const Icon = config.icon
+ const backgroundStyle = showCloudNativePattern
+ ? 'from-slate-900 via-blue-900 to-slate-900'
+ : config.gradient
+
+ return (
+
+ {showCloudNativePattern && (
+
+ )}
+
+
+
+
+
+
+
+
+ Thank you
+
+
+ {sponsor.logo && (
+
+
+
+ )}
+
+
+
+
+
+
+
+ {tier.title} Sponsor
+
+
+
+ {config.footerText}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Scan to learn more
+
+
+
+
+
+
+ )
+}
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Components/SponsorThankYou',
+ component: SponsorThankYouStorybook,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Animated thank-you card for sponsors featuring cloud native patterns with authentic CNCF project logos. Six visual variants (code-heroes, cloud-wizards, tech-ninjas, deploy-legends, kubernetes-masters, devops-rockstars) use the brand gradient system and focus/diffusion technology for atmospheric depth. Perfect for social media sharing and sponsor recognition.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: [
+ 'code-heroes',
+ 'cloud-wizards',
+ 'tech-ninjas',
+ 'deploy-legends',
+ 'kubernetes-masters',
+ 'devops-rockstars',
+ ],
+ description: 'Visual variant with different gradient and messaging',
+ },
+ showCloudNativePattern: {
+ control: 'boolean',
+ description: 'Show CNCF project icons pattern in background',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const CodeHeroes: Story = {
+ args: {
+ sponsor: mockSponsor,
+ tier: mockTier,
+ variant: 'code-heroes',
+ eventName: 'Cloud Native Days Norway',
+ eventDate: 'June 10-11, 2026 • Bergen',
+ },
+}
+
+export const CloudWizards: Story = {
+ args: {
+ sponsor: mockSponsor,
+ tier: mockTier,
+ variant: 'cloud-wizards',
+ eventName: 'Cloud Native Days Norway',
+ eventDate: 'June 10-11, 2026 • Bergen',
+ },
+}
+
+export const TechNinjas: Story = {
+ args: {
+ sponsor: mockSponsor,
+ tier: mockTier,
+ variant: 'tech-ninjas',
+ eventName: 'Cloud Native Days Norway',
+ eventDate: 'June 10-11, 2026 • Bergen',
+ },
+}
+
+export const DeployLegends: Story = {
+ args: {
+ sponsor: mockSponsor,
+ tier: mockTier,
+ variant: 'deploy-legends',
+ eventName: 'Cloud Native Days Norway',
+ eventDate: 'June 10-11, 2026 • Bergen',
+ },
+}
+
+export const KubernetesMasters: Story = {
+ args: {
+ sponsor: mockSponsor,
+ tier: mockTier,
+ variant: 'kubernetes-masters',
+ eventName: 'Cloud Native Days Norway',
+ eventDate: 'June 10-11, 2026 • Bergen',
+ },
+}
+
+export const DevOpsRockstars: Story = {
+ args: {
+ sponsor: mockSponsor,
+ tier: mockTier,
+ variant: 'devops-rockstars',
+ eventName: 'Cloud Native Days Norway',
+ eventDate: 'June 10-11, 2026 • Bergen',
+ },
+}
+
+export const WithCloudNativePattern: Story = {
+ args: {
+ sponsor: mockSponsor,
+ tier: mockTier,
+ variant: 'code-heroes',
+ eventName: 'Cloud Native Days Norway',
+ eventDate: 'June 10-11, 2026 • Bergen',
+ showCloudNativePattern: true,
+ },
+}
diff --git a/src/components/Sponsors.stories.tsx b/src/components/Sponsors.stories.tsx
new file mode 100644
index 00000000..0c1e5118
--- /dev/null
+++ b/src/components/Sponsors.stories.tsx
@@ -0,0 +1,166 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { Sponsors } from './Sponsors'
+import type { Conference } from '@/lib/conference/types'
+import type { ConferenceSponsor } from '@/lib/sponsor/types'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Components/Sponsors',
+ component: Sponsors,
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Public-facing sponsor showcase displaying active conference sponsors organized by tier. Features responsive grid layouts with tier-specific styling. Only shows sponsors with status="closed-won" to conference attendees. Supports optional CTA for prospective sponsors.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ showCTA: {
+ control: 'boolean',
+ description: 'Show call-to-action for prospective sponsors',
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+const mockConference: Partial = {
+ _id: 'conf-2026',
+ title: 'Cloud Native Days Norway 2026',
+ city: 'Bergen',
+ startDate: '2026-06-10',
+ endDate: '2026-06-11',
+ sponsorEmail: 'sponsor@cloudnativedays.no',
+ sponsorTiers: [
+ {
+ _id: 'tier-ingress',
+ title: 'Ingress',
+ tagline: 'Premium tier',
+ tierType: 'standard' as const,
+ price: [{ _key: 'price-1', amount: 100000, currency: 'NOK' }],
+ _createdAt: '2026-01-01T00:00:00Z',
+ _updatedAt: '2026-01-01T00:00:00Z',
+ soldOut: false,
+ mostPopular: false,
+ },
+ {
+ _id: 'tier-service',
+ title: 'Service',
+ tagline: 'Mid tier',
+ tierType: 'standard' as const,
+ price: [{ _key: 'price-2', amount: 50000, currency: 'NOK' }],
+ _createdAt: '2026-01-01T00:00:00Z',
+ _updatedAt: '2026-01-01T00:00:00Z',
+ soldOut: false,
+ mostPopular: false,
+ },
+ {
+ _id: 'tier-pod',
+ title: 'Pod',
+ tagline: 'Base tier',
+ tierType: 'standard' as const,
+ price: [{ _key: 'price-3', amount: 25000, currency: 'NOK' }],
+ _createdAt: '2026-01-01T00:00:00Z',
+ _updatedAt: '2026-01-01T00:00:00Z',
+ soldOut: false,
+ mostPopular: false,
+ },
+ ],
+}
+
+const mockSponsors = [
+ {
+ _id: 'cs-1',
+ sponsor: {
+ _id: 's-1',
+ name: 'Acme Corporation',
+ website: 'https://acme.example.com',
+ logo: 'ACME ',
+ },
+ tier: { title: 'Ingress', tagline: 'Premium' },
+ },
+ {
+ _id: 'cs-2',
+ sponsor: {
+ _id: 's-2',
+ name: 'Tech Solutions',
+ website: 'https://tech.example.com',
+ logo: 'TECH ',
+ },
+ tier: { title: 'Ingress', tagline: 'Premium' },
+ },
+ {
+ _id: 'cs-3',
+ sponsor: {
+ _id: 's-3',
+ name: 'Cloud Services Inc',
+ website: 'https://cloud.example.com',
+ logo: 'CLOUD ',
+ },
+ tier: { title: 'Service', tagline: 'Mid' },
+ },
+ {
+ _id: 'cs-4',
+ sponsor: {
+ _id: 's-4',
+ name: 'DevOps Masters',
+ website: 'https://devops.example.com',
+ logo: 'DevOps ',
+ },
+ tier: { title: 'Service', tagline: 'Mid' },
+ },
+ {
+ _id: 'cs-5',
+ sponsor: {
+ _id: 's-5',
+ name: 'Container Platform',
+ website: 'https://containers.example.com',
+ logo: 'CNTR ',
+ },
+ tier: { title: 'Pod', tagline: 'Base' },
+ },
+ {
+ _id: 'cs-6',
+ sponsor: {
+ _id: 's-6',
+ name: 'Kubernetes Experts',
+ website: 'https://k8s.example.com',
+ logo: 'K8s ',
+ },
+ tier: { title: 'Pod', tagline: 'Base' },
+ },
+]
+
+export const WithSponsors: Story = {
+ args: {
+ sponsors: mockSponsors as ConferenceSponsor[],
+ conference: mockConference as Conference,
+ showCTA: true,
+ },
+}
+
+export const WithoutCTA: Story = {
+ args: {
+ sponsors: mockSponsors as ConferenceSponsor[],
+ conference: mockConference as Conference,
+ showCTA: false,
+ },
+}
+
+export const NoSponsors: Story = {
+ args: {
+ sponsors: [],
+ conference: mockConference as Conference,
+ showCTA: true,
+ },
+}
+
+export const SingleTier: Story = {
+ args: {
+ sponsors: mockSponsors.slice(0, 2) as ConferenceSponsor[],
+ conference: mockConference as Conference,
+ showCTA: true,
+ },
+}
diff --git a/src/components/TalkPromotionCard.stories.tsx b/src/components/TalkPromotionCard.stories.tsx
new file mode 100644
index 00000000..05c8f242
--- /dev/null
+++ b/src/components/TalkPromotionCard.stories.tsx
@@ -0,0 +1,215 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { TalkPromotionCard } from './TalkPromotionCard'
+import { Format, Language, Level, Audience, Status } from '@/lib/proposal/types'
+import type { ProposalExisting } from '@/lib/proposal/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+import type { Speaker } from '@/lib/speaker/types'
+import { Flags } from '@/lib/speaker/types'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker],
+ },
+]
+
+const mockTopics = [
+ {
+ _id: 'topic-1',
+ _type: 'topic' as const,
+ title: 'Kubernetes',
+ slug: { current: 'kubernetes' },
+ color: '326CE5',
+ },
+]
+
+const createMockTalk = (
+ overrides: Partial = {},
+): ProposalExisting => ({
+ _id: 'talk-promo-1',
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Building Scalable Cloud Native Applications with Kubernetes',
+ description: convertStringToPortableTextBlocks(
+ 'In this talk, we will explore best practices for building and deploying scalable applications on Kubernetes. We will cover horizontal pod autoscaling, resource management, and observability patterns.',
+ ),
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [Audience.developer, Audience.architect],
+ status: Status.confirmed,
+ outline: '',
+ topics: mockTopics,
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ ...overrides,
+})
+
+const meta = {
+ title: 'Systems/Program/TalkPromotionCard',
+ component: TalkPromotionCard,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Promotional card for highlighting individual talks. Supports three variants: default, featured (with blue border and badge), and compact (smaller layout without description). Displays talk format, level, topic, speakers, and schedule info.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+ argTypes: {
+ variant: {
+ control: 'select',
+ options: ['default', 'featured', 'compact'],
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ talk: createMockTalk(),
+ ctaUrl: '/program/talk-1',
+ },
+}
+
+export const Featured: Story = {
+ args: {
+ talk: createMockTalk(),
+ variant: 'featured',
+ ctaUrl: '/program/talk-1',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Featured variant with a blue border, shadow, and a Featured badge.',
+ },
+ },
+ },
+}
+
+export const Compact: Story = {
+ args: {
+ talk: createMockTalk(),
+ variant: 'compact',
+ ctaUrl: '/program/talk-1',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Compact variant with smaller padding, no description, and duration shown inline.',
+ },
+ },
+ },
+}
+
+export const WithSlot: Story = {
+ args: {
+ talk: createMockTalk(),
+ slot: {
+ date: 'September 15, 2025',
+ time: '10:00 - 10:45',
+ location: 'Main Stage',
+ },
+ ctaUrl: '/program/talk-1',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Shows schedule slot info (time, date, location) in the footer instead of duration.',
+ },
+ },
+ },
+}
+
+export const LightningTalk: Story = {
+ args: {
+ talk: createMockTalk({
+ title: '5 Tips for Better Kubernetes Debugging',
+ format: Format.lightning_10,
+ level: Level.beginner,
+ speakers: [mockSpeakers[0]],
+ }),
+ ctaUrl: '/program/talk-lightning',
+ },
+}
+
+export const Workshop: Story = {
+ args: {
+ talk: createMockTalk({
+ title: 'Hands-on Kubernetes Workshop',
+ format: Format.workshop_120,
+ level: Level.beginner,
+ speakers: mockSpeakers,
+ }),
+ variant: 'featured',
+ ctaText: 'Register Now',
+ ctaUrl: '/workshops/k8s',
+ },
+}
+
+export const AllVariants: Story = {
+ args: {
+ talk: createMockTalk(),
+ },
+ render: () => (
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'Side-by-side comparison of all three card variants.',
+ },
+ },
+ },
+}
diff --git a/src/components/ThemeToggle.stories.tsx b/src/components/ThemeToggle.stories.tsx
new file mode 100644
index 00000000..0a65914b
--- /dev/null
+++ b/src/components/ThemeToggle.stories.tsx
@@ -0,0 +1,141 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ThemeToggle } from './ThemeToggle'
+
+const meta = {
+ title: 'Components/Layout/ThemeToggle',
+ component: ThemeToggle,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const InContext: Story = {
+ render: () => (
+
+
+ Toggle theme:
+
+
+
+ ),
+}
+
+export const InHeader: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+ ThemeToggle Component
+
+
+ A button component for switching between light and dark themes. Uses
+ next-themes for theme management and handles hydration properly.
+
+
+
+
+ Features
+
+
+
+ Hydration safe: Shows placeholder until mounted to
+ avoid flash
+
+
+ Accessible: Proper ARIA labels describe current and
+ target state
+
+
+ System theme aware: Uses resolvedTheme to handle
+ system preference
+
+
+ Visual feedback: Sun/Moon icons with hover states
+
+
+
+
+
+
+ Interactive Demo
+
+
+
+
+
+ Click the toggle to switch themes
+
+
+
+
+
+ Styling
+
+
+ The toggle uses a pill shape with subtle shadow and backdrop blur for
+ a modern, cohesive look that works well in both light and dark themes.
+
+
+
+
+ ),
+}
diff --git a/src/components/TypewriterEffect.stories.tsx b/src/components/TypewriterEffect.stories.tsx
new file mode 100644
index 00000000..1716ba44
--- /dev/null
+++ b/src/components/TypewriterEffect.stories.tsx
@@ -0,0 +1,49 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { TypewriterEffect } from './TypewriterEffect'
+
+const meta = {
+ title: 'Components/Data Display/TypewriterEffect',
+ component: TypewriterEffect,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ prefix: 'We love ',
+ words: ['Kubernetes.', 'Cloud Native.', 'Open Source.', 'Community.'],
+ typingSpeed: 100,
+ deletingSpeed: 50,
+ pauseDuration: 2000,
+ },
+}
+
+export const FastTyping: Story = {
+ args: {
+ prefix: 'Build with ',
+ words: ['containers.', 'microservices.', 'observability.'],
+ typingSpeed: 40,
+ deletingSpeed: 20,
+ pauseDuration: 1000,
+ },
+}
+
+export const AnimationDisabled: Story = {
+ args: {
+ prefix: 'We love ',
+ words: ['Kubernetes.', 'Cloud Native.', 'Open Source.'],
+ animation: false,
+ },
+}
diff --git a/src/components/VideoEmbed.stories.tsx b/src/components/VideoEmbed.stories.tsx
new file mode 100644
index 00000000..95325f38
--- /dev/null
+++ b/src/components/VideoEmbed.stories.tsx
@@ -0,0 +1,113 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { VideoEmbed } from './VideoEmbed'
+
+const meta = {
+ title: 'Components/Data Display/VideoEmbed',
+ component: VideoEmbed,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Privacy-preserving video embed supporting YouTube and Vimeo. Uses youtube-nocookie.com for YouTube and dnt=1 for Vimeo to minimize tracking.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ url: {
+ control: 'text',
+ description: 'YouTube or Vimeo video URL',
+ },
+ title: {
+ control: 'text',
+ description: 'Accessible title for the iframe',
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const YouTube: Story = {
+ args: {
+ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ title: 'Example YouTube Video',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export const Vimeo: Story = {
+ args: {
+ url: 'https://vimeo.com/148751763',
+ title: 'Example Vimeo Video',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export const InvalidUrl: Story = {
+ args: {
+ url: 'https://example.com/not-a-video',
+ title: 'Invalid Video',
+ },
+}
+
+export const PrivacyFeatures: Story = {
+ args: {
+ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ title: 'Example Video',
+ },
+ render: () => (
+
+
+
+ Privacy-First Video Embedding
+
+
+
+
+ YouTube
+
+
+ • Uses youtube-nocookie.com domain
+ • No tracking cookies until playback
+ • GDPR-friendly implementation
+
+
+
+
+ Vimeo
+
+
+ • Uses dnt=1 parameter
+ • Disables tracking and analytics
+ • Respects Do Not Track
+
+
+
+
+
+
+
+ Example embed (YouTube):
+
+
+
+
+ ),
+}
diff --git a/src/components/admin/CollapsibleSection.stories.tsx b/src/components/admin/CollapsibleSection.stories.tsx
new file mode 100644
index 00000000..87c3cecf
--- /dev/null
+++ b/src/components/admin/CollapsibleSection.stories.tsx
@@ -0,0 +1,117 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { CollapsibleSection } from './CollapsibleSection'
+
+const meta = {
+ title: 'Components/Data Display/CollapsibleSection',
+ component: CollapsibleSection,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'An expandable/collapsible section with a header and toggle button. Used to organize content in admin pages.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ title: 'Speaker Details',
+ children: (
+
+
+
+
+ Email
+
+
jane@example.com
+
+
+ ),
+ },
+}
+
+export const DefaultOpen: Story = {
+ args: {
+ title: 'Proposal Information',
+ defaultOpen: true,
+ children: (
+
+
+ This section is expanded by default and contains additional details
+ about the proposal.
+
+
+ ),
+ },
+}
+
+export const WithLongContent: Story = {
+ args: {
+ title: 'Review History',
+ defaultOpen: true,
+ children: (
+
+ {['Review 1', 'Review 2', 'Review 3', 'Review 4'].map((review, i) => (
+
+
+ {review}
+
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+
+
+ ))}
+
+ ),
+ },
+}
+
+export const MultipleSections: Story = {
+ args: {
+ title: 'Multiple Sections Example',
+ children: null,
+ },
+ render: () => (
+
+
+
+
+ Basic speaker information and contact details.
+
+
+
+
+
+
+ Previous talks and conference appearances.
+
+
+
+
+
+
+ Internal notes and organizer comments.
+
+
+
+
+ ),
+}
diff --git a/src/components/admin/ConfirmationModal.stories.tsx b/src/components/admin/ConfirmationModal.stories.tsx
new file mode 100644
index 00000000..adc44d9d
--- /dev/null
+++ b/src/components/admin/ConfirmationModal.stories.tsx
@@ -0,0 +1,80 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { fn } from 'storybook/test'
+import { ConfirmationModal } from './ConfirmationModal'
+
+const meta = {
+ title: 'Components/Feedback/ConfirmationModal',
+ component: ConfirmationModal,
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'A reusable confirmation dialog with support for danger, warning, and info variants. Used throughout the admin interface for destructive actions.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ args: {
+ onClose: fn(),
+ onConfirm: fn(),
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Danger: Story = {
+ args: {
+ isOpen: true,
+ title: 'Delete proposal?',
+ message:
+ 'This action cannot be undone. The proposal and all associated reviews will be permanently deleted.',
+ confirmButtonText: 'Delete',
+ variant: 'danger',
+ },
+}
+
+export const Warning: Story = {
+ args: {
+ isOpen: true,
+ title: 'Reject proposal?',
+ message:
+ 'This will send a rejection notification to the speaker. You can change the decision later if needed.',
+ confirmButtonText: 'Reject',
+ variant: 'warning',
+ },
+}
+
+export const Info: Story = {
+ args: {
+ isOpen: true,
+ title: 'Accept proposal?',
+ message:
+ 'This will send an acceptance notification to the speaker and add the talk to the schedule.',
+ confirmButtonText: 'Accept',
+ variant: 'info',
+ },
+}
+
+export const Loading: Story = {
+ args: {
+ isOpen: true,
+ title: 'Processing...',
+ message: 'Please wait while we process your request.',
+ confirmButtonText: 'Confirm',
+ variant: 'info',
+ isLoading: true,
+ },
+}
+
+export const CustomButtons: Story = {
+ args: {
+ isOpen: true,
+ title: 'Withdraw talk?',
+ message: 'Are you sure you want to withdraw this talk from the conference?',
+ confirmButtonText: 'Yes, withdraw',
+ cancelButtonText: 'No, keep it',
+ variant: 'warning',
+ },
+}
diff --git a/src/components/admin/EmailModal.stories.tsx b/src/components/admin/EmailModal.stories.tsx
new file mode 100644
index 00000000..5b424476
--- /dev/null
+++ b/src/components/admin/EmailModal.stories.tsx
@@ -0,0 +1,114 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { fn } from 'storybook/test'
+import { EmailModal } from './EmailModal'
+import { NotificationProvider } from './NotificationProvider'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Email/EmailModal',
+ component: EmailModal,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'Generic email composition modal used as the base for all email sending flows. Features a rich text editor (PortableText), auto-save drafts to localStorage, email preview, template selector slot, and configurable fields. Used by SponsorIndividualEmailModal and SponsorDiscountEmailModal.',
+ },
+ },
+ },
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+ args: {
+ isOpen: true,
+ onClose: fn(),
+ onSend: fn(),
+ title: 'Compose Email',
+ recipientInfo: 'maria@example.com',
+ fromAddress: 'conference@cloudnativebergen.no',
+ submitButtonText: 'Send Email',
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Basic email modal with To, From, Subject fields and a rich text editor.',
+ },
+ },
+ },
+}
+
+export const WithContext: Story = {
+ args: {
+ title: 'Send Sponsor Email',
+ recipientInfo: (
+
+
+ Maria Jensen <maria@techgiant.com>
+
+
+ Erik Olsen <erik@techgiant.com>
+
+
+ ),
+ contextInfo: 'Sponsor: TechGiant Corp • Gold tier',
+ helpText:
+ 'Templates: {{{SPONSOR_NAME}}}, {{{CONTACT_NAME}}}, {{{EVENT_NAME}}}',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'With multiple recipients displayed as badges, context info, and help text showing available template variables.',
+ },
+ },
+ },
+}
+
+export const WithTicketUrl: Story = {
+ args: {
+ title: 'Send Discount Code Email',
+ contextInfo: 'Discount code: SPONSOR2025 • Gold tier',
+ ticketUrl: 'https://cloudnativebergen.no/tickets',
+ onTicketUrlChange: fn(),
+ initialValues: {
+ subject: 'Your Cloud Native Days Sponsor Discount Code',
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'When ticketUrl and onTicketUrlChange are provided, an additional Tickets field appears for the ticket registration URL.',
+ },
+ },
+ },
+}
+
+export const WithInitialValues: Story = {
+ args: {
+ title: 'Follow Up Email',
+ initialValues: {
+ subject: 'Following up: Sponsorship for Cloud Native Days Bergen 2025',
+ message:
+ 'Dear TechGiant team,\n\nThank you for your interest in sponsoring our event.\n\nBest regards,\nThe Conference Team',
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Pre-populated subject and message body from initialValues.',
+ },
+ },
+ },
+}
diff --git a/src/components/admin/EmailModal.tsx b/src/components/admin/EmailModal.tsx
index 8858a92d..d94fa1c7 100644
--- a/src/components/admin/EmailModal.tsx
+++ b/src/components/admin/EmailModal.tsx
@@ -430,7 +430,7 @@ export function EmailModal({
To:
-
+
{typeof recipientInfo === 'string' ? (
{recipientInfo}
@@ -526,7 +526,7 @@ export function EmailModal({
-
+
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ title: 'Something went wrong',
+ message: 'An unexpected error occurred. Please try again later.',
+ },
+}
+
+export const NotFound: Story = {
+ args: {
+ title: 'Proposal not found',
+ message:
+ 'The proposal you are looking for does not exist or has been deleted.',
+ backLink: {
+ href: '/admin/proposals',
+ label: 'Back to Proposals',
+ },
+ },
+}
+
+export const Unauthorized: Story = {
+ args: {
+ title: 'Access denied',
+ message: 'You do not have permission to view this page.',
+ backLink: {
+ href: '/admin',
+ label: 'Back to Dashboard',
+ },
+ },
+}
+
+export const WithoutHomeLink: Story = {
+ args: {
+ title: 'Conference not found',
+ message: 'No conference is configured for this domain.',
+ homeLink: false,
+ },
+}
+
+export const CustomBackLink: Story = {
+ args: {
+ title: 'Speaker not found',
+ message: 'The speaker profile could not be loaded.',
+ backLink: {
+ href: '/admin/speakers',
+ label: 'View all speakers',
+ },
+ },
+}
+
+export const ServerError: Story = {
+ args: {
+ title: 'Server Error',
+ message:
+ 'We encountered an internal server error. Our team has been notified and is working on a fix.',
+ backLink: {
+ href: '/admin',
+ label: 'Return to Dashboard',
+ },
+ },
+}
diff --git a/src/components/admin/ErrorDisplay.tsx b/src/components/admin/ErrorDisplay.tsx
index 7b87c230..435cc100 100644
--- a/src/components/admin/ErrorDisplay.tsx
+++ b/src/components/admin/ErrorDisplay.tsx
@@ -21,7 +21,7 @@ export function ErrorDisplay({
{backLink.label}
diff --git a/src/components/admin/FeaturedSpeakersManager.stories.tsx b/src/components/admin/FeaturedSpeakersManager.stories.tsx
new file mode 100644
index 00000000..42e672af
--- /dev/null
+++ b/src/components/admin/FeaturedSpeakersManager.stories.tsx
@@ -0,0 +1,163 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { FeaturedSpeakersManager } from './FeaturedSpeakersManager'
+import { http, HttpResponse } from 'msw'
+
+const mockFeaturedSpeakers = [
+ {
+ _id: 'speaker-1',
+ name: 'Anna Hansen',
+ title: 'Platform Engineer at TechCorp',
+ image: null,
+ talks: [{ title: 'Building Kubernetes Operators' }],
+ },
+ {
+ _id: 'speaker-2',
+ name: 'Erik Larsen',
+ title: 'SRE Lead at CloudScale',
+ image: null,
+ talks: [{ title: 'Observability at Scale' }],
+ },
+]
+
+const mockAvailableSpeakers = [
+ {
+ _id: 'speaker-3',
+ name: 'Sofia Berg',
+ title: 'DevOps Architect',
+ image: null,
+ proposals: [{ title: 'GitOps Best Practices' }],
+ },
+ {
+ _id: 'speaker-4',
+ name: 'Magnus Olsen',
+ title: 'Cloud Native Engineer',
+ image: null,
+ proposals: [{ title: 'Service Mesh Deep Dive' }],
+ },
+]
+
+const handlers = [
+ http.get('/api/trpc/featured.featuredSpeakers', () => {
+ return HttpResponse.json({
+ result: { data: mockFeaturedSpeakers },
+ })
+ }),
+ http.get('/api/trpc/speakers.search', () => {
+ return HttpResponse.json({
+ result: { data: mockAvailableSpeakers },
+ })
+ }),
+ http.get('/api/trpc/featured.summary', () => {
+ return HttpResponse.json({
+ result: { data: { speakersCount: 2, talksCount: 2 } },
+ })
+ }),
+ http.post('/api/trpc/featured.addSpeaker', () => {
+ return HttpResponse.json({ result: { data: { success: true } } })
+ }),
+ http.post('/api/trpc/featured.removeSpeaker', () => {
+ return HttpResponse.json({ result: { data: { success: true } } })
+ }),
+]
+
+const meta: Meta
= {
+ title: 'Systems/Speakers/Admin/FeaturedSpeakersManager',
+ component: FeaturedSpeakersManager,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Admin component for managing featured speakers on the conference landing page. Allows searching for speakers with confirmed talks and adding them to the featured section.',
+ },
+ },
+ msw: { handlers },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
+
+export const WithClassName: Story = {
+ args: {
+ className: 'border-2 border-blue-500',
+ },
+}
+
+export const Empty: Story = {
+ args: {},
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/featured.featuredSpeakers', () => {
+ return HttpResponse.json({
+ result: { data: [] },
+ })
+ }),
+ http.get('/api/trpc/speakers.search', () => {
+ return HttpResponse.json({
+ result: { data: mockAvailableSpeakers },
+ })
+ }),
+ http.post('/api/trpc/featured.addSpeaker', () => {
+ return HttpResponse.json({ result: { data: { success: true } } })
+ }),
+ ],
+ },
+ },
+}
+
+export const ManyFeaturedSpeakers: Story = {
+ args: {},
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/featured.featuredSpeakers', () => {
+ return HttpResponse.json({
+ result: {
+ data: [
+ ...mockFeaturedSpeakers,
+ {
+ _id: 'speaker-5',
+ name: 'Ingrid Nilsen',
+ title: 'Security Engineer',
+ image: null,
+ talks: [{ title: 'Cloud Security Best Practices' }],
+ },
+ {
+ _id: 'speaker-6',
+ name: 'Lars Andersen',
+ title: 'Platform Lead',
+ image: null,
+ talks: [
+ { title: 'Building Internal Platforms' },
+ { title: 'Developer Experience Workshop' },
+ ],
+ },
+ ],
+ },
+ })
+ }),
+ http.get('/api/trpc/speakers.search', () => {
+ return HttpResponse.json({
+ result: { data: [] },
+ })
+ }),
+ http.post('/api/trpc/featured.removeSpeaker', () => {
+ return HttpResponse.json({ result: { data: { success: true } } })
+ }),
+ ],
+ },
+ },
+}
diff --git a/src/components/admin/FeaturedTalksManager.stories.tsx b/src/components/admin/FeaturedTalksManager.stories.tsx
new file mode 100644
index 00000000..f3e3aefe
--- /dev/null
+++ b/src/components/admin/FeaturedTalksManager.stories.tsx
@@ -0,0 +1,193 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { FeaturedTalksManager } from './FeaturedTalksManager'
+import { http, HttpResponse } from 'msw'
+
+const mockFeaturedTalks = [
+ {
+ _id: 'talk-1',
+ title: 'Building Production-Ready Kubernetes Operators',
+ description:
+ 'Learn how to build robust Kubernetes operators using the Operator SDK.',
+ format: 'presentation_45',
+ status: 'confirmed',
+ speakers: [{ name: 'Anna Hansen' }],
+ topics: [
+ { _id: 'topic-1', title: 'Kubernetes', color: '#326CE5' },
+ { _id: 'topic-2', title: 'Operators', color: '#10B981' },
+ ],
+ },
+ {
+ _id: 'talk-2',
+ title: 'Observability at Scale with OpenTelemetry',
+ description:
+ 'A deep dive into implementing distributed tracing across microservices.',
+ format: 'presentation_25',
+ status: 'confirmed',
+ speakers: [{ name: 'Erik Larsen' }],
+ topics: [{ _id: 'topic-3', title: 'Observability', color: '#FFE66D' }],
+ },
+]
+
+const mockAvailableTalks = [
+ {
+ _id: 'talk-3',
+ title: 'GitOps: The Path to Continuous Deployment',
+ description:
+ 'Implementing GitOps patterns with Flux and ArgoCD for reliable deployments.',
+ format: 'presentation_45',
+ status: 'confirmed',
+ speakers: [{ name: 'Sofia Berg' }],
+ topics: [
+ { _id: 'topic-4', title: 'GitOps', color: '#FC5185' },
+ { _id: 'topic-5', title: 'DevOps', color: '#FF6B6B' },
+ ],
+ },
+ {
+ _id: 'talk-4',
+ title: 'Service Mesh Security Deep Dive',
+ description: 'Understanding mTLS, authorization policies, and more.',
+ format: 'presentation_25',
+ status: 'accepted',
+ speakers: [{ name: 'Magnus Olsen' }],
+ topics: [
+ { _id: 'topic-6', title: 'Security', color: '#4ECDC4' },
+ { _id: 'topic-7', title: 'Service Mesh', color: '#3FC1C9' },
+ ],
+ },
+]
+
+const handlers = [
+ http.get('/api/trpc/featured.featuredTalks', () => {
+ return HttpResponse.json({
+ result: { data: mockFeaturedTalks },
+ })
+ }),
+ http.get('/api/trpc/proposals.searchTalks', () => {
+ return HttpResponse.json({
+ result: { data: mockAvailableTalks },
+ })
+ }),
+ http.get('/api/trpc/featured.summary', () => {
+ return HttpResponse.json({
+ result: { data: { speakersCount: 2, talksCount: 2 } },
+ })
+ }),
+ http.post('/api/trpc/featured.addTalk', () => {
+ return HttpResponse.json({ result: { data: { success: true } } })
+ }),
+ http.post('/api/trpc/featured.removeTalk', () => {
+ return HttpResponse.json({ result: { data: { success: true } } })
+ }),
+]
+
+const meta: Meta = {
+ title: 'Systems/Proposals/Admin/FeaturedTalksManager',
+ component: FeaturedTalksManager,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Admin component for managing featured talks on the conference landing page. Allows searching for confirmed/accepted talks and adding them to the featured section. Shows talk details including speakers, topics, and format.',
+ },
+ },
+ msw: { handlers },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
+
+export const WithClassName: Story = {
+ args: {
+ className: 'border-2 border-blue-500',
+ },
+}
+
+export const Empty: Story = {
+ args: {},
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/featured.featuredTalks', () => {
+ return HttpResponse.json({
+ result: { data: [] },
+ })
+ }),
+ http.get('/api/trpc/proposals.searchTalks', () => {
+ return HttpResponse.json({
+ result: { data: mockAvailableTalks },
+ })
+ }),
+ http.post('/api/trpc/featured.addTalk', () => {
+ return HttpResponse.json({ result: { data: { success: true } } })
+ }),
+ ],
+ },
+ },
+}
+
+export const ManyFeaturedTalks: Story = {
+ args: {},
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/featured.featuredTalks', () => {
+ return HttpResponse.json({
+ result: {
+ data: [
+ ...mockFeaturedTalks,
+ {
+ _id: 'talk-5',
+ title: 'Cloud Security Best Practices',
+ description:
+ 'Essential security patterns for cloud native applications.',
+ format: 'presentation_45',
+ status: 'confirmed',
+ speakers: [{ name: 'Ingrid Nilsen' }],
+ topics: [
+ { _id: 'topic-8', title: 'Security', color: '#4ECDC4' },
+ ],
+ },
+ {
+ _id: 'talk-6',
+ title: 'Platform Engineering Workshop',
+ description: 'Hands-on workshop building internal platforms.',
+ format: 'workshop_180',
+ status: 'confirmed',
+ speakers: [{ name: 'Lars Andersen' }],
+ topics: [
+ {
+ _id: 'topic-9',
+ title: 'Platform Engineering',
+ color: '#95D5B2',
+ },
+ ],
+ },
+ ],
+ },
+ })
+ }),
+ http.get('/api/trpc/proposals.searchTalks', () => {
+ return HttpResponse.json({
+ result: { data: [] },
+ })
+ }),
+ http.post('/api/trpc/featured.removeTalk', () => {
+ return HttpResponse.json({ result: { data: { success: true } } })
+ }),
+ ],
+ },
+ },
+}
diff --git a/src/components/admin/FilterDropdown.stories.tsx b/src/components/admin/FilterDropdown.stories.tsx
new file mode 100644
index 00000000..c444630c
--- /dev/null
+++ b/src/components/admin/FilterDropdown.stories.tsx
@@ -0,0 +1,205 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import { FilterDropdown, FilterOption } from './FilterDropdown'
+
+const meta = {
+ title: 'Components/Forms/FilterDropdown',
+ component: FilterDropdown,
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'A dropdown menu for filter options with checkbox/radio selections. Automatically detects position and drops up when near the bottom of the viewport.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ label: 'Status',
+ activeCount: 0,
+ children: (
+ <>
+ {}} checked={false}>
+ Submitted
+
+ {}} checked={false}>
+ Accepted
+
+ {}} checked={false}>
+ Rejected
+
+ >
+ ),
+ },
+}
+
+export const WithActiveFilters: Story = {
+ args: {
+ label: 'Format',
+ activeCount: 2,
+ children: (
+ <>
+ {}} checked={true}>
+ Lightning Talk (10 min)
+
+ {}} checked={true}>
+ Presentation (45 min)
+
+ {}} checked={false}>
+ Workshop (120 min)
+
+ >
+ ),
+ },
+}
+
+export const RadioOptions: Story = {
+ args: {
+ label: 'Sort by',
+ activeCount: 1,
+ children: (
+ <>
+ {}} checked={true} type="radio">
+ Newest first
+
+ {}} checked={false} type="radio">
+ Oldest first
+
+ {}} checked={false} type="radio">
+ Highest rated
+
+ >
+ ),
+ },
+}
+
+export const WideDropdown: Story = {
+ args: {
+ label: 'Topics',
+ activeCount: 0,
+ width: 'wide',
+ children: (
+ <>
+ {}} checked={false}>
+ Kubernetes & Container Orchestration
+
+ {}} checked={false}>
+ Cloud Native Security
+
+ {}} checked={false}>
+ Observability & Monitoring
+
+ >
+ ),
+ },
+}
+
+export const RightAligned: Story = {
+ args: {
+ label: 'Actions',
+ activeCount: 0,
+ position: 'right',
+ children: (
+ <>
+ {}} checked={false}>
+ Export CSV
+
+ {}} checked={false}>
+ Send emails
+
+ >
+ ),
+ },
+}
+
+export const SmallSize: Story = {
+ args: {
+ label: 'Level',
+ activeCount: 1,
+ size: 'sm',
+ children: (
+ <>
+ {}} checked={false}>
+ Beginner
+
+ {}} checked={true}>
+ Intermediate
+
+ {}} checked={false}>
+ Advanced
+
+ >
+ ),
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ label: 'Disabled Filter',
+ activeCount: 0,
+ disabled: true,
+ children: (
+ <>
+ {}} checked={false}>
+ Option 1
+
+ {}} checked={false}>
+ Option 2
+
+ >
+ ),
+ },
+}
+
+export const Interactive: Story = {
+ args: {
+ label: 'Status',
+ activeCount: 0,
+ children: null,
+ },
+ render: () => {
+ const InteractiveDemo = () => {
+ const [selected, setSelected] = useState([])
+
+ const toggleOption = (option: string) => {
+ setSelected((prev) =>
+ prev.includes(option)
+ ? prev.filter((o) => o !== option)
+ : [...prev, option],
+ )
+ }
+
+ const options = ['Submitted', 'Accepted', 'Confirmed', 'Rejected']
+
+ return (
+
+ {options.map((option) => (
+ toggleOption(option)}
+ checked={selected.includes(option)}
+ keepOpen
+ >
+ {option}
+
+ ))}
+
+ )
+ }
+ return
+ },
+}
diff --git a/src/components/admin/LoadingSkeleton.stories.tsx b/src/components/admin/LoadingSkeleton.stories.tsx
new file mode 100644
index 00000000..9e4a1858
--- /dev/null
+++ b/src/components/admin/LoadingSkeleton.stories.tsx
@@ -0,0 +1,128 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ SkeletonCard,
+ SkeletonTable,
+ SkeletonSearchResult,
+ SkeletonProposalDetail,
+ SkeletonModal,
+ SkeletonGrid,
+} from './LoadingSkeleton'
+
+const meta = {
+ title: 'Components/Feedback/LoadingSkeleton',
+ component: SkeletonCard,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'A collection of skeleton loading components for various UI patterns. Use these to show loading states while content is being fetched.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Card: Story = {
+ args: {
+ showHeader: true,
+ rows: 3,
+ },
+}
+
+export const CardWithoutHeader: Story = {
+ args: {
+ showHeader: false,
+ rows: 4,
+ },
+}
+
+export const Table: Story = {
+ render: () => ,
+ args: {},
+}
+
+export const WideTable: Story = {
+ render: () => ,
+ args: {},
+}
+
+export const SearchResult: Story = {
+ render: () => ,
+ args: {},
+}
+
+export const ProposalDetail: Story = {
+ render: () => ,
+ args: {},
+}
+
+export const Modal: Story = {
+ render: () => (
+
+
+
+ ),
+ args: {},
+}
+
+export const ModalWithoutFooter: Story = {
+ render: () => (
+
+
+
+ ),
+ args: {},
+}
+
+export const Grid: Story = {
+ render: () => ,
+ args: {},
+}
+
+export const CompactGrid: Story = {
+ render: () => ,
+ args: {},
+}
+
+export const AllVariants: Story = {
+ render: () => (
+
+
+
+ Card Skeleton
+
+
+
+
+
+
+
+
+ Table Skeleton
+
+
+
+
+
+
+ Search Results Skeleton
+
+
+
+
+
+
+
+
+ Grid Skeleton
+
+
+
+
+ ),
+ args: {},
+}
diff --git a/src/components/admin/MemeGeneratorWithDownload.tsx b/src/components/admin/MemeGeneratorWithDownload.tsx
index fdd9ad90..a705c509 100644
--- a/src/components/admin/MemeGeneratorWithDownload.tsx
+++ b/src/components/admin/MemeGeneratorWithDownload.tsx
@@ -1,7 +1,7 @@
'use client'
import { MemeGenerator } from './MemeGenerator'
-import { DownloadSpeakerImage } from '../branding/DownloadSpeakerImage'
+import { DownloadableImage } from '../common/DownloadableImage'
import type { ConferenceLogos } from '../common/DashboardLayout'
interface MemeGeneratorWithDownloadProps {
@@ -19,7 +19,7 @@ export function MemeGeneratorWithDownload({
(
- {node}
+ {node}
)}
/>
)
diff --git a/src/components/admin/PhotoGalleryWithDownload.tsx b/src/components/admin/PhotoGalleryWithDownload.tsx
index 52c3be6e..6261bf0f 100644
--- a/src/components/admin/PhotoGalleryWithDownload.tsx
+++ b/src/components/admin/PhotoGalleryWithDownload.tsx
@@ -1,7 +1,7 @@
'use client'
import { PhotoGalleryBuilder } from './PhotoGalleryBuilder'
-import { DownloadSpeakerImage } from '../branding/DownloadSpeakerImage'
+import { DownloadableImage } from '../common/DownloadableImage'
import type { GalleryImageWithSpeakers } from '@/lib/gallery/types'
import type { ConferenceLogos } from '../common/DashboardLayout'
@@ -27,7 +27,7 @@ export function PhotoGalleryWithDownload({
conferenceTitle={conferenceTitle}
conferenceLogos={conferenceLogos}
wrapPreview={(node) => (
- {node}
+ {node}
)}
/>
)
diff --git a/src/components/admin/ProposalCard.stories.tsx b/src/components/admin/ProposalCard.stories.tsx
new file mode 100644
index 00000000..505e557f
--- /dev/null
+++ b/src/components/admin/ProposalCard.stories.tsx
@@ -0,0 +1,278 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalCard } from './ProposalCard'
+import {
+ ProposalExisting,
+ Format,
+ Language,
+ Level,
+ Audience,
+ Status,
+} from '@/lib/proposal/types'
+import { Speaker, Flags } from '@/lib/speaker/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker],
+ },
+]
+
+import { Topic } from '@/lib/topic/types'
+
+const mockTopics: Topic[] = [
+ {
+ _id: 'topic-1',
+ _type: 'topic',
+ title: 'Kubernetes',
+ color: '326CE5',
+ slug: { current: 'kubernetes' },
+ },
+ {
+ _id: 'topic-2',
+ _type: 'topic',
+ title: 'DevOps',
+ color: 'FF6B35',
+ slug: { current: 'devops' },
+ },
+]
+
+const createMockProposal = (
+ overrides: Partial = {},
+): ProposalExisting => ({
+ _id: 'proposal-1',
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Building Scalable Kubernetes Applications',
+ description: convertStringToPortableTextBlocks(
+ 'Learn how to build and deploy scalable applications on Kubernetes with best practices for production environments.',
+ ),
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [Audience.developer, Audience.operator],
+ status: Status.submitted,
+ outline: 'Introduction, Architecture, Demo, Q&A',
+ topics: mockTopics,
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _type: 'reference', _ref: 'conf-1' },
+ reviews: [
+ {
+ _id: 'review-1',
+ _rev: '1',
+ _createdAt: '2024-01-15T00:00:00Z',
+ _updatedAt: '2024-01-15T00:00:00Z',
+ comment: 'Great proposal!',
+ score: { content: 4, relevance: 4, speaker: 4 },
+ reviewer: { _type: 'reference', _ref: 'reviewer-1' },
+ proposal: { _type: 'reference', _ref: 'proposal-1' },
+ },
+ {
+ _id: 'review-2',
+ _rev: '1',
+ _createdAt: '2024-01-16T00:00:00Z',
+ _updatedAt: '2024-01-16T00:00:00Z',
+ comment: 'Very relevant topic.',
+ score: { content: 5, relevance: 5, speaker: 5 },
+ reviewer: { _type: 'reference', _ref: 'reviewer-2' },
+ proposal: { _type: 'reference', _ref: 'proposal-1' },
+ },
+ ],
+ ...overrides,
+})
+
+const meta: Meta = {
+ title: 'Systems/Proposals/Admin/ProposalCard',
+ component: ProposalCard,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Displays a proposal summary card with speaker avatars, status badge, rating, and metadata. Used in the admin proposal list view.',
+ },
+ },
+ },
+ argTypes: {
+ isSelected: {
+ control: 'boolean',
+ description: 'Whether the card is selected',
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Submitted: Story = {
+ args: {
+ proposal: createMockProposal({ status: Status.submitted }),
+ href: '/admin/proposals/proposal-1',
+ },
+}
+
+export const Draft: Story = {
+ args: {
+ proposal: createMockProposal({
+ status: Status.draft,
+ title: 'Introduction to GitOps',
+ format: Format.lightning_10,
+ level: Level.beginner,
+ reviews: [],
+ }),
+ href: '/admin/proposals/proposal-2',
+ },
+}
+
+export const Accepted: Story = {
+ args: {
+ proposal: createMockProposal({
+ status: Status.accepted,
+ title: 'Advanced Service Mesh Patterns',
+ format: Format.presentation_40,
+ level: Level.advanced,
+ }),
+ href: '/admin/proposals/proposal-3',
+ },
+}
+
+export const Confirmed: Story = {
+ args: {
+ proposal: createMockProposal({
+ status: Status.confirmed,
+ title: 'Kubernetes Security Deep Dive',
+ }),
+ href: '/admin/proposals/proposal-4',
+ },
+}
+
+export const Rejected: Story = {
+ args: {
+ proposal: createMockProposal({
+ status: Status.rejected,
+ title: 'Basic Docker Introduction',
+ level: Level.beginner,
+ }),
+ href: '/admin/proposals/proposal-5',
+ },
+}
+
+export const Workshop: Story = {
+ args: {
+ proposal: createMockProposal({
+ title: 'Hands-on Kubernetes Workshop',
+ format: Format.workshop_120,
+ audiences: [Audience.developer, Audience.devopsEngineer],
+ }),
+ href: '/admin/proposals/proposal-6',
+ },
+}
+
+export const SingleSpeaker: Story = {
+ args: {
+ proposal: createMockProposal({
+ speakers: [mockSpeakers[0]],
+ title: 'Platform Engineering Best Practices',
+ }),
+ href: '/admin/proposals/proposal-7',
+ },
+}
+
+export const Selected: Story = {
+ args: {
+ proposal: createMockProposal(),
+ href: '/admin/proposals/proposal-1',
+ isSelected: true,
+ onSelect: () => console.log('Card selected'),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Selected state with indigo highlight border.',
+ },
+ },
+ },
+}
+
+export const AllStatuses: Story = {
+ render: () => (
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story: 'All proposal statuses displayed together for comparison.',
+ },
+ },
+ },
+}
diff --git a/src/components/admin/ProposalPreview.stories.tsx b/src/components/admin/ProposalPreview.stories.tsx
new file mode 100644
index 00000000..a6c2f5f8
--- /dev/null
+++ b/src/components/admin/ProposalPreview.stories.tsx
@@ -0,0 +1,226 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalPreview } from './ProposalPreview'
+import { fn } from 'storybook/test'
+import {
+ ProposalExisting,
+ Status,
+ Format,
+ Language,
+ Level,
+ Audience,
+} from '@/lib/proposal/types'
+import { Speaker, Flags } from '@/lib/speaker/types'
+import { Review } from '@/lib/review/types'
+import { Topic } from '@/lib/topic/types'
+
+const createMockSpeaker = (
+ id: string,
+ name: string,
+ flags?: Flags[],
+): Speaker =>
+ ({
+ _id: id,
+ _rev: 'rev1',
+ _createdAt: '2025-01-01T00:00:00Z',
+ _updatedAt: '2025-01-01T00:00:00Z',
+ name,
+ email: `${name.toLowerCase().replace(/\s+/g, '.')}@example.com`,
+ slug: name.toLowerCase().replace(/\s+/g, '-'),
+ title: 'Platform Engineer at TechCorp',
+ flags,
+ }) as Speaker
+
+const createMockReview = (
+ id: string,
+ score: { content: number; relevance: number; speaker: number },
+): Review => ({
+ _id: id,
+ _rev: 'rev1',
+ _createdAt: '2025-01-15T10:00:00Z',
+ _updatedAt: '2025-01-15T10:00:00Z',
+ comment: 'Good proposal.',
+ score,
+ reviewer: { _ref: 'reviewer-1', _type: 'reference' },
+ proposal: { _ref: 'proposal-1', _type: 'reference' },
+})
+
+const mockTopics: Topic[] = [
+ {
+ _id: 'topic-1',
+ _type: 'topic',
+ title: 'Kubernetes',
+ color: '#326CE5',
+ slug: { current: 'kubernetes' },
+ },
+ {
+ _id: 'topic-2',
+ _type: 'topic',
+ title: 'Observability',
+ color: '#FFE66D',
+ slug: { current: 'observability' },
+ },
+]
+
+const createMockProposal = (
+ overrides: Partial = {},
+): ProposalExisting => ({
+ _id: 'proposal-1',
+ _rev: 'rev1',
+ _type: 'proposal',
+ _createdAt: '2025-01-15T10:00:00Z',
+ _updatedAt: '2025-01-15T10:00:00Z',
+ title: 'Building Production-Ready Kubernetes Operators',
+ description: [
+ {
+ _type: 'block',
+ _key: 'block1',
+ style: 'normal',
+ markDefs: [],
+ children: [
+ {
+ _type: 'span',
+ _key: 'span1',
+ text: 'Learn how to build robust Kubernetes operators using the Operator SDK. This talk covers best practices, testing strategies, and deployment patterns.',
+ marks: [],
+ },
+ ],
+ },
+ ],
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [Audience.developer, Audience.architect],
+ outline: 'Detailed outline for the talk',
+ tos: true,
+ status: Status.submitted,
+ speakers: [createMockSpeaker('speaker-1', 'Anna Hansen')],
+ conference: { _ref: 'conf-2025', _type: 'reference' },
+ topics: mockTopics,
+ ...overrides,
+})
+
+const meta: Meta = {
+ title: 'Systems/Proposals/Admin/ProposalPreview',
+ component: ProposalPreview,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'A slide-out preview panel showing proposal details. Displays speaker info, talk metadata, description, and review summary if available.',
+ },
+ },
+ layout: 'fullscreen',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ proposal: createMockProposal(),
+ onClose: fn(),
+ },
+}
+
+export const WithReviews: Story = {
+ args: {
+ proposal: createMockProposal({
+ reviews: [
+ createMockReview('review-1', { content: 5, relevance: 4, speaker: 5 }),
+ createMockReview('review-2', { content: 4, relevance: 5, speaker: 4 }),
+ createMockReview('review-3', { content: 4, relevance: 4, speaker: 3 }),
+ ],
+ }),
+ onClose: fn(),
+ },
+}
+
+export const AcceptedStatus: Story = {
+ args: {
+ proposal: createMockProposal({ status: Status.accepted }),
+ onClose: fn(),
+ },
+}
+
+export const ConfirmedStatus: Story = {
+ args: {
+ proposal: createMockProposal({ status: Status.confirmed }),
+ onClose: fn(),
+ },
+}
+
+export const RejectedStatus: Story = {
+ args: {
+ proposal: createMockProposal({ status: Status.rejected }),
+ onClose: fn(),
+ },
+}
+
+export const LightningTalk: Story = {
+ args: {
+ proposal: createMockProposal({
+ title: 'Quick Tips for Better Kubernetes Manifests',
+ format: Format.lightning_10,
+ }),
+ onClose: fn(),
+ },
+}
+
+export const Workshop: Story = {
+ args: {
+ proposal: createMockProposal({
+ title: 'Hands-On Kubernetes Security Workshop',
+ format: Format.workshop_120,
+ level: Level.advanced,
+ }),
+ onClose: fn(),
+ },
+}
+
+export const MultipleSpeakers: Story = {
+ args: {
+ proposal: createMockProposal({
+ speakers: [
+ createMockSpeaker('speaker-1', 'Anna Hansen'),
+ createMockSpeaker('speaker-2', 'Erik Larsen'),
+ ],
+ }),
+ onClose: fn(),
+ },
+}
+
+export const RequiresTravelFunding: Story = {
+ args: {
+ proposal: createMockProposal({
+ speakers: [
+ createMockSpeaker('speaker-1', 'International Speaker', [
+ Flags.requiresTravelFunding,
+ ]),
+ ],
+ }),
+ onClose: fn(),
+ },
+}
+
+export const NoTopics: Story = {
+ args: {
+ proposal: createMockProposal({ topics: [] }),
+ onClose: fn(),
+ },
+}
+
+export const NoSpeakers: Story = {
+ args: {
+ proposal: createMockProposal({ speakers: [] }),
+ onClose: fn(),
+ },
+}
diff --git a/src/components/admin/ProposalReviewForm.stories.tsx b/src/components/admin/ProposalReviewForm.stories.tsx
new file mode 100644
index 00000000..a301ad17
--- /dev/null
+++ b/src/components/admin/ProposalReviewForm.stories.tsx
@@ -0,0 +1,101 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalReviewForm } from './ProposalReviewForm'
+import { NotificationProvider } from '@/components/admin/NotificationProvider'
+import { Review } from '@/lib/review/types'
+import { fn } from 'storybook/test'
+
+const mockExistingReview: Review = {
+ _id: 'review-1',
+ _rev: 'rev1',
+ _createdAt: '2025-01-15T10:00:00Z',
+ _updatedAt: '2025-01-15T10:00:00Z',
+ comment: 'Great proposal! The topic is very relevant and well-structured.',
+ score: { content: 4, relevance: 5, speaker: 4 },
+ reviewer: { _ref: 'speaker-reviewer-1', _type: 'reference' },
+ proposal: { _ref: 'proposal-1', _type: 'reference' },
+}
+
+const meta: Meta = {
+ title: 'Systems/Proposals/Admin/ProposalReviewForm',
+ component: ProposalReviewForm,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Form for organizers to submit or update their review of a proposal. Includes star ratings for content, relevance, and speaker quality, plus a comment field. Has buttons to submit review and navigate to next unreviewed proposal.',
+ },
+ },
+ nextjs: {
+ appDirectory: true,
+ navigation: {
+ push: fn(),
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const EmptyForm: Story = {
+ args: {
+ proposalId: 'proposal-123',
+ onReviewSubmit: fn(),
+ },
+}
+
+export const WithExistingReview: Story = {
+ args: {
+ proposalId: 'proposal-123',
+ existingReview: mockExistingReview,
+ onReviewSubmit: fn(),
+ },
+}
+
+export const WithPartialRatings: Story = {
+ args: {
+ proposalId: 'proposal-123',
+ existingReview: {
+ ...mockExistingReview,
+ score: { content: 3, relevance: 0, speaker: 4 },
+ comment: '',
+ },
+ onReviewSubmit: fn(),
+ },
+}
+
+export const LowScoreReview: Story = {
+ args: {
+ proposalId: 'proposal-123',
+ existingReview: {
+ ...mockExistingReview,
+ score: { content: 2, relevance: 2, speaker: 1 },
+ comment:
+ 'This proposal needs significant improvements. The topic is not very relevant to our audience.',
+ },
+ onReviewSubmit: fn(),
+ },
+}
+
+export const HighScoreReview: Story = {
+ args: {
+ proposalId: 'proposal-123',
+ existingReview: {
+ ...mockExistingReview,
+ score: { content: 5, relevance: 5, speaker: 5 },
+ comment:
+ 'Excellent proposal! Must-have talk for the conference. Speaker is highly experienced.',
+ },
+ onReviewSubmit: fn(),
+ },
+}
diff --git a/src/components/admin/ProposalReviewList.stories.tsx b/src/components/admin/ProposalReviewList.stories.tsx
new file mode 100644
index 00000000..208ce545
--- /dev/null
+++ b/src/components/admin/ProposalReviewList.stories.tsx
@@ -0,0 +1,174 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalReviewList } from './ProposalReviewList'
+import { Review } from '@/lib/review/types'
+import { Speaker } from '@/lib/speaker/types'
+
+const createMockSpeaker = (id: string, name: string, email: string): Speaker =>
+ ({
+ _id: id,
+ _rev: 'rev1',
+ _createdAt: '2025-01-01T00:00:00Z',
+ _updatedAt: '2025-01-01T00:00:00Z',
+ name,
+ email,
+ slug: name.toLowerCase().replace(/\s+/g, '-'),
+ }) as Speaker
+
+const mockReviewers: Speaker[] = [
+ createMockSpeaker('reviewer-1', 'Anna Hansen', 'anna@conference.no'),
+ createMockSpeaker('reviewer-2', 'Erik Larsen', 'erik@conference.no'),
+ createMockSpeaker('reviewer-3', 'Sofia Berg', 'sofia@conference.no'),
+]
+
+const createMockReview = (
+ id: string,
+ reviewer: Speaker,
+ score: { content: number; relevance: number; speaker: number },
+ comment: string,
+ createdAt: string = '2025-01-15T10:00:00Z',
+): Review => ({
+ _id: id,
+ _rev: 'rev1',
+ _createdAt: createdAt,
+ _updatedAt: createdAt,
+ comment,
+ score,
+ reviewer,
+ proposal: { _ref: 'proposal-1', _type: 'reference' },
+})
+
+const mockReviews: Review[] = [
+ createMockReview(
+ 'review-1',
+ mockReviewers[0],
+ { content: 5, relevance: 4, speaker: 5 },
+ 'Excellent proposal! Very relevant topic for the community.',
+ '2025-01-15T10:00:00Z',
+ ),
+ createMockReview(
+ 'review-2',
+ mockReviewers[1],
+ { content: 4, relevance: 5, speaker: 4 },
+ 'Good technical depth, speaker has strong experience.',
+ '2025-01-14T14:30:00Z',
+ ),
+ createMockReview(
+ 'review-3',
+ mockReviewers[2],
+ { content: 4, relevance: 4, speaker: 3 },
+ 'Solid content, would benefit from more real-world examples.',
+ '2025-01-13T09:15:00Z',
+ ),
+]
+
+const meta: Meta = {
+ title: 'Systems/Proposals/Admin/ProposalReviewList',
+ component: ProposalReviewList,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Displays a list of reviews for a proposal. Shows reviewer avatars, names, scores by category (content, relevance, speaker), and comments. Highlights the current user\'s review with a "You" badge.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ reviews: mockReviews,
+ },
+}
+
+export const WithCurrentUser: Story = {
+ args: {
+ reviews: mockReviews,
+ currentUserId: 'reviewer-1',
+ },
+}
+
+export const NoReviews: Story = {
+ args: {
+ reviews: [],
+ },
+}
+
+export const SingleReview: Story = {
+ args: {
+ reviews: [mockReviews[0]],
+ },
+}
+
+export const MinimalMode: Story = {
+ args: {
+ reviews: mockReviews,
+ minimal: true,
+ },
+}
+
+export const MinimalNoReviews: Story = {
+ args: {
+ reviews: [],
+ minimal: true,
+ },
+}
+
+export const CurrentUserMinimal: Story = {
+ args: {
+ reviews: mockReviews,
+ currentUserId: 'reviewer-2',
+ minimal: true,
+ },
+}
+
+export const NoComment: Story = {
+ args: {
+ reviews: [
+ createMockReview(
+ 'review-no-comment',
+ mockReviewers[0],
+ { content: 3, relevance: 4, speaker: 3 },
+ '',
+ ),
+ ],
+ },
+}
+
+export const MixedScores: Story = {
+ args: {
+ reviews: [
+ createMockReview(
+ 'review-high',
+ mockReviewers[0],
+ { content: 5, relevance: 5, speaker: 5 },
+ 'Must have talk!',
+ '2025-01-15T10:00:00Z',
+ ),
+ createMockReview(
+ 'review-mid',
+ mockReviewers[1],
+ { content: 3, relevance: 3, speaker: 3 },
+ 'Average proposal.',
+ '2025-01-14T10:00:00Z',
+ ),
+ createMockReview(
+ 'review-low',
+ mockReviewers[2],
+ { content: 1, relevance: 2, speaker: 2 },
+ 'Not a good fit for this conference.',
+ '2025-01-13T10:00:00Z',
+ ),
+ ],
+ },
+}
diff --git a/src/components/admin/ProposalReviewSummary.stories.tsx b/src/components/admin/ProposalReviewSummary.stories.tsx
new file mode 100644
index 00000000..201155f1
--- /dev/null
+++ b/src/components/admin/ProposalReviewSummary.stories.tsx
@@ -0,0 +1,153 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalReviewSummary } from './ProposalReviewSummary'
+import { Review } from '@/lib/review/types'
+
+const createMockReview = (
+ overrides: Partial & { id: string },
+): Review => ({
+ _id: overrides.id,
+ _rev: 'rev1',
+ _createdAt: '2025-01-15T10:00:00Z',
+ _updatedAt: '2025-01-15T10:00:00Z',
+ comment: overrides.comment || 'Great proposal with solid technical content.',
+ score: overrides.score || { content: 4, relevance: 4, speaker: 4 },
+ reviewer: { _ref: 'speaker-reviewer-1', _type: 'reference' },
+ proposal: { _ref: 'proposal-1', _type: 'reference' },
+ ...overrides,
+})
+
+const mockReviews: Review[] = [
+ createMockReview({
+ id: 'review-1',
+ score: { content: 5, relevance: 4, speaker: 5 },
+ comment: 'Excellent proposal! Very relevant topic for the community.',
+ }),
+ createMockReview({
+ id: 'review-2',
+ score: { content: 4, relevance: 5, speaker: 4 },
+ comment: 'Good technical depth, speaker has strong experience.',
+ }),
+ createMockReview({
+ id: 'review-3',
+ score: { content: 4, relevance: 4, speaker: 3 },
+ comment: 'Solid content, would benefit from more real-world examples.',
+ }),
+]
+
+const meta: Meta = {
+ title: 'Systems/Proposals/Admin/ProposalReviewSummary',
+ component: ProposalReviewSummary,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Displays aggregated review scores for a proposal. Shows overall score and breakdown by category (content, relevance, speaker) with star ratings.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ reviews: mockReviews,
+ },
+}
+
+export const NoReviews: Story = {
+ args: {
+ reviews: [],
+ },
+}
+
+export const SingleReview: Story = {
+ args: {
+ reviews: [mockReviews[0]],
+ },
+}
+
+export const HighScores: Story = {
+ args: {
+ reviews: [
+ createMockReview({
+ id: 'review-high-1',
+ score: { content: 5, relevance: 5, speaker: 5 },
+ }),
+ createMockReview({
+ id: 'review-high-2',
+ score: { content: 5, relevance: 5, speaker: 4 },
+ }),
+ ],
+ },
+}
+
+export const LowScores: Story = {
+ args: {
+ reviews: [
+ createMockReview({
+ id: 'review-low-1',
+ score: { content: 2, relevance: 2, speaker: 3 },
+ }),
+ createMockReview({
+ id: 'review-low-2',
+ score: { content: 1, relevance: 3, speaker: 2 },
+ }),
+ ],
+ },
+}
+
+export const MixedScores: Story = {
+ args: {
+ reviews: [
+ createMockReview({
+ id: 'review-mixed-1',
+ score: { content: 5, relevance: 2, speaker: 4 },
+ }),
+ createMockReview({
+ id: 'review-mixed-2',
+ score: { content: 3, relevance: 5, speaker: 2 },
+ }),
+ createMockReview({
+ id: 'review-mixed-3',
+ score: { content: 4, relevance: 3, speaker: 5 },
+ }),
+ ],
+ },
+}
+
+export const ManyReviews: Story = {
+ args: {
+ reviews: [
+ createMockReview({
+ id: 'review-many-1',
+ score: { content: 5, relevance: 4, speaker: 5 },
+ }),
+ createMockReview({
+ id: 'review-many-2',
+ score: { content: 4, relevance: 5, speaker: 4 },
+ }),
+ createMockReview({
+ id: 'review-many-3',
+ score: { content: 4, relevance: 4, speaker: 3 },
+ }),
+ createMockReview({
+ id: 'review-many-4',
+ score: { content: 5, relevance: 3, speaker: 4 },
+ }),
+ createMockReview({
+ id: 'review-many-5',
+ score: { content: 3, relevance: 4, speaker: 5 },
+ }),
+ ],
+ },
+}
diff --git a/src/components/admin/ProposalStatistics.stories.tsx b/src/components/admin/ProposalStatistics.stories.tsx
new file mode 100644
index 00000000..666571a1
--- /dev/null
+++ b/src/components/admin/ProposalStatistics.stories.tsx
@@ -0,0 +1,246 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalStatistics } from './ProposalStatistics'
+import {
+ ProposalExisting,
+ Format,
+ Language,
+ Level,
+ Audience,
+ Status,
+} from '@/lib/proposal/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+
+import { Topic } from '@/lib/topic/types'
+
+const mockTopics: Topic[] = [
+ {
+ _id: 'topic-1',
+ _type: 'topic',
+ title: 'Kubernetes',
+ color: '326CE5',
+ slug: { current: 'kubernetes' },
+ },
+ {
+ _id: 'topic-2',
+ _type: 'topic',
+ title: 'DevOps',
+ color: 'FF6B35',
+ slug: { current: 'devops' },
+ },
+ {
+ _id: 'topic-3',
+ _type: 'topic',
+ title: 'AI/ML',
+ color: '4CAF50',
+ slug: { current: 'ai-ml' },
+ },
+ {
+ _id: 'topic-4',
+ _type: 'topic',
+ title: 'Security',
+ color: 'E91E63',
+ slug: { current: 'security' },
+ },
+ {
+ _id: 'topic-5',
+ _type: 'topic',
+ title: 'Observability',
+ color: '9C27B0',
+ slug: { current: 'observability' },
+ },
+]
+
+const createMockProposal = (
+ id: string,
+ level: Level,
+ audiences: Audience[],
+ topics: typeof mockTopics,
+): ProposalExisting => ({
+ _id: id,
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: `Proposal ${id}`,
+ description: convertStringToPortableTextBlocks('Description'),
+ language: Language.english,
+ format: Format.presentation_45,
+ level,
+ audiences,
+ status: Status.submitted,
+ outline: '',
+ topics,
+ tos: true,
+ conference: { _type: 'reference', _ref: 'conf-1' },
+})
+
+const mockProposals: ProposalExisting[] = [
+ createMockProposal(
+ '1',
+ Level.beginner,
+ [Audience.developer],
+ [mockTopics[0]],
+ ),
+ createMockProposal(
+ '2',
+ Level.beginner,
+ [Audience.developer, Audience.devopsEngineer],
+ [mockTopics[0], mockTopics[1]],
+ ),
+ createMockProposal(
+ '3',
+ Level.intermediate,
+ [Audience.developer],
+ [mockTopics[0]],
+ ),
+ createMockProposal(
+ '4',
+ Level.intermediate,
+ [Audience.architect],
+ [mockTopics[1]],
+ ),
+ createMockProposal(
+ '5',
+ Level.intermediate,
+ [Audience.operator, Audience.devopsEngineer],
+ [mockTopics[1], mockTopics[4]],
+ ),
+ createMockProposal(
+ '6',
+ Level.advanced,
+ [Audience.architect, Audience.securityEngineer],
+ [mockTopics[3]],
+ ),
+ createMockProposal(
+ '7',
+ Level.advanced,
+ [Audience.dataEngineer],
+ [mockTopics[2]],
+ ),
+ createMockProposal(
+ '8',
+ Level.advanced,
+ [Audience.developer, Audience.architect],
+ [mockTopics[2], mockTopics[3]],
+ ),
+ createMockProposal(
+ '9',
+ Level.intermediate,
+ [Audience.devopsEngineer],
+ [mockTopics[4]],
+ ),
+ createMockProposal(
+ '10',
+ Level.beginner,
+ [Audience.developer, Audience.qaEngineer],
+ [mockTopics[0], mockTopics[4]],
+ ),
+]
+
+const meta: Meta = {
+ title: 'Systems/Proposals/Admin/ProposalStatistics',
+ component: ProposalStatistics,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Collapsible statistics panel showing proposal distribution by level, topic, and audience. Uses colored progress bars that match topic colors from Sanity.',
+ },
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ proposals: mockProposals,
+ },
+}
+
+export const Expanded: Story = {
+ args: {
+ proposals: mockProposals,
+ },
+ play: async ({ canvasElement }) => {
+ const button = canvasElement.querySelector('button')
+ if (button) {
+ button.click()
+ }
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Statistics panel in expanded state showing all distributions.',
+ },
+ },
+ },
+}
+
+export const FewProposals: Story = {
+ args: {
+ proposals: mockProposals.slice(0, 3),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'With only a few proposals, showing limited distribution.',
+ },
+ },
+ },
+}
+
+export const SingleProposal: Story = {
+ args: {
+ proposals: [mockProposals[0]],
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ proposals: [],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'With no proposals, the component renders nothing.',
+ },
+ },
+ },
+}
+
+export const ManyTopics: Story = {
+ args: {
+ proposals: [
+ ...mockProposals,
+ createMockProposal(
+ '11',
+ Level.beginner,
+ [Audience.developer],
+ [mockTopics[0], mockTopics[1], mockTopics[2]],
+ ),
+ createMockProposal(
+ '12',
+ Level.intermediate,
+ [Audience.architect],
+ [mockTopics[3], mockTopics[4]],
+ ),
+ createMockProposal(
+ '13',
+ Level.advanced,
+ [Audience.securityEngineer],
+ [mockTopics[3]],
+ ),
+ ],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'With many proposals across multiple topics showing diverse distribution.',
+ },
+ },
+ },
+}
diff --git a/src/components/admin/ProposalsFilter.stories.tsx b/src/components/admin/ProposalsFilter.stories.tsx
new file mode 100644
index 00000000..70c30498
--- /dev/null
+++ b/src/components/admin/ProposalsFilter.stories.tsx
@@ -0,0 +1,276 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import { fn } from 'storybook/test'
+import { ProposalsFilter, FilterState, ReviewStatus } from './ProposalsFilter'
+import { Status, Format, Level, Language, Audience } from '@/lib/proposal/types'
+import { Flags } from '@/lib/speaker/types'
+
+const defaultFilters: FilterState = {
+ status: [],
+ format: [],
+ level: [],
+ language: [],
+ audience: [],
+ speakerFlags: [],
+ reviewStatus: ReviewStatus.all,
+ hideMultipleTalks: false,
+ sortBy: 'created',
+ sortOrder: 'desc',
+}
+
+const meta = {
+ title: 'Systems/Proposals/Admin/ProposalsFilter',
+ component: ProposalsFilter,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Filter controls for the proposals list. Supports filtering by status, format, level, speaker flags, and review status. Includes sorting options and a clear all button when filters are active.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ filters: defaultFilters,
+ onFilterChange: fn(),
+ onReviewStatusChange: fn(),
+ onMultipleTalksFilterChange: fn(),
+ onSortChange: fn(),
+ onSortOrderToggle: fn(),
+ onClearAll: fn(),
+ activeFilterCount: 0,
+ currentUserId: 'user-1',
+ },
+}
+
+export const WithActiveFilters: Story = {
+ args: {
+ filters: {
+ ...defaultFilters,
+ status: [Status.submitted, Status.confirmed],
+ format: [Format.presentation_45],
+ level: [Level.intermediate],
+ },
+ onFilterChange: fn(),
+ onReviewStatusChange: fn(),
+ onMultipleTalksFilterChange: fn(),
+ onSortChange: fn(),
+ onSortOrderToggle: fn(),
+ onClearAll: fn(),
+ activeFilterCount: 4,
+ currentUserId: 'user-1',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Filter dropdowns show badge counts when filters are active.',
+ },
+ },
+ },
+}
+
+export const WithSpeakerFilters: Story = {
+ args: {
+ filters: {
+ ...defaultFilters,
+ speakerFlags: [Flags.diverseSpeaker, Flags.firstTimeSpeaker],
+ hideMultipleTalks: true,
+ },
+ onFilterChange: fn(),
+ onReviewStatusChange: fn(),
+ onMultipleTalksFilterChange: fn(),
+ onSortChange: fn(),
+ onSortOrderToggle: fn(),
+ onClearAll: fn(),
+ activeFilterCount: 3,
+ currentUserId: 'user-1',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Speaker filters help organizers find diverse and new speakers while avoiding speakers with existing acceptances.',
+ },
+ },
+ },
+}
+
+export const ReviewMode: Story = {
+ args: {
+ filters: {
+ ...defaultFilters,
+ reviewStatus: ReviewStatus.unreviewed,
+ },
+ onFilterChange: fn(),
+ onReviewStatusChange: fn(),
+ onMultipleTalksFilterChange: fn(),
+ onSortChange: fn(),
+ onSortOrderToggle: fn(),
+ onClearAll: fn(),
+ activeFilterCount: 1,
+ currentUserId: 'user-1',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Review mode filters help reviewers see only proposals they have or have not reviewed.',
+ },
+ },
+ },
+}
+
+export const WithoutReviewFeature: Story = {
+ args: {
+ filters: defaultFilters,
+ onFilterChange: fn(),
+ onReviewStatusChange: fn(),
+ onMultipleTalksFilterChange: fn(),
+ onSortChange: fn(),
+ onSortOrderToggle: fn(),
+ onClearAll: fn(),
+ activeFilterCount: 0,
+ currentUserId: undefined,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Without a currentUserId, the review filter dropdown is hidden.',
+ },
+ },
+ },
+}
+
+export const LimitedFormats: Story = {
+ args: {
+ filters: defaultFilters,
+ onFilterChange: fn(),
+ onReviewStatusChange: fn(),
+ onMultipleTalksFilterChange: fn(),
+ onSortChange: fn(),
+ onSortOrderToggle: fn(),
+ onClearAll: fn(),
+ activeFilterCount: 0,
+ currentUserId: 'user-1',
+ allowedFormats: [
+ Format.lightning_10,
+ Format.presentation_25,
+ Format.presentation_45,
+ ],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'The format dropdown can be limited to only show certain formats (e.g., excluding workshops).',
+ },
+ },
+ },
+}
+
+const InteractiveTemplate = () => {
+ const [filters, setFilters] = useState(defaultFilters)
+
+ const handleFilterChange = (
+ filterType: keyof FilterState,
+ value: Status | Format | Level | Language | Audience | Flags,
+ ) => {
+ setFilters((prev) => {
+ const currentArray = prev[filterType] as (typeof value)[]
+ const isActive = currentArray.includes(value)
+
+ return {
+ ...prev,
+ [filterType]: isActive
+ ? currentArray.filter((v) => v !== value)
+ : [...currentArray, value],
+ }
+ })
+ }
+
+ const handleReviewStatusChange = (reviewStatus: ReviewStatus) => {
+ setFilters((prev) => ({ ...prev, reviewStatus }))
+ }
+
+ const handleMultipleTalksFilterChange = (hideMultipleTalks: boolean) => {
+ setFilters((prev) => ({ ...prev, hideMultipleTalks }))
+ }
+
+ const handleSortChange = (sortBy: FilterState['sortBy']) => {
+ setFilters((prev) => ({ ...prev, sortBy }))
+ }
+
+ const handleSortOrderToggle = () => {
+ setFilters((prev) => ({
+ ...prev,
+ sortOrder: prev.sortOrder === 'asc' ? 'desc' : 'asc',
+ }))
+ }
+
+ const handleClearAll = () => {
+ setFilters(defaultFilters)
+ }
+
+ const activeFilterCount =
+ filters.status.length +
+ filters.format.length +
+ filters.level.length +
+ filters.speakerFlags.length +
+ (filters.hideMultipleTalks ? 1 : 0) +
+ (filters.reviewStatus !== ReviewStatus.all ? 1 : 0)
+
+ return (
+
+
+
+
+
+ Current Filter State
+
+
+ {JSON.stringify(filters, null, 2)}
+
+
+
+ )
+}
+
+export const Interactive: Story = {
+ args: {
+ filters: defaultFilters,
+ onFilterChange: fn(),
+ onReviewStatusChange: fn(),
+ onMultipleTalksFilterChange: fn(),
+ onSortChange: fn(),
+ onSortOrderToggle: fn(),
+ onClearAll: fn(),
+ activeFilterCount: 0,
+ currentUserId: 'user-1',
+ },
+ render: () => ,
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Interactive demo showing how filter state changes as you interact with the controls.',
+ },
+ },
+ },
+}
diff --git a/src/components/admin/ProposalsList.stories.tsx b/src/components/admin/ProposalsList.stories.tsx
new file mode 100644
index 00000000..22db431c
--- /dev/null
+++ b/src/components/admin/ProposalsList.stories.tsx
@@ -0,0 +1,212 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalsList } from './ProposalsList'
+import { fn } from 'storybook/test'
+import {
+ ProposalExisting,
+ Status,
+ Format,
+ Language,
+ Level,
+} from '@/lib/proposal/types'
+import { Speaker } from '@/lib/speaker/types'
+
+const createMockSpeaker = (id: string, name: string): Speaker =>
+ ({
+ _id: id,
+ _rev: 'rev1',
+ _createdAt: '2025-01-01T00:00:00Z',
+ _updatedAt: '2025-01-01T00:00:00Z',
+ name,
+ email: `${name.toLowerCase().replace(/\s+/g, '.')}@example.com`,
+ slug: name.toLowerCase().replace(/\s+/g, '-'),
+ }) as Speaker
+
+const createMockProposal = (
+ id: string,
+ title: string,
+ status: Status,
+ format: Format,
+ speakerName: string,
+): ProposalExisting => ({
+ _id: id,
+ _rev: 'rev1',
+ _type: 'proposal',
+ _createdAt: '2025-01-15T10:00:00Z',
+ _updatedAt: '2025-01-15T10:00:00Z',
+ title,
+ description: [],
+ language: Language.english,
+ format,
+ level: Level.intermediate,
+ audiences: [],
+ outline: 'Outline for the talk',
+ tos: true,
+ status,
+ speakers: [createMockSpeaker(`speaker-${id}`, speakerName)],
+ conference: { _ref: 'conf-2025', _type: 'reference' },
+})
+
+const mockProposals: ProposalExisting[] = [
+ createMockProposal(
+ 'prop-1',
+ 'Building Kubernetes Operators',
+ Status.submitted,
+ Format.presentation_45,
+ 'Anna Hansen',
+ ),
+ createMockProposal(
+ 'prop-2',
+ 'Observability at Scale',
+ Status.accepted,
+ Format.presentation_25,
+ 'Erik Larsen',
+ ),
+ createMockProposal(
+ 'prop-3',
+ 'GitOps Best Practices',
+ Status.confirmed,
+ Format.presentation_45,
+ 'Sofia Berg',
+ ),
+ createMockProposal(
+ 'prop-4',
+ 'Lightning Talk: Quick Tips',
+ Status.submitted,
+ Format.lightning_10,
+ 'Magnus Olsen',
+ ),
+ createMockProposal(
+ 'prop-5',
+ 'Advanced Workshop',
+ Status.draft,
+ Format.workshop_120,
+ 'Ingrid Nilsen',
+ ),
+]
+
+const meta: Meta = {
+ title: 'Systems/Proposals/Admin/ProposalsList',
+ component: ProposalsList,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Main admin view for managing conference proposals. Displays a filterable grid of proposal cards with statistics and status indicators.',
+ },
+ },
+ nextjs: {
+ appDirectory: true,
+ navigation: {
+ pathname: '/admin/proposals',
+ query: {},
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ proposals: mockProposals,
+ },
+}
+
+export const WithPreviewEnabled: Story = {
+ args: {
+ proposals: mockProposals,
+ enablePreview: true,
+ onProposalSelect: fn(),
+ selectedProposalId: 'prop-2',
+ },
+}
+
+export const WithCurrentUser: Story = {
+ args: {
+ proposals: mockProposals,
+ currentUserId: 'speaker-prop-1',
+ },
+}
+
+export const WithCreateButton: Story = {
+ args: {
+ proposals: mockProposals,
+ onCreateProposal: fn(),
+ },
+}
+
+export const EmptyList: Story = {
+ args: {
+ proposals: [],
+ },
+}
+
+export const SingleProposal: Story = {
+ args: {
+ proposals: [mockProposals[0]],
+ },
+}
+
+export const SubmittedOnly: Story = {
+ args: {
+ proposals: mockProposals.filter((p) => p.status === Status.submitted),
+ },
+}
+
+export const AllStatuses: Story = {
+ args: {
+ proposals: [
+ createMockProposal(
+ 'p1',
+ 'Draft Proposal',
+ Status.draft,
+ Format.presentation_25,
+ 'Speaker 1',
+ ),
+ createMockProposal(
+ 'p2',
+ 'Submitted Proposal',
+ Status.submitted,
+ Format.presentation_25,
+ 'Speaker 2',
+ ),
+ createMockProposal(
+ 'p3',
+ 'Accepted Proposal',
+ Status.accepted,
+ Format.presentation_45,
+ 'Speaker 3',
+ ),
+ createMockProposal(
+ 'p4',
+ 'Confirmed Proposal',
+ Status.confirmed,
+ Format.presentation_45,
+ 'Speaker 4',
+ ),
+ createMockProposal(
+ 'p5',
+ 'Rejected Proposal',
+ Status.rejected,
+ Format.lightning_10,
+ 'Speaker 5',
+ ),
+ createMockProposal(
+ 'p6',
+ 'Withdrawn Proposal',
+ Status.withdrawn,
+ Format.workshop_120,
+ 'Speaker 6',
+ ),
+ ],
+ },
+}
diff --git a/src/components/admin/SpeakerActions.stories.tsx b/src/components/admin/SpeakerActions.stories.tsx
new file mode 100644
index 00000000..ce32dad4
--- /dev/null
+++ b/src/components/admin/SpeakerActions.stories.tsx
@@ -0,0 +1,119 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { SpeakerActions } from './SpeakerActions'
+import { NotificationProvider } from './NotificationProvider'
+import { fn } from 'storybook/test'
+import { http, HttpResponse } from 'msw'
+import { Conference } from '@/lib/conference/types'
+import { Format } from '@/lib/proposal/types'
+
+const mockConference: Conference = {
+ _id: 'conf-2025',
+ title: 'Cloud Native Day Bergen 2025',
+ organizer: 'Cloud Native Bergen',
+ city: 'Bergen',
+ country: 'Norway',
+ startDate: '2025-09-18',
+ endDate: '2025-09-18',
+ cfpStartDate: '2025-03-01',
+ cfpEndDate: '2025-06-15',
+ cfpNotifyDate: '2025-07-01',
+ cfpEmail: 'cfp@cloudnativeday.no',
+ sponsorEmail: 'sponsor@cloudnativeday.no',
+ programDate: '2025-07-15',
+ contactEmail: 'info@cloudnativeday.no',
+ registrationEnabled: true,
+ organizers: [],
+ domains: ['cloudnativeday.no'],
+ formats: [Format.lightning_10, Format.presentation_25, Format.workshop_120],
+ topics: [],
+ socialLinks: [
+ 'https://twitter.com/cloudnativeday',
+ 'https://linkedin.com/company/cloudnativeday',
+ ],
+}
+
+const meta: Meta = {
+ title: 'Systems/Speakers/Admin/SpeakerActions',
+ component: SpeakerActions,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Action buttons and modal for managing speaker communications. Provides functionality to send broadcast emails to all confirmed speakers and sync contacts with email service.',
+ },
+ },
+ msw: {
+ handlers: [
+ http.post('/admin/api/speakers/email/broadcast', () => {
+ return HttpResponse.json({ success: true, sentCount: 15 })
+ }),
+ http.post('/admin/api/speakers/email/audience/sync', () => {
+ return HttpResponse.json({ success: true, syncedCount: 20 })
+ }),
+ ],
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const ModalOpen: Story = {
+ args: {
+ eligibleSpeakersCount: 15,
+ fromEmail: 'speakers@cloudnativeday.no',
+ conference: mockConference,
+ isModalOpen: true,
+ setIsModalOpen: fn(),
+ },
+}
+
+export const ModalClosed: Story = {
+ args: {
+ eligibleSpeakersCount: 15,
+ fromEmail: 'speakers@cloudnativeday.no',
+ conference: mockConference,
+ isModalOpen: false,
+ setIsModalOpen: fn(),
+ },
+}
+
+export const ManySpeakers: Story = {
+ args: {
+ eligibleSpeakersCount: 45,
+ fromEmail: 'speakers@cloudnativeday.no',
+ conference: mockConference,
+ isModalOpen: true,
+ setIsModalOpen: fn(),
+ },
+}
+
+export const SingleSpeaker: Story = {
+ args: {
+ eligibleSpeakersCount: 1,
+ fromEmail: 'speakers@cloudnativeday.no',
+ conference: mockConference,
+ isModalOpen: true,
+ setIsModalOpen: fn(),
+ },
+}
+
+export const NoSocialLinks: Story = {
+ args: {
+ eligibleSpeakersCount: 15,
+ fromEmail: 'speakers@cloudnativeday.no',
+ conference: { ...mockConference, socialLinks: [] },
+ isModalOpen: true,
+ setIsModalOpen: fn(),
+ },
+}
diff --git a/src/components/admin/SpeakerMultiSelect.stories.tsx b/src/components/admin/SpeakerMultiSelect.stories.tsx
new file mode 100644
index 00000000..5c51b0d4
--- /dev/null
+++ b/src/components/admin/SpeakerMultiSelect.stories.tsx
@@ -0,0 +1,139 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { SpeakerMultiSelect } from './SpeakerMultiSelect'
+import { fn } from 'storybook/test'
+import { http, HttpResponse } from 'msw'
+
+const mockSpeakers = [
+ {
+ _id: 'speaker-1',
+ name: 'Anna Hansen',
+ title: 'Platform Engineer at TechCorp',
+ email: 'anna@techcorp.no',
+ image: null,
+ slug: 'anna-hansen',
+ },
+ {
+ _id: 'speaker-2',
+ name: 'Erik Larsen',
+ title: 'SRE Lead at CloudScale',
+ email: 'erik@cloudscale.no',
+ image: null,
+ slug: 'erik-larsen',
+ },
+ {
+ _id: 'speaker-3',
+ name: 'Sofia Berg',
+ title: 'DevOps Architect',
+ email: 'sofia@devops.io',
+ image: null,
+ slug: 'sofia-berg',
+ },
+ {
+ _id: 'speaker-4',
+ name: 'Magnus Olsen',
+ title: 'Cloud Native Engineer',
+ email: 'magnus@cncf.io',
+ image: null,
+ slug: 'magnus-olsen',
+ },
+ {
+ _id: 'speaker-5',
+ name: 'Ingrid Nilsen',
+ title: 'Security Engineer at SecureTech',
+ email: 'ingrid@securetech.no',
+ image: null,
+ slug: 'ingrid-nilsen',
+ },
+]
+
+const meta: Meta = {
+ title: 'Systems/Speakers/Admin/SpeakerMultiSelect',
+ component: SpeakerMultiSelect,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'A dropdown component for selecting multiple speakers. Used in admin interfaces to assign speakers to proposals. Features search filtering, avatar display, and max speaker limits.',
+ },
+ },
+ msw: {
+ handlers: [
+ http.get('/api/admin/speakers', () => {
+ return HttpResponse.json({ speakers: mockSpeakers })
+ }),
+ ],
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Empty: Story = {
+ args: {
+ selectedSpeakerIds: [],
+ onChange: fn(),
+ label: 'Speakers',
+ },
+}
+
+export const WithSelectedSpeakers: Story = {
+ args: {
+ selectedSpeakerIds: ['speaker-1', 'speaker-2'],
+ onChange: fn(),
+ label: 'Speakers',
+ },
+}
+
+export const MaxSpeakersReached: Story = {
+ args: {
+ selectedSpeakerIds: ['speaker-1', 'speaker-2', 'speaker-3'],
+ onChange: fn(),
+ maxSpeakers: 3,
+ label: 'Speakers',
+ },
+}
+
+export const Required: Story = {
+ args: {
+ selectedSpeakerIds: [],
+ onChange: fn(),
+ label: 'Speakers',
+ required: true,
+ },
+}
+
+export const WithError: Story = {
+ args: {
+ selectedSpeakerIds: [],
+ onChange: fn(),
+ label: 'Speakers',
+ required: true,
+ error: 'At least one speaker is required',
+ },
+}
+
+export const CustomMaxSpeakers: Story = {
+ args: {
+ selectedSpeakerIds: ['speaker-1'],
+ onChange: fn(),
+ maxSpeakers: 2,
+ label: 'Co-Presenters',
+ },
+}
+
+export const SingleSpeaker: Story = {
+ args: {
+ selectedSpeakerIds: ['speaker-3'],
+ onChange: fn(),
+ label: 'Primary Speaker',
+ },
+}
diff --git a/src/components/admin/SpeakerTable.stories.tsx b/src/components/admin/SpeakerTable.stories.tsx
new file mode 100644
index 00000000..0fb74da6
--- /dev/null
+++ b/src/components/admin/SpeakerTable.stories.tsx
@@ -0,0 +1,254 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { fn } from 'storybook/test'
+import { SpeakerTable } from './SpeakerTable'
+import { Speaker, Flags } from '@/lib/speaker/types'
+import {
+ ProposalExisting,
+ Format,
+ Language,
+ Level,
+ Audience,
+ Status,
+} from '@/lib/proposal/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+
+interface SpeakerWithProposals extends Speaker {
+ proposals: ProposalExisting[]
+}
+
+const mockTopic = { _type: 'reference' as const, _ref: 'topic-1' }
+
+const mockProposal = (
+ id: string,
+ title: string,
+ status: Status,
+ format: Format = Format.presentation_45,
+): ProposalExisting => ({
+ _id: id,
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title,
+ description: convertStringToPortableTextBlocks('Test description'),
+ language: Language.english,
+ format,
+ level: Level.intermediate,
+ audiences: [Audience.developer],
+ status,
+ outline: '',
+ topics: [mockTopic],
+ tos: true,
+ speakers: [],
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+})
+
+const mockSpeakers: SpeakerWithProposals[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Platform Engineer at Google',
+ flags: [Flags.localSpeaker],
+ links: [
+ 'https://linkedin.com/in/alicejohnson',
+ 'https://bsky.app/profile/alice.dev',
+ ],
+ proposals: [
+ mockProposal(
+ 'talk-1',
+ 'Building Scalable Microservices with Kubernetes',
+ Status.confirmed,
+ ),
+ ],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker, Flags.diverseSpeaker],
+ links: ['https://linkedin.com/in/bobsmith'],
+ proposals: [
+ mockProposal('talk-2', 'GitOps for the Enterprise', Status.accepted),
+ mockProposal(
+ 'talk-3',
+ 'Advanced CI/CD Patterns',
+ Status.submitted,
+ Format.lightning_10,
+ ),
+ ],
+ },
+ {
+ _id: 'speaker-3',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Carol Williams',
+ email: 'carol@cloudprovider.io',
+ slug: 'carol-williams',
+ title: 'Principal Solutions Architect, AWS',
+ flags: [Flags.requiresTravelFunding],
+ links: ['https://bsky.app/profile/carol.codes'],
+ proposals: [
+ mockProposal(
+ 'talk-4',
+ 'Hands-on Kubernetes Workshop',
+ Status.confirmed,
+ Format.workshop_120,
+ ),
+ ],
+ },
+ {
+ _id: 'speaker-4',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'David Chen',
+ email: 'david@startup.io',
+ slug: 'david-chen',
+ title: 'CTO at CloudStartup',
+ flags: [],
+ links: [],
+ proposals: [
+ mockProposal(
+ 'talk-5',
+ 'From Zero to Production: A Startup Journey',
+ Status.accepted,
+ ),
+ ],
+ },
+]
+
+const meta = {
+ title: 'Systems/Speakers/Admin/SpeakerTable',
+ component: SpeakerTable,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ 'Admin table for managing speakers with accepted/confirmed talks. Features search, filtering by status and speaker flags, configurable column visibility, and action menus for editing and previewing speaker profiles.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ speakers: mockSpeakers,
+ currentConferenceId: 'conf-2025',
+ onEditSpeaker: fn(),
+ onPreviewSpeaker: fn(),
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ speakers: [],
+ currentConferenceId: 'conf-2025',
+ onEditSpeaker: fn(),
+ onPreviewSpeaker: fn(),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Shows empty state when no speakers with accepted talks exist.',
+ },
+ },
+ },
+}
+
+export const SingleSpeaker: Story = {
+ args: {
+ speakers: [mockSpeakers[0]],
+ currentConferenceId: 'conf-2025',
+ onEditSpeaker: fn(),
+ onPreviewSpeaker: fn(),
+ },
+}
+
+export const ManySpeakers: Story = {
+ args: {
+ speakers: [
+ ...mockSpeakers,
+ {
+ _id: 'speaker-5',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Eva Martinez',
+ email: 'eva@tech.com',
+ slug: 'eva-martinez',
+ title: 'Staff Engineer at Netflix',
+ flags: [Flags.localSpeaker, Flags.diverseSpeaker],
+ links: ['https://linkedin.com/in/evamartinez'],
+ proposals: [
+ mockProposal('talk-6', 'Observability at Scale', Status.confirmed),
+ ],
+ },
+ {
+ _id: 'speaker-6',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Frank Thompson',
+ email: 'frank@consultancy.com',
+ slug: 'frank-thompson',
+ title: 'Independent Consultant',
+ flags: [Flags.firstTimeSpeaker, Flags.requiresTravelFunding],
+ links: [],
+ proposals: [
+ mockProposal('talk-7', 'Service Mesh Deep Dive', Status.accepted),
+ ],
+ },
+ ],
+ currentConferenceId: 'conf-2025',
+ onEditSpeaker: fn(),
+ onPreviewSpeaker: fn(),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Table with many speakers showing various flags and proposal statuses.',
+ },
+ },
+ },
+}
+
+export const WithoutConferenceFilter: Story = {
+ args: {
+ speakers: mockSpeakers,
+ currentConferenceId: undefined,
+ onEditSpeaker: fn(),
+ onPreviewSpeaker: fn(),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Without a currentConferenceId, all proposals from all conferences are shown.',
+ },
+ },
+ },
+}
diff --git a/src/components/admin/schedule/DraggableProposal.tsx b/src/components/admin/schedule/DraggableProposal.tsx
index a3fe8652..b017c2a1 100644
--- a/src/components/admin/schedule/DraggableProposal.tsx
+++ b/src/components/admin/schedule/DraggableProposal.tsx
@@ -73,11 +73,11 @@ export function DraggableProposal({
const speaker =
proposal.speakers &&
- Array.isArray(proposal.speakers) &&
- proposal.speakers.length > 0 &&
- proposal.speakers[0] &&
- typeof proposal.speakers[0] === 'object' &&
- 'name' in proposal.speakers[0]
+ Array.isArray(proposal.speakers) &&
+ proposal.speakers.length > 0 &&
+ proposal.speakers[0] &&
+ typeof proposal.speakers[0] === 'object' &&
+ 'name' in proposal.speakers[0]
? proposal.speakers[0].name
: null
@@ -266,9 +266,8 @@ export function DraggableProposal({
1 ? ` (${audienceCount} total)` : ''
- }`}
+ title={`Primary: ${audiences.get(proposal.audiences?.[0])}${audienceCount > 1 ? ` (${audienceCount} total)` : ''
+ }`}
>
{primaryAudience.abbr}
{countText}
@@ -321,7 +320,7 @@ export function DraggableProposal({
const statusEmoji =
proposal.status === Status.withdrawn ||
- proposal.status === Status.rejected
+ proposal.status === Status.rejected
? 'đźš«'
: proposal.status === Status.accepted
? '⚠️'
@@ -378,7 +377,7 @@ export function DraggableProposal({
title={tooltipContent}
{...attributes}
>
-
+
= {
+ title: 'Systems/Sponsors/Admin/Pipeline/BoardViewSwitcher',
+ component: BoardViewSwitcher,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Navigation control for switching between different sponsor management views. Pipeline view tracks relationship stages, Contract view focuses on agreement status, and Invoice view monitors payment progress. Uses brand colors with Cloud Blue (#1D4ED8) for active states.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const BoardViewSwitcher_: Story = {
+ name: 'BoardViewSwitcher',
+ render: () => {
+ const [view, setView] = useState('pipeline')
+ return (
+
+
+
+ Current view: {view}
+
+
+ )
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Click each tab to switch between Pipeline, Contract, and Invoice views. The active view is highlighted with brand blue color.',
+ },
+ },
+ },
+}
diff --git a/src/components/admin/sponsor-crm/ContractReadinessIndicator.stories.tsx b/src/components/admin/sponsor-crm/ContractReadinessIndicator.stories.tsx
new file mode 100644
index 00000000..fb9bddd1
--- /dev/null
+++ b/src/components/admin/sponsor-crm/ContractReadinessIndicator.stories.tsx
@@ -0,0 +1,172 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { http, HttpResponse } from 'msw'
+import { ContractReadinessIndicator } from './ContractReadinessIndicator'
+import {
+ mockReadinessReady,
+ mockReadinessMissing,
+} from '@/__mocks__/sponsor-data'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Pipeline/ContractReadinessIndicator',
+ component: ContractReadinessIndicator,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Visual validation indicator showing contract generation readiness. Uses tRPC to check for required fields (sponsor name, org number, address, tier selection, primary contact). Displays missing fields by data source (Sponsor Profile, Tier Selection, Contact Information) with clear success/warning states using brand colors.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Interactive: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-ready',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get(
+ '/api/trpc/sponsor.contractTemplates.contractReadiness',
+ () => {
+ return HttpResponse.json({
+ result: {
+ data: mockReadinessReady(),
+ },
+ })
+ },
+ ),
+ ],
+ },
+ },
+}
+
+export const ReadyForContract: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-ready',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get(
+ '/api/trpc/sponsor.contractTemplates.contractReadiness',
+ () => {
+ return HttpResponse.json({
+ result: {
+ data: mockReadinessReady(),
+ },
+ })
+ },
+ ),
+ ],
+ },
+ },
+}
+
+export const MissingFields: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-missing',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get(
+ '/api/trpc/sponsor.contractTemplates.contractReadiness',
+ () => {
+ return HttpResponse.json({
+ result: {
+ data: mockReadinessMissing(),
+ },
+ })
+ },
+ ),
+ ],
+ },
+ },
+}
+
+export const MissingSponsorDataOnly: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-sponsor-missing',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get(
+ '/api/trpc/sponsor.contractTemplates.contractReadiness',
+ () => {
+ return HttpResponse.json({
+ result: {
+ data: mockReadinessMissing([
+ {
+ field: 'sponsor.orgNumber',
+ label: 'Organization number',
+ source: 'sponsor',
+ },
+ {
+ field: 'sponsor.address',
+ label: 'Address',
+ source: 'sponsor',
+ },
+ {
+ field: 'contactPersons.primary',
+ label: 'Primary contact person',
+ source: 'sponsor',
+ },
+ ]),
+ },
+ })
+ },
+ ),
+ ],
+ },
+ },
+}
+
+export const MissingOrganizerDataOnly: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-organizer-missing',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get(
+ '/api/trpc/sponsor.contractTemplates.contractReadiness',
+ () => {
+ return HttpResponse.json({
+ result: {
+ data: mockReadinessMissing([
+ {
+ field: 'conference.name',
+ label: 'Conference name',
+ source: 'organizer',
+ },
+ {
+ field: 'conference.organizerOrgNumber',
+ label: 'Organizer org number',
+ source: 'organizer',
+ },
+ {
+ field: 'conference.organizerAddress',
+ label: 'Organizer address',
+ source: 'organizer',
+ },
+ {
+ field: 'conference.sponsorEmail',
+ label: 'Sponsor contact email',
+ source: 'organizer',
+ },
+ ]),
+ },
+ })
+ },
+ ),
+ ],
+ },
+ },
+}
diff --git a/src/components/admin/sponsor-crm/ContractReadinessIndicator.tsx b/src/components/admin/sponsor-crm/ContractReadinessIndicator.tsx
new file mode 100644
index 00000000..e332c0d6
--- /dev/null
+++ b/src/components/admin/sponsor-crm/ContractReadinessIndicator.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+import { api } from '@/lib/trpc/client'
+import {
+ CheckCircleIcon,
+ ExclamationTriangleIcon,
+} from '@heroicons/react/24/outline'
+import {
+ groupMissingBySource,
+ type MissingField,
+ type ReadinessSource,
+} from '@/lib/sponsor-crm/contract-readiness'
+
+const SOURCE_LABELS: Record = {
+ organizer: 'Conference settings',
+ sponsor: 'Sponsor (via onboarding)',
+ pipeline: 'CRM pipeline',
+}
+
+function MissingFieldList({ items }: { items: MissingField[] }) {
+ if (items.length === 0) return null
+ return (
+
+ {items.map((item) => (
+
+ • {item.label}
+
+ ))}
+
+ )
+}
+
+export function ContractReadinessIndicator({
+ sponsorForConferenceId,
+}: {
+ sponsorForConferenceId: string
+}) {
+ const { data: readiness, isLoading } =
+ api.sponsor.contractTemplates.contractReadiness.useQuery(
+ { id: sponsorForConferenceId },
+ { enabled: !!sponsorForConferenceId },
+ )
+
+ if (isLoading || !readiness) return null
+
+ if (readiness.ready) {
+ return (
+
+
+
+
+ Contract ready
+
+
+
+ )
+ }
+
+ const grouped = groupMissingBySource(readiness.missing)
+
+ return (
+
+
+
+
+ Missing data for contract
+
+
+
+ {(Object.entries(grouped) as [ReadinessSource, MissingField[]][])
+ .filter(([, items]) => items.length > 0)
+ .map(([source, items]) => (
+
+
+ {SOURCE_LABELS[source]}:
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/components/admin/sponsor-crm/ImportHistoricSponsorsButton.stories.tsx b/src/components/admin/sponsor-crm/ImportHistoricSponsorsButton.stories.tsx
new file mode 100644
index 00000000..6b37aac2
--- /dev/null
+++ b/src/components/admin/sponsor-crm/ImportHistoricSponsorsButton.stories.tsx
@@ -0,0 +1,220 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ ArrowDownTrayIcon,
+ CheckCircleIcon,
+ XCircleIcon,
+} from '@heroicons/react/24/outline'
+import { useState } from 'react'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Pipeline/ImportHistoricSponsorsButton',
+ parameters: {
+ layout: 'centered',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+interface ImportResult {
+ sponsor: string
+ success: boolean
+ error?: string
+ existed?: boolean
+}
+
+function ImportButton() {
+ const [isOpen, setIsOpen] = useState(false)
+
+ return (
+ <>
+ setIsOpen(true)}
+ className="flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ >
+
+ Import Historic Sponsors
+
+
+ {isOpen && (
+
+
+
+ Import Historic Sponsors
+
+
+ This will import all sponsors from the current conference's
+ inline sponsors array into the CRM pipeline as prospects.
+
+
+
+
+ Note: Sponsors that already exist in the
+ pipeline will be skipped.
+
+
+
+
+ setIsOpen(false)}
+ className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
+ >
+ Cancel
+
+ setIsOpen(false)}
+ className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
+ >
+ Import Sponsors
+
+
+
+
+ )}
+ >
+ )
+}
+
+function ImportResults() {
+ const results: ImportResult[] = [
+ { sponsor: 'TechGiant Corp', success: true },
+ { sponsor: 'CloudPro Inc', success: true },
+ { sponsor: 'DataSys', success: false, existed: true },
+ { sponsor: 'StartupX', success: true },
+ { sponsor: 'DevTools Ltd', success: false, error: 'Invalid data' },
+ ]
+
+ const successful = results.filter((r) => r.success).length
+ const skipped = results.filter((r) => r.existed).length
+ const failed = results.filter((r) => !r.success && !r.existed).length
+
+ return (
+
+
+ Import Complete
+
+
+
+
+
+ {successful}
+
+
Imported
+
+
+
+ {skipped}
+
+
Skipped
+
+
+
+ {failed}
+
+
Failed
+
+
+
+
+ {results.map((result, idx) => (
+
+
+ {result.sponsor}
+
+ {result.success ? (
+
+ ) : result.existed ? (
+
+ Already exists
+
+ ) : (
+
+
+
+ {result.error}
+
+
+ )}
+
+ ))}
+
+
+
+ Done
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+}
+
+export const Results: Story = {
+ render: () => (
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ ImportHistoricSponsorsButton
+
+
+ Imports sponsors from the legacy inline sponsors array on conference
+ documents into the new CRM pipeline. Used during migration from old
+ sponsor management to CRM.
+
+
+
+
+
Props
+
+
+
+ conferenceId
+ {' '}
+ - Conference to import sponsors for
+
+
+
+
+
+
+ Workflow
+
+
+ Click button to open confirmation modal
+ Review import notice and warning about duplicates
+ Confirm to start import process
+ View results with success/skip/fail counts
+ Close dialog when done
+
+
+
+
+
+ Migration Tool
+
+
+ This component is primarily used during the transition from inline
+ sponsor arrays to the new CRM system. It should only be visible to
+ admins and may be removed in future versions.
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor-crm/MobileFilterSheet.stories.tsx b/src/components/admin/sponsor-crm/MobileFilterSheet.stories.tsx
new file mode 100644
index 00000000..292edf6b
--- /dev/null
+++ b/src/components/admin/sponsor-crm/MobileFilterSheet.stories.tsx
@@ -0,0 +1,326 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { XMarkIcon, FunnelIcon, CheckIcon } from '@heroicons/react/24/outline'
+import { useState } from 'react'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Pipeline/MobileFilterSheet',
+ parameters: {
+ layout: 'centered',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const mockStatuses = [
+ { value: 'prospect', label: 'Prospect', count: 12 },
+ { value: 'contacted', label: 'Contacted', count: 8 },
+ { value: 'negotiating', label: 'Negotiating', count: 5 },
+ { value: 'confirmed', label: 'Confirmed', count: 4 },
+ { value: 'declined', label: 'Declined', count: 2 },
+]
+
+const mockTiers = [
+ { value: 'platinum', label: 'Platinum' },
+ { value: 'gold', label: 'Gold' },
+ { value: 'silver', label: 'Silver' },
+ { value: 'bronze', label: 'Bronze' },
+]
+
+const mockTags = [
+ { value: 'returning', label: 'Returning Sponsor' },
+ { value: 'priority', label: 'Priority' },
+ { value: 'follow-up', label: 'Needs Follow-up' },
+ { value: 'local', label: 'Local Company' },
+]
+
+function MobileFilterSheetDemo() {
+ const [isOpen, setIsOpen] = useState(true)
+ const [selectedStatuses, setSelectedStatuses] = useState([
+ 'prospect',
+ 'contacted',
+ ])
+ const [selectedTiers, setSelectedTiers] = useState(['gold'])
+ const [selectedTags, setSelectedTags] = useState([])
+
+ const toggleStatus = (status: string) => {
+ setSelectedStatuses((prev) =>
+ prev.includes(status)
+ ? prev.filter((s) => s !== status)
+ : [...prev, status],
+ )
+ }
+
+ const toggleTier = (tier: string) => {
+ setSelectedTiers((prev) =>
+ prev.includes(tier) ? prev.filter((t) => t !== tier) : [...prev, tier],
+ )
+ }
+
+ const toggleTag = (tag: string) => {
+ setSelectedTags((prev) =>
+ prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
+ )
+ }
+
+ const activeFilterCount =
+ selectedStatuses.length + selectedTiers.length + selectedTags.length
+
+ return (
+
+ {/* Trigger button */}
+
setIsOpen(true)}
+ className="relative flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
+ >
+
+ Filters
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+
+ {/* Sheet overlay */}
+ {isOpen && (
+
+
setIsOpen(false)}
+ />
+
+ {/* Sheet panel */}
+
+ {/* Handle */}
+
+
+ {/* Header */}
+
+
+ Filters
+
+
+ {
+ setSelectedStatuses([])
+ setSelectedTiers([])
+ setSelectedTags([])
+ }}
+ className="text-sm text-indigo-600 hover:text-indigo-700 dark:text-indigo-400"
+ >
+ Clear all
+
+ setIsOpen(false)}
+ className="rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
+ >
+
+
+
+
+
+
+ {/* Status filter */}
+
+
+ Status
+
+
+ {mockStatuses.map((status) => (
+ toggleStatus(status.value)}
+ className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors ${
+ selectedStatuses.includes(status.value)
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
+ }`}
+ >
+ {selectedStatuses.includes(status.value) && (
+
+ )}
+ {status.label}
+
+ ({status.count})
+
+
+ ))}
+
+
+
+ {/* Tier filter */}
+
+
+ Tier
+
+
+ {mockTiers.map((tier) => (
+ toggleTier(tier.value)}
+ className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors ${
+ selectedTiers.includes(tier.value)
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
+ }`}
+ >
+ {selectedTiers.includes(tier.value) && (
+
+ )}
+ {tier.label}
+
+ ))}
+
+
+
+ {/* Tags filter */}
+
+
+ Tags
+
+
+ {mockTags.map((tag) => (
+ toggleTag(tag.value)}
+ className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors ${
+ selectedTags.includes(tag.value)
+ ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400'
+ : 'bg-gray-100 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
+ }`}
+ >
+ {selectedTags.includes(tag.value) && (
+
+ )}
+ {tag.label}
+
+ ))}
+
+
+
+
+ {/* Apply button */}
+
+ setIsOpen(false)}
+ className="w-full rounded-lg bg-indigo-600 px-4 py-3 text-sm font-medium text-white hover:bg-indigo-700"
+ >
+ Apply Filters
+ {activeFilterCount > 0 && ` (${activeFilterCount})`}
+
+
+
+
+ )}
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+}
+
+export const TriggerOnly: Story = {
+ render: () => (
+
+
+
+ Filters
+
+ 3
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ MobileFilterSheet
+
+
+ Bottom sheet filter UI for mobile devices. Provides touch-friendly
+ filter selection for the sponsor CRM pipeline view.
+
+
+
+
+
Props
+
+
+
+ isOpen
+ {' '}
+ - Whether the sheet is visible
+
+
+
+ onClose
+ {' '}
+ - Callback when sheet is closed
+
+
+
+ filters
+ {' '}
+ - Current filter state (status, tier, tags)
+
+
+
+ onFiltersChange
+ {' '}
+ - Callback when filters change
+
+
+
+ availableTiers
+ {' '}
+ - Tier options from conference
+
+
+
+ statusCounts
+ {' '}
+ - Count of sponsors per status
+
+
+
+
+
+
+ Features
+
+
+ • Touch-friendly pill-style filter toggles
+ • Swipe-down to close gesture (via drag handle)
+ • Clear all filters option
+ • Sticky apply button at bottom
+ • Status counts shown inline
+ • Badge on trigger shows active filter count
+
+
+
+
+
+ Responsive Design
+
+
+ This component is only rendered on mobile viewports. On desktop,
+ filters are displayed in a sidebar or dropdown instead. Use responsive
+ utilities to conditionally render.
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor-crm/OnboardingLinkButton.stories.tsx b/src/components/admin/sponsor-crm/OnboardingLinkButton.stories.tsx
new file mode 100644
index 00000000..1e662f24
--- /dev/null
+++ b/src/components/admin/sponsor-crm/OnboardingLinkButton.stories.tsx
@@ -0,0 +1,120 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { OnboardingLinkButton } from './OnboardingLinkButton'
+import { http, HttpResponse } from 'msw'
+
+const meta: Meta
= {
+ title: 'Systems/Sponsors/Admin/Pipeline/OnboardingLinkButton',
+ component: OnboardingLinkButton,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Generates and displays unique onboarding portal links for sponsors. Uses tRPC mutation to create secure tokens that allow sponsors to access their self-service onboarding portal. Shows different states: not onboarded, existing token, or onboarding complete with timestamp.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Interactive: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-123',
+ onboardingComplete: false,
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.generateToken', () => {
+ return HttpResponse.json({
+ result: {
+ data: {
+ url: 'http://localhost:3000/sponsor/onboarding/tok_abc123xyz',
+ token: 'tok_abc123xyz',
+ },
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
+
+export const NotOnboarded: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-123',
+ onboardingComplete: false,
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.generateToken', () => {
+ return HttpResponse.json({
+ result: {
+ data: {
+ url: 'http://localhost:3000/sponsor/onboarding/tok_abc123xyz',
+ token: 'tok_abc123xyz',
+ },
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
+
+export const WithExistingToken: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-123',
+ existingToken: 'tok_existing123',
+ onboardingComplete: false,
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.generateToken', () => {
+ return HttpResponse.json({
+ result: {
+ data: {
+ url: 'http://localhost:3000/sponsor/onboarding/tok_abc123xyz',
+ token: 'tok_abc123xyz',
+ },
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
+
+export const OnboardingComplete: Story = {
+ args: {
+ sponsorForConferenceId: 'sfc-123',
+ onboardingComplete: true,
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.generateToken', () => {
+ return HttpResponse.json({
+ result: {
+ data: {
+ url: 'http://localhost:3000/sponsor/onboarding/tok_abc123xyz',
+ token: 'tok_abc123xyz',
+ },
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
diff --git a/src/components/admin/sponsor-crm/OnboardingLinkButton.tsx b/src/components/admin/sponsor-crm/OnboardingLinkButton.tsx
new file mode 100644
index 00000000..ee8f50f5
--- /dev/null
+++ b/src/components/admin/sponsor-crm/OnboardingLinkButton.tsx
@@ -0,0 +1,120 @@
+'use client'
+
+import { useState } from 'react'
+import { api } from '@/lib/trpc/client'
+import {
+ LinkIcon,
+ ClipboardDocumentIcon,
+ CheckIcon,
+} from '@heroicons/react/24/outline'
+
+interface OnboardingLinkButtonProps {
+ sponsorForConferenceId: string
+ existingToken?: string
+ onboardingComplete?: boolean
+}
+
+export function OnboardingLinkButton({
+ sponsorForConferenceId,
+ existingToken,
+ onboardingComplete,
+}: OnboardingLinkButtonProps) {
+ const [showLink, setShowLink] = useState(false)
+ const [generatedUrl, setGeneratedUrl] = useState(null)
+ const [copied, setCopied] = useState(false)
+
+ const generateMutation = api.onboarding.generateToken.useMutation({
+ onSuccess: (data) => {
+ setGeneratedUrl(data.url)
+ setShowLink(true)
+ },
+ })
+
+ const handleGenerate = () => {
+ generateMutation.mutate({ sponsorForConferenceId })
+ }
+
+ const handleCopy = async () => {
+ if (!generatedUrl) return
+ try {
+ await navigator.clipboard.writeText(generatedUrl)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ } catch {
+ // Fallback: select the input text for manual copy
+ const input = document.querySelector(
+ 'input[readonly][value="' + generatedUrl + '"]',
+ )
+ if (input) {
+ input.select()
+ input.setSelectionRange(0, input.value.length)
+ }
+ }
+ }
+
+ if (onboardingComplete) {
+ return (
+
+
+ Onboarded
+
+ )
+ }
+
+ if (showLink && generatedUrl) {
+ return (
+
+
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ setShowLink(false)}
+ className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
+ >
+ ×
+
+
+ )
+ }
+
+ return (
+ {
+ const baseUrl = window.location.origin
+ setGeneratedUrl(`${baseUrl}/sponsor/onboarding/${existingToken}`)
+ setShowLink(true)
+ }
+ : handleGenerate
+ }
+ disabled={generateMutation.isPending}
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-gray-300 ring-inset hover:bg-gray-50 dark:bg-gray-800 dark:text-white dark:ring-gray-600 dark:hover:bg-gray-700"
+ title={
+ existingToken ? 'Show onboarding link' : 'Generate onboarding link'
+ }
+ >
+
+
+ {generateMutation.isPending ? 'Generating\u2026' : 'Onboard'}
+
+
+ )
+}
diff --git a/src/components/admin/sponsor-crm/SponsorBoardColumn.stories.tsx b/src/components/admin/sponsor-crm/SponsorBoardColumn.stories.tsx
new file mode 100644
index 00000000..ad4c991a
--- /dev/null
+++ b/src/components/admin/sponsor-crm/SponsorBoardColumn.stories.tsx
@@ -0,0 +1,205 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { SponsorBoardColumn } from './SponsorBoardColumn'
+import { mockSponsors, mockSponsor } from '@/__mocks__/sponsor-data'
+import { DndContext } from '@dnd-kit/core'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Admin/Pipeline/SponsorBoardColumn',
+ component: SponsorBoardColumn,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Kanban board column for organizing sponsors by pipeline stage. Supports drag-and-drop sponsor cards between columns to update their status. The CRM pipeline follows these stages: Prospect → Contacted → Negotiating → Closed Won/Lost. Each column shows a count badge and supports selection mode for bulk operations.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Interactive: Story = {
+ args: {
+ columnKey: 'negotiating',
+ title: 'Negotiating',
+ sponsors: [
+ mockSponsors.negotiating,
+ mockSponsor({
+ _id: 'sfc-5',
+ sponsor: {
+ ...mockSponsors.negotiating.sponsor,
+ name: 'Container Platform AS',
+ },
+ status: 'negotiating',
+ contractValue: 150000,
+ tags: ['high-priority', 'multi-year-potential'],
+ }),
+ ],
+ currentView: 'pipeline',
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onSponsorEmail: (sponsor) => console.log('Email sponsor', sponsor),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
+
+export const ProspectColumn: Story = {
+ args: {
+ columnKey: 'prospect',
+ title: 'Prospect',
+ sponsors: [
+ mockSponsors.prospect,
+ mockSponsor({
+ _id: 'sfc-2',
+ sponsor: { ...mockSponsors.prospect.sponsor, name: 'Tech Corp' },
+ status: 'prospect',
+ }),
+ ],
+ currentView: 'pipeline',
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
+
+export const ContactedColumn: Story = {
+ args: {
+ columnKey: 'contacted',
+ title: 'Contacted',
+ sponsors: [
+ mockSponsors.contacted,
+ mockSponsor({
+ _id: 'sfc-4',
+ sponsor: {
+ ...mockSponsors.contacted.sponsor,
+ name: 'DevOps Solutions',
+ },
+ status: 'contacted',
+ tags: ['warm-lead'],
+ }),
+ ],
+ currentView: 'pipeline',
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onSponsorEmail: (sponsor) => console.log('Email sponsor', sponsor),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
+
+export const NegotiatingColumn: Story = {
+ args: {
+ columnKey: 'negotiating',
+ title: 'Negotiating',
+ sponsors: [
+ mockSponsors.negotiating,
+ mockSponsor({
+ _id: 'sfc-5',
+ sponsor: {
+ ...mockSponsors.negotiating.sponsor,
+ name: 'Container Platform',
+ },
+ status: 'negotiating',
+ contractValue: 150000,
+ }),
+ ],
+ currentView: 'pipeline',
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onSponsorEmail: (sponsor) => console.log('Email sponsor', sponsor),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
+
+export const ContractBoard: Story = {
+ args: {
+ columnKey: 'contract-sent',
+ title: 'Contract Sent',
+ sponsors: [
+ mockSponsor({
+ status: 'closed-won',
+ contractStatus: 'contract-sent',
+ invoiceStatus: 'not-sent',
+ }),
+ ],
+ currentView: 'contract',
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
+
+export const InvoiceBoard: Story = {
+ args: {
+ columnKey: 'sent',
+ title: 'Invoice Sent',
+ sponsors: [
+ mockSponsor({
+ status: 'closed-won',
+ contractStatus: 'contract-signed',
+ invoiceStatus: 'sent',
+ }),
+ ],
+ currentView: 'invoice',
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
+
+export const EmptyColumn: Story = {
+ args: {
+ columnKey: 'closed-won',
+ title: 'Closed - Won',
+ sponsors: [],
+ currentView: 'pipeline',
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
+
+export const Loading: Story = {
+ args: {
+ columnKey: 'prospect',
+ title: 'Prospect',
+ sponsors: [],
+ isLoading: true,
+ currentView: 'pipeline',
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
+
+export const WithSelection: Story = {
+ args: {
+ columnKey: 'negotiating',
+ title: 'Negotiating',
+ sponsors: [
+ mockSponsors.negotiating,
+ mockSponsor({
+ _id: 'sfc-6',
+ sponsor: { ...mockSponsors.negotiating.sponsor, name: 'K8s Experts' },
+ status: 'negotiating',
+ }),
+ ],
+ currentView: 'pipeline',
+ selectedIds: ['sfc-123'],
+ isSelectionMode: true,
+ onSponsorClick: (sponsor) => console.log('Sponsor clicked', sponsor),
+ onSponsorDelete: (id) => console.log('Delete sponsor', id),
+ onSponsorToggleSelect: (id) => console.log('Toggle select', id),
+ onAddClick: () => console.log('Add sponsor'),
+ },
+}
diff --git a/src/components/admin/sponsor-crm/SponsorBulkActions.stories.tsx b/src/components/admin/sponsor-crm/SponsorBulkActions.stories.tsx
new file mode 100644
index 00000000..dd0e38f8
--- /dev/null
+++ b/src/components/admin/sponsor-crm/SponsorBulkActions.stories.tsx
@@ -0,0 +1,91 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { SponsorBulkActions } from './SponsorBulkActions'
+import { NotificationProvider } from '@/components/admin/NotificationProvider'
+import { http, HttpResponse } from 'msw'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Admin/Pipeline/SponsorBulkActions',
+ component: SponsorBulkActions,
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+
+ The bulk actions toolbar appears at the bottom of the screen when
+ sponsors are selected
+
+
+
+
+
+ ),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Toolbar for performing bulk operations on selected sponsors. Appears when one or more sponsors are selected in the CRM interface. Allows batch updates to status, assignment, tags, and deletion. Uses Fresh Green (#10B981) for action buttons following the brand system.',
+ },
+ },
+ msw: {
+ handlers: [
+ http.get('/api/trpc/sponsor.crm.listOrganizers', () => {
+ return HttpResponse.json({
+ result: {
+ data: [
+ { _id: 'org-1', name: 'John Doe', email: 'john@example.com' },
+ {
+ _id: 'org-2',
+ name: 'Jane Smith',
+ email: 'jane@example.com',
+ },
+ {
+ _id: 'org-3',
+ name: 'Bob Johnson',
+ email: 'bob@example.com',
+ },
+ ],
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
+
+export default meta
+type Story = StoryObj
+
+export const Interactive: Story = {
+ args: {
+ selectedIds: ['sfc-123', 'sfc-456', 'sfc-789'],
+ onClearSelection: () => console.log('Clear selection'),
+ onSuccess: () => console.log('Success'),
+ },
+}
+
+export const SingleSelected: Story = {
+ args: {
+ selectedIds: ['sfc-123'],
+ onClearSelection: () => console.log('Clear'),
+ onSuccess: () => console.log('Success'),
+ },
+}
+
+export const MultipleSelected: Story = {
+ args: {
+ selectedIds: ['sfc-123', 'sfc-456', 'sfc-789'],
+ onClearSelection: () => console.log('Clear'),
+ onSuccess: () => console.log('Success'),
+ },
+}
+
+export const ManySelected: Story = {
+ args: {
+ selectedIds: Array.from({ length: 15 }, (_, i) => `sfc-${i + 1}`),
+ onClearSelection: () => console.log('Clear'),
+ onSuccess: () => console.log('Success'),
+ },
+}
diff --git a/src/components/admin/sponsor-crm/SponsorCRMFilterBar.stories.tsx b/src/components/admin/sponsor-crm/SponsorCRMFilterBar.stories.tsx
new file mode 100644
index 00000000..8b8ea553
--- /dev/null
+++ b/src/components/admin/sponsor-crm/SponsorCRMFilterBar.stories.tsx
@@ -0,0 +1,170 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { fn } from 'storybook/test'
+import { SponsorCRMFilterBar } from './SponsorCRMFilterBar'
+import type { SponsorTier } from '@/lib/sponsor/types'
+
+const mockTiers: SponsorTier[] = [
+ {
+ _id: 'tier-1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Platinum',
+ tagline: 'Premium sponsorship tier',
+ tierType: 'standard',
+ price: [{ _key: 'nok', amount: 50000, currency: 'NOK' }],
+ soldOut: false,
+ mostPopular: false,
+ },
+ {
+ _id: 'tier-2',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Gold',
+ tagline: 'Gold sponsorship tier',
+ tierType: 'standard',
+ price: [{ _key: 'nok', amount: 25000, currency: 'NOK' }],
+ soldOut: false,
+ mostPopular: true,
+ },
+ {
+ _id: 'tier-3',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Silver',
+ tagline: 'Silver sponsorship tier',
+ tierType: 'standard',
+ price: [{ _key: 'nok', amount: 10000, currency: 'NOK' }],
+ soldOut: false,
+ mostPopular: false,
+ },
+]
+
+const mockOrganizers = [
+ { _id: 'org-1', name: 'Alice Johnson', email: 'alice@example.com' },
+ { _id: 'org-2', name: 'Bob Smith', email: 'bob@example.com' },
+ { _id: 'org-3', name: 'Carol Williams', email: 'carol@example.com' },
+]
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/SponsorCRMFilterBar',
+ component: SponsorCRMFilterBar,
+ parameters: {
+ layout: 'padded',
+ },
+ args: {
+ currentView: 'pipeline',
+ onViewChange: fn(),
+ searchQuery: '',
+ onSearchChange: fn(),
+ tiers: mockTiers,
+ tiersFilter: [],
+ onToggleTier: fn(),
+ organizers: mockOrganizers,
+ assignedToFilter: undefined,
+ onSetOrganizer: fn(),
+ tagsFilter: [],
+ onToggleTag: fn(),
+ onClearAllFilters: fn(),
+ selectedCount: 0,
+ onSelectAll: fn(),
+ onClearSelection: fn(),
+ isMobileSearchOpen: false,
+ onToggleMobileSearch: fn(),
+ onOpenMobileFilter: fn(),
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {},
+}
+
+export const WithSearch: Story = {
+ args: {
+ searchQuery: 'Google',
+ },
+}
+
+export const WithTierFilter: Story = {
+ args: {
+ tiersFilter: ['tier-1', 'tier-2'],
+ },
+}
+
+export const WithOwnerFilter: Story = {
+ args: {
+ assignedToFilter: 'org-1',
+ },
+}
+
+export const WithUnassignedFilter: Story = {
+ args: {
+ assignedToFilter: 'unassigned',
+ },
+}
+
+export const WithTagsFilter: Story = {
+ args: {
+ tagsFilter: ['returning-sponsor', 'high-priority'],
+ },
+}
+
+export const WithMultipleFilters: Story = {
+ args: {
+ searchQuery: 'Tech',
+ tiersFilter: ['tier-1'],
+ assignedToFilter: 'org-2',
+ tagsFilter: ['returning-sponsor'],
+ },
+}
+
+export const WithSelection: Story = {
+ args: {
+ selectedCount: 5,
+ },
+}
+
+export const ContractView: Story = {
+ args: {
+ currentView: 'contract',
+ },
+}
+
+export const InvoiceView: Story = {
+ args: {
+ currentView: 'invoice',
+ },
+}
+
+export const MobileSearchOpen: Story = {
+ args: {
+ isMobileSearchOpen: true,
+ },
+ parameters: {
+ viewport: {
+ defaultViewport: 'mobile1',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export const NoTiers: Story = {
+ args: {
+ tiers: [],
+ },
+}
diff --git a/src/components/admin/sponsor-crm/SponsorCRMFilterBar.tsx b/src/components/admin/sponsor-crm/SponsorCRMFilterBar.tsx
new file mode 100644
index 00000000..fc717f64
--- /dev/null
+++ b/src/components/admin/sponsor-crm/SponsorCRMFilterBar.tsx
@@ -0,0 +1,374 @@
+'use client'
+
+import {
+ MagnifyingGlassIcon,
+ XMarkIcon,
+ FunnelIcon,
+} from '@heroicons/react/20/solid'
+import { FilterDropdown, FilterOption } from '@/components/admin/FilterDropdown'
+import {
+ BoardViewSwitcher,
+ type BoardView,
+} from '@/components/admin/sponsor-crm/BoardViewSwitcher'
+import {
+ sortSponsorTiers,
+ formatTierLabel,
+} from '@/components/admin/sponsor-crm/utils'
+import { TAGS } from '@/components/admin/sponsor-crm/form/constants'
+import type { SponsorTag } from '@/lib/sponsor-crm/types'
+import type { SponsorTier } from '@/lib/sponsor/types'
+import clsx from 'clsx'
+
+export interface Organizer {
+ _id: string
+ name: string
+ email: string
+ avatar?: string
+}
+
+export interface SponsorCRMFilterBarProps {
+ /** Current board view mode */
+ currentView: BoardView
+ /** Callback when view changes */
+ onViewChange: (view: BoardView) => void
+ /** Search query string */
+ searchQuery: string
+ /** Callback when search query changes */
+ onSearchChange: (query: string) => void
+ /** Available sponsor tiers for filtering */
+ tiers: SponsorTier[]
+ /** Currently selected tier IDs */
+ tiersFilter: string[]
+ /** Callback to toggle a tier filter */
+ onToggleTier: (tierId: string) => void
+ /** Available organizers for filtering */
+ organizers: Organizer[]
+ /** Currently selected organizer ID or 'unassigned' */
+ assignedToFilter?: string
+ /** Callback to set organizer filter */
+ onSetOrganizer: (organizerId: string | null) => void
+ /** Currently selected tags */
+ tagsFilter: SponsorTag[]
+ /** Callback to toggle a tag filter */
+ onToggleTag: (tag: SponsorTag) => void
+ /** Callback to clear all filters */
+ onClearAllFilters: () => void
+ /** Number of selected items */
+ selectedCount: number
+ /** Callback to select all filtered items */
+ onSelectAll: () => void
+ /** Callback to clear selection */
+ onClearSelection: () => void
+ /** Whether mobile search is open */
+ isMobileSearchOpen?: boolean
+ /** Callback to toggle mobile search */
+ onToggleMobileSearch?: () => void
+ /** Callback to open mobile filter sheet */
+ onOpenMobileFilter?: () => void
+}
+
+export function SponsorCRMFilterBar({
+ currentView,
+ onViewChange,
+ searchQuery,
+ onSearchChange,
+ tiers,
+ tiersFilter,
+ onToggleTier,
+ organizers,
+ assignedToFilter,
+ onSetOrganizer,
+ tagsFilter,
+ onToggleTag,
+ onClearAllFilters,
+ selectedCount,
+ onSelectAll,
+ onClearSelection,
+ isMobileSearchOpen = false,
+ onToggleMobileSearch,
+ onOpenMobileFilter,
+}: SponsorCRMFilterBarProps) {
+ const activeFilterCount =
+ tiersFilter.length + (assignedToFilter ? 1 : 0) + tagsFilter.length
+
+ return (
+
+
+ {/* View Switcher */}
+
+
+
+
+
+
+ {/* Search - desktop: always visible, mobile: expandable */}
+
+
+
+ onSearchChange(e.target.value)}
+ placeholder="Search sponsors..."
+ className="h-9 w-full rounded-lg bg-gray-50 pr-8 pl-9 text-sm ring-1 ring-gray-300 transition-all ring-inset focus:bg-white focus:ring-2 focus:ring-indigo-500 focus:ring-inset dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:bg-white/10"
+ />
+ {searchQuery && (
+ onSearchChange('')}
+ className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+ >
+
+
+ )}
+
+
+
+ {/* Mobile: search toggle */}
+ {onToggleMobileSearch && (
+
+
+
+ )}
+
+
+
+ {/* Filter dropdowns - desktop only */}
+
+
+ {tiers.length === 0 ? (
+
+ No tiers available
+
+ ) : (
+ sortSponsorTiers(tiers).map((tier: SponsorTier) => (
+ onToggleTier(tier._id)}
+ checked={tiersFilter.includes(tier._id)}
+ keepOpen
+ >
+ {formatTierLabel(tier)}
+
+ ))
+ )}
+
+
+
+ onSetOrganizer(null)}
+ checked={!assignedToFilter}
+ type="radio"
+ >
+ All Owners
+
+ onSetOrganizer('unassigned')}
+ checked={assignedToFilter === 'unassigned'}
+ type="radio"
+ >
+ Unassigned
+
+
+ {organizers.map((organizer) => (
+ onSetOrganizer(organizer._id)}
+ checked={assignedToFilter === organizer._id}
+ type="radio"
+ >
+ {organizer.name}
+
+ ))}
+
+
+
+ {TAGS.map((tag) => (
+ onToggleTag(tag.value)}
+ checked={tagsFilter.includes(tag.value)}
+ keepOpen
+ >
+ {tag.label}
+
+ ))}
+
+
+
+ {/* Mobile: Filter button with badge */}
+ {onOpenMobileFilter && (
+
0
+ ? 'bg-indigo-100 text-indigo-700 ring-1 ring-indigo-300 ring-inset dark:bg-indigo-900/40 dark:text-indigo-300 dark:ring-indigo-700'
+ : 'text-gray-600 ring-1 ring-gray-300 ring-inset hover:bg-gray-50 dark:text-gray-400 dark:ring-white/10 dark:hover:bg-gray-800',
+ )}
+ >
+
+ Filter
+ {activeFilterCount > 0 && (
+
+ {activeFilterCount}
+
+ )}
+
+ )}
+
+
+
+ {/* Select all / Clear selection */}
+ {selectedCount === 0 ? (
+
+ Select all
+
+ ) : (
+
+ Clear ({selectedCount})
+
+ )}
+
+
+ {/* Mobile: expandable search row */}
+ {isMobileSearchOpen && (
+
+
+
+ onSearchChange(e.target.value)}
+ placeholder="Search sponsors..."
+ autoFocus
+ className="h-9 w-full rounded-lg bg-gray-50 pr-8 pl-9 text-sm ring-1 ring-gray-300 transition-all ring-inset focus:bg-white focus:ring-2 focus:ring-indigo-500 focus:ring-inset dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-gray-500 dark:focus:bg-white/10"
+ />
+ {searchQuery && (
+ onSearchChange('')}
+ className="absolute top-1/2 right-2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
+ >
+
+
+ )}
+
+
+ )}
+
+ {/* Row 2: Active filter pills (appears only when filters are active) */}
+ {(activeFilterCount > 0 || searchQuery) && (
+
+ {/* Tier pills */}
+ {tiersFilter.map((tierId) => {
+ const tier = tiers.find((t) => t._id === tierId)
+ return (
+ onToggleTier(tierId)}
+ />
+ )
+ })}
+
+ {/* Owner pill */}
+ {assignedToFilter && (
+ o._id === assignedToFilter)?.name ||
+ 'Owner'
+ }
+ category="Owner"
+ onRemove={() => onSetOrganizer(null)}
+ />
+ )}
+
+ {/* Tag pills */}
+ {tagsFilter.map((tag) => {
+ const tagDef = TAGS.find((t) => t.value === tag)
+ return (
+ onToggleTag(tag)}
+ />
+ )
+ })}
+
+ {/* Search pill */}
+ {searchQuery && (
+ onSearchChange('')}
+ />
+ )}
+
+ {/* Clear all */}
+
+ Clear all
+
+
+ )}
+
+ )
+}
+
+interface FilterPillProps {
+ label: string
+ category: string
+ onRemove: () => void
+}
+
+export function FilterPill({ label, category, onRemove }: FilterPillProps) {
+ return (
+
+ {category}:
+ {label}
+
+
+
+
+ )
+}
diff --git a/src/components/admin/sponsor-crm/SponsorCRMForm.stories.tsx b/src/components/admin/sponsor-crm/SponsorCRMForm.stories.tsx
new file mode 100644
index 00000000..c6161e93
--- /dev/null
+++ b/src/components/admin/sponsor-crm/SponsorCRMForm.stories.tsx
@@ -0,0 +1,323 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ XMarkIcon,
+ ClockIcon,
+ InformationCircleIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Pipeline/SponsorCRMForm',
+ parameters: {
+ layout: 'centered',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const PIPELINE_STATUSES = [
+ { value: 'prospect', label: 'Prospect' },
+ { value: 'contacted', label: 'Contacted' },
+ { value: 'negotiating', label: 'Negotiating' },
+ { value: 'confirmed', label: 'Confirmed' },
+ { value: 'declined', label: 'Declined' },
+]
+
+const TIERS = [
+ { id: 'platinum', name: 'Platinum', value: 100000 },
+ { id: 'gold', name: 'Gold', value: 50000 },
+ { id: 'silver', name: 'Silver', value: 25000 },
+ { id: 'bronze', name: 'Bronze', value: 10000 },
+ { id: 'community', name: 'Community', value: 0 },
+]
+
+function CRMFormPreview() {
+ return (
+
+ {/* Header */}
+
+
+
+ TechGiant Corp
+
+
+ Edit sponsor pipeline entry
+
+
+
+
+
+
+
+ {/* Tabs */}
+
+
+
+ Pipeline
+
+
+
+
+ History
+
+
+
+
+
+
+ {/* Sponsor Selection */}
+
+
+ Sponsor
+
+
+
+ TG
+
+
+
+ TechGiant Corp
+
+
+ techgiant.com
+
+
+
+
+
+ {/* Status & Tier Row */}
+
+
+
+ Pipeline Status
+
+
+ {PIPELINE_STATUSES.map((s) => (
+
+ {s.label}
+
+ ))}
+
+
+
+
+ Assigned To
+
+
+ Unassigned
+ Hans Kristian
+ Maria Jensen
+ Erik Olsen
+
+
+
+
+ {/* Tier Selection */}
+
+
+ Sponsor Tier
+
+
+ {TIERS.slice(0, 3).map((tier) => (
+
+
+
+
+
+ {tier.name}
+
+
+ {tier.value > 0
+ ? new Intl.NumberFormat('nb-NO', {
+ style: 'currency',
+ currency: 'NOK',
+ maximumFractionDigits: 0,
+ }).format(tier.value)
+ : 'Free'}
+
+
+
+
+ ))}
+
+
+
+ {/* Contract Value */}
+
+
+ Contract Value
+
+
+
+
+ NOK
+ EUR
+ USD
+
+
+
+
+ {/* Tags */}
+
+
+ Tags
+
+
+
+ returning
+ Ă—
+
+
+ priority
+ Ă—
+
+
+ + Add tag
+
+
+
+
+ {/* Notes */}
+
+
+ Notes
+
+
+
+
+ {/* Info Box */}
+
+
+
+ Contract status and invoice status are managed separately on their
+ respective tabs.
+
+
+
+
+ {/* Footer */}
+
+
+ Delete Entry
+
+
+
+ Cancel
+
+
+ Save Changes
+
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorCRMForm
+
+
+ Main edit modal for sponsor pipeline entries. Includes all CRM fields
+ organized into tabs: Pipeline details, History timeline.
+
+
+
+
+
Props
+
+
+
+ isOpen
+ {' '}
+ - Whether modal is visible
+
+
+
+ onClose
+ {' '}
+ - Callback when closed
+
+
+
+ sponsor
+ {' '}
+ - SponsorForConference to edit (null for new)
+
+
+
+ conference
+ {' '}
+ - Conference with tier definitions
+
+
+
+ initialView
+ {' '}
+ - Tab to open (pipeline/history)
+
+
+
+
+
+
+ Form Sections
+
+
+ • Sponsor selection (existing or create new)
+ • Pipeline status and assignee
+ • Tier selection with radio group
+ • Contract value with currency
+ • Add-ons checkbox group
+ • Tags combobox
+ • Notes textarea
+
+
+
+
+
Tabs
+
+
+ Pipeline - Main form fields for sponsor details
+
+
+ History - Activity timeline with all events
+
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor-crm/SponsorCRMForm.tsx b/src/components/admin/sponsor-crm/SponsorCRMForm.tsx
index 0a91a086..8bb7e9ba 100644
--- a/src/components/admin/sponsor-crm/SponsorCRMForm.tsx
+++ b/src/components/admin/sponsor-crm/SponsorCRMForm.tsx
@@ -42,6 +42,8 @@ import { SponsorLogoEditor } from '../sponsor/SponsorLogoEditor'
import { SponsorActivityTimeline } from '../sponsor/SponsorActivityTimeline'
import { SponsorTier } from '@/lib/sponsor/types'
import { useSponsorCRMFormMutations } from '@/hooks/useSponsorCRMFormMutations'
+import { OnboardingLinkButton } from './OnboardingLinkButton'
+import { ContractReadinessIndicator } from './ContractReadinessIndicator'
type FormView = 'pipeline' | 'contacts' | 'logo' | 'history'
@@ -278,6 +280,11 @@ export function SponsorCRMForm({
{sponsor && (
<>
+
{
@@ -553,6 +560,13 @@ export function SponsorCRMForm({
className="mt-1.5 block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6 dark:bg-white/5 dark:text-white dark:outline-white/10"
/>
+
+ {/* Contract Readiness */}
+ {sponsor && (
+
+ )}
diff --git a/src/components/admin/sponsor-crm/SponsorCRMPipeline.stories.tsx b/src/components/admin/sponsor-crm/SponsorCRMPipeline.stories.tsx
new file mode 100644
index 00000000..29b44ae5
--- /dev/null
+++ b/src/components/admin/sponsor-crm/SponsorCRMPipeline.stories.tsx
@@ -0,0 +1,352 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ MagnifyingGlassIcon,
+ FunnelIcon,
+ PlusIcon,
+ Squares2X2Icon,
+ TagIcon,
+ CurrencyDollarIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Pipeline/SponsorCRMPipeline',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const PIPELINE_STATUSES = [
+ { id: 'prospect', label: 'Prospect', color: 'bg-gray-100 text-gray-700' },
+ { id: 'contacted', label: 'Contacted', color: 'bg-blue-100 text-blue-700' },
+ {
+ id: 'negotiating',
+ label: 'Negotiating',
+ color: 'bg-amber-100 text-amber-700',
+ },
+ { id: 'confirmed', label: 'Confirmed', color: 'bg-green-100 text-green-700' },
+ { id: 'declined', label: 'Declined', color: 'bg-red-100 text-red-700' },
+]
+
+interface MockSponsor {
+ id: string
+ name: string
+ tier: string
+ tags: string[]
+ assignee: string
+ value?: number
+}
+
+const mockSponsors: Record = {
+ prospect: [
+ {
+ id: '1',
+ name: 'TechCorp AS',
+ tier: 'Gold',
+ tags: ['returning'],
+ assignee: 'Hans',
+ value: 50000,
+ },
+ {
+ id: '2',
+ name: 'CloudSoft Inc',
+ tier: '',
+ tags: ['priority'],
+ assignee: 'Maria',
+ },
+ ],
+ contacted: [
+ {
+ id: '3',
+ name: 'DataSys Norge',
+ tier: 'Silver',
+ tags: [],
+ assignee: 'Hans',
+ value: 25000,
+ },
+ ],
+ negotiating: [
+ {
+ id: '4',
+ name: 'DevTools Pro',
+ tier: 'Platinum',
+ tags: ['returning'],
+ assignee: 'Erik',
+ value: 100000,
+ },
+ {
+ id: '5',
+ name: 'OpenSource Labs',
+ tier: 'Community',
+ tags: ['community'],
+ assignee: 'Sofia',
+ },
+ ],
+ confirmed: [
+ {
+ id: '6',
+ name: 'MegaCorp',
+ tier: 'Platinum',
+ tags: ['returning'],
+ assignee: 'Hans',
+ value: 100000,
+ },
+ {
+ id: '7',
+ name: 'StartupX',
+ tier: 'Gold',
+ tags: [],
+ assignee: 'Maria',
+ value: 50000,
+ },
+ {
+ id: '8',
+ name: 'Local Business',
+ tier: 'Bronze',
+ tags: ['local'],
+ assignee: 'Erik',
+ value: 10000,
+ },
+ ],
+ declined: [
+ {
+ id: '9',
+ name: 'OtherCo',
+ tier: '',
+ tags: [],
+ assignee: 'Sofia',
+ },
+ ],
+}
+
+function getTierColor(tier: string) {
+ switch (tier.toLowerCase()) {
+ case 'platinum':
+ return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
+ case 'gold':
+ return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
+ case 'silver':
+ return 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
+ case 'bronze':
+ return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
+ case 'community':
+ return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
+ default:
+ return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
+ }
+}
+
+function SponsorCardPreview({ sponsor }: { sponsor: MockSponsor }) {
+ return (
+
+
+
+ {sponsor.name}
+
+ {sponsor.tier && (
+
+ {sponsor.tier}
+
+ )}
+
+ {sponsor.value && (
+
+ {new Intl.NumberFormat('nb-NO', {
+ style: 'currency',
+ currency: 'NOK',
+ maximumFractionDigits: 0,
+ }).format(sponsor.value)}
+
+ )}
+
+
+ {sponsor.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ {sponsor.assignee[0]}
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+ {/* Header */}
+
+
+
+
+ Sponsor Pipeline
+
+
+ Cloud Native Days Bergen 2025
+
+
+
+
+
+
+
+
+
+ Filters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add Sponsor
+
+
+
+
+
+ {/* Board */}
+
+ {PIPELINE_STATUSES.map((status) => (
+
+
+
+
+ {status.label}
+
+
+ {mockSponsors[status.id]?.length || 0}
+
+
+
+
+ {mockSponsors[status.id]?.map((sponsor) => (
+
+ ))}
+ {(!mockSponsors[status.id] ||
+ mockSponsors[status.id].length === 0) && (
+
+ No sponsors
+
+ )}
+
+
+ ))}
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorCRMPipeline
+
+
+ Main Kanban board for managing sponsors through the sales pipeline.
+ Supports drag-and-drop status changes, multiple view modes, search,
+ and filtering.
+
+
+
+
+
Props
+
+
+
+ conferenceId
+ {' '}
+ - Conference to manage sponsors for
+
+
+
+ conference
+ {' '}
+ - Full conference object with tiers
+
+
+
+ domain
+ {' '}
+ - Conference domain for links
+
+
+
+ externalNewTrigger
+ {' '}
+ - Counter to trigger new sponsor form
+
+
+
+
+
+
+ View Modes
+
+
+
+ Pipeline - Status-based columns (Prospect →
+ Confirmed)
+
+
+ Tier - Group by sponsor tier (Platinum, Gold, etc.)
+
+
+ Invoice - Group by invoice status
+
+
+
+
+
+
+ Features
+
+
+ • Drag-and-drop between columns
+ • Quick search by sponsor name
+ • Filter by status, tier, tags, assignee
+ • Bulk selection and actions
+ • Click card to open edit modal
+ • Quick email action on cards
+ • Real-time data with tRPC
+
+
+
+
+
Note
+
+ This component has complex dependencies including tRPC queries,
+ drag-and-drop context, and URL state management. The story above shows
+ a static preview of the UI layout.
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor-crm/SponsorCard.stories.tsx b/src/components/admin/sponsor-crm/SponsorCard.stories.tsx
new file mode 100644
index 00000000..f877d16d
--- /dev/null
+++ b/src/components/admin/sponsor-crm/SponsorCard.stories.tsx
@@ -0,0 +1,211 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { SponsorCard } from './SponsorCard'
+import { mockSponsors, mockSponsor } from '@/__mocks__/sponsor-data'
+import { DndContext } from '@dnd-kit/core'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Admin/Pipeline/SponsorCard',
+ component: SponsorCard,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Visual representation of sponsors in the CRM pipeline. Each card displays sponsor information, current status, contract value, and available actions. Cards support drag-and-drop for pipeline management and adapt their layout based on the current view (pipeline, contract, or invoice board). The design follows Nordic minimalism principles with clear status indicators using the brand color system.',
+ },
+ },
+ },
+ argTypes: {
+ currentView: {
+ control: 'select',
+ options: ['pipeline', 'contract', 'invoice'],
+ description:
+ 'Current board view affects card layout and displayed information',
+ },
+ isSelected: {
+ control: 'boolean',
+ description: 'Whether the card is selected for bulk actions',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Interactive: Story = {
+ args: {
+ sponsor: mockSponsors.negotiating,
+ currentView: 'pipeline',
+ onEdit: () => console.log('Edit clicked'),
+ onDelete: () => console.log('Delete clicked'),
+ onEmail: () => console.log('Email clicked'),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Interactive playground - use controls to change view type, selection state, and other properties.',
+ },
+ },
+ },
+}
+
+export const Prospect: Story = {
+ args: {
+ sponsor: mockSponsors.prospect,
+ currentView: 'pipeline',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const Contacted: Story = {
+ args: {
+ sponsor: mockSponsors.contacted,
+ currentView: 'pipeline',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const Negotiating: Story = {
+ args: {
+ sponsor: mockSponsors.negotiating,
+ currentView: 'pipeline',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const ClosedWon: Story = {
+ args: {
+ sponsor: mockSponsors.closedWon,
+ currentView: 'pipeline',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const ClosedLost: Story = {
+ args: {
+ sponsor: mockSponsors.closedLost,
+ currentView: 'pipeline',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const PipelineView: Story = {
+ args: {
+ sponsor: mockSponsors.negotiating,
+ currentView: 'pipeline',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const ContractView: Story = {
+ args: {
+ sponsor: mockSponsor({
+ status: 'closed-won',
+ contractStatus: 'contract-sent',
+ }),
+ currentView: 'contract',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const InvoiceView: Story = {
+ args: {
+ sponsor: mockSponsor({
+ status: 'closed-won',
+ contractStatus: 'contract-signed',
+ invoiceStatus: 'sent',
+ }),
+ currentView: 'invoice',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const WithAssignee: Story = {
+ args: {
+ sponsor: mockSponsor({
+ assignedTo: {
+ _id: 'speaker-1',
+ name: 'John Doe',
+ email: 'john@example.com',
+ },
+ }),
+ currentView: 'pipeline',
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const Selected: Story = {
+ args: {
+ sponsor: mockSponsors.negotiating,
+ currentView: 'pipeline',
+ isSelected: true,
+ isSelectionMode: true,
+ onToggleSelect: (e) => console.log('Toggle', e),
+ onEdit: () => {},
+ onDelete: () => {},
+ },
+}
+
+export const NoLogo: Story = {
+ args: {
+ sponsor: mockSponsor({
+ sponsor: {
+ ...mockSponsors.negotiating.sponsor,
+ logo: '',
+ logoBright: '',
+ },
+ }),
+ currentView: 'pipeline',
+ onEdit: () => console.log('Edit clicked'),
+ onDelete: () => console.log('Delete clicked'),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Edge case: Sponsor without logo displays fallback name initials.',
+ },
+ },
+ },
+}
+
+export const HighValue: Story = {
+ args: {
+ sponsor: mockSponsor({
+ contractValue: 2500000,
+ contractCurrency: 'NOK',
+ status: 'negotiating',
+ tags: ['high-priority', 'multi-year-potential'],
+ }),
+ currentView: 'pipeline',
+ onEdit: () => console.log('Edit clicked'),
+ onDelete: () => console.log('Delete clicked'),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Edge case: High-value sponsor with large contract value and priority tags.',
+ },
+ },
+ },
+}
diff --git a/src/components/admin/sponsor-crm/form/AddonsCheckboxGroup.stories.tsx b/src/components/admin/sponsor-crm/form/AddonsCheckboxGroup.stories.tsx
new file mode 100644
index 00000000..33806aa8
--- /dev/null
+++ b/src/components/admin/sponsor-crm/form/AddonsCheckboxGroup.stories.tsx
@@ -0,0 +1,137 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { AddonsCheckboxGroup } from './AddonsCheckboxGroup'
+import { mockSponsorTier } from '@/__mocks__/sponsor-data'
+import { useState } from 'react'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Admin/Form/AddonsCheckboxGroup',
+ component: AddonsCheckboxGroup,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Multi-select checkbox group for sponsor addon purchases (booth space, workshop hosting, swag bags, etc.). Addon tiers have tierType="addon" and can be combined with standard tiers. Displays pricing and sold-out status for each option.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+const mockAddons = [
+ mockSponsorTier({
+ _id: 'addon-booth',
+ title: 'Exhibition Booth',
+ tierType: 'addon',
+ price: [{ _key: 'price-1', amount: 15000, currency: 'NOK' }],
+ }),
+ mockSponsorTier({
+ _id: 'addon-workshop',
+ title: 'Workshop Slot',
+ tierType: 'addon',
+ price: [{ _key: 'price-2', amount: 10000, currency: 'NOK' }],
+ }),
+ mockSponsorTier({
+ _id: 'addon-swag',
+ title: 'Swag Bag Insert',
+ tierType: 'addon',
+ price: [{ _key: 'price-3', amount: 5000, currency: 'NOK' }],
+ }),
+]
+
+export const Interactive: Story = {
+ render: () => {
+ const [value, setValue] = useState(['addon-booth'])
+ return (
+
+ )
+ },
+}
+
+export const NoAddons: Story = {
+ render: () => {
+ const [value, setValue] = useState([])
+ return (
+
+ )
+ },
+}
+
+export const SomeSelected: Story = {
+ render: () => {
+ const [value, setValue] = useState(['addon-booth', 'addon-swag'])
+ return (
+
+ )
+ },
+}
+
+export const AllSelected: Story = {
+ render: () => {
+ const [value, setValue] = useState([
+ 'addon-booth',
+ 'addon-workshop',
+ 'addon-swag',
+ ])
+ return (
+
+ )
+ },
+}
+
+export const ManyAddons: Story = {
+ render: () => {
+ const [value, setValue] = useState(['addon-booth'])
+ const manyAddons = [
+ ...mockAddons,
+ mockSponsorTier({
+ _id: 'addon-logo',
+ title: 'Logo on Lanyard',
+ tierType: 'addon',
+ }),
+ mockSponsorTier({
+ _id: 'addon-social',
+ title: 'Social Media Post',
+ tierType: 'addon',
+ }),
+ mockSponsorTier({
+ _id: 'addon-banner',
+ title: 'Banner Placement',
+ tierType: 'addon',
+ }),
+ ]
+ return (
+
+ )
+ },
+}
diff --git a/src/components/admin/sponsor-crm/form/ContractValueInput.stories.tsx b/src/components/admin/sponsor-crm/form/ContractValueInput.stories.tsx
new file mode 100644
index 00000000..434ed408
--- /dev/null
+++ b/src/components/admin/sponsor-crm/form/ContractValueInput.stories.tsx
@@ -0,0 +1,138 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ContractValueInput } from './ContractValueInput'
+import { useState } from 'react'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Admin/Form/ContractValueInput',
+ component: ContractValueInput,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Combined input for contract value with currency selector. Supports NOK, USD, EUR, and GBP. The value defaults to the sponsor tier price but can be customized for negotiated deals. Formats numbers with thousand separators and currency symbols for readability.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Interactive: Story = {
+ render: () => {
+ const [value, setValue] = useState('100000')
+ const [currency, setCurrency] = useState('NOK')
+ return (
+
+
+
+ Value: {value || '0'} {currency}
+
+
+ )
+ },
+}
+
+export const DefaultValue: Story = {
+ render: () => {
+ const [value, setValue] = useState('100000')
+ const [currency, setCurrency] = useState('NOK')
+ return (
+
+ )
+ },
+}
+
+export const EmptyValue: Story = {
+ render: () => {
+ const [value, setValue] = useState('')
+ const [currency, setCurrency] = useState('NOK')
+ return (
+
+ )
+ },
+}
+
+export const LargeValue: Story = {
+ render: () => {
+ const [value, setValue] = useState('2500000')
+ const [currency, setCurrency] = useState('NOK')
+ return (
+
+ )
+ },
+}
+
+export const USD: Story = {
+ render: () => {
+ const [value, setValue] = useState('75000')
+ const [currency, setCurrency] = useState('USD')
+ return (
+
+ )
+ },
+}
+
+export const EUR: Story = {
+ render: () => {
+ const [value, setValue] = useState('50000')
+ const [currency, setCurrency] = useState('EUR')
+ return (
+
+ )
+ },
+}
+
+export const GBP: Story = {
+ render: () => {
+ const [value, setValue] = useState('45000')
+ const [currency, setCurrency] = useState('GBP')
+ return (
+
+ )
+ },
+}
diff --git a/src/components/admin/sponsor-crm/form/OrganizerCombobox.stories.tsx b/src/components/admin/sponsor-crm/form/OrganizerCombobox.stories.tsx
new file mode 100644
index 00000000..ebedb7a4
--- /dev/null
+++ b/src/components/admin/sponsor-crm/form/OrganizerCombobox.stories.tsx
@@ -0,0 +1,264 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import {
+ ChevronUpDownIcon,
+ CheckIcon,
+ XMarkIcon,
+} from '@heroicons/react/20/solid'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Form/OrganizerCombobox',
+ parameters: {
+ layout: 'padded',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+interface Organizer {
+ _id: string
+ name: string
+ email?: string
+ avatar?: string
+}
+
+const mockOrganizers: Organizer[] = [
+ {
+ _id: 'org-1',
+ name: 'Hans Kristian',
+ email: 'hans@example.com',
+ avatar: undefined,
+ },
+ {
+ _id: 'org-2',
+ name: 'Maria Jensen',
+ email: 'maria@example.com',
+ avatar: undefined,
+ },
+ {
+ _id: 'org-3',
+ name: 'Erik Olsen',
+ email: 'erik@example.com',
+ avatar: undefined,
+ },
+ {
+ _id: 'org-4',
+ name: 'Sofia Berg',
+ email: 'sofia@example.com',
+ avatar: undefined,
+ },
+]
+
+function OrganizerAvatar({ name }: { name: string }) {
+ const initials = name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase()
+ .slice(0, 2)
+
+ return (
+
+ {initials}
+
+ )
+}
+
+function OrganizerComboboxDemo() {
+ const [selectedId, setSelectedId] = useState('')
+ const [query, setQuery] = useState('')
+ const [isOpen, setIsOpen] = useState(false)
+
+ const selected = mockOrganizers.find((o) => o._id === selectedId)
+
+ const filtered =
+ query === ''
+ ? mockOrganizers
+ : mockOrganizers.filter(
+ (o) =>
+ o.name.toLowerCase().includes(query.toLowerCase()) ||
+ o.email?.toLowerCase().includes(query.toLowerCase()),
+ )
+
+ return (
+
+
+ Assigned To
+
+
+
setIsOpen(!isOpen)}
+ >
+ {selected ? (
+
+
+
+ {selected.name}
+
+
+ ) : (
+
setQuery(e.target.value)}
+ />
+ )}
+
+
+
+
+
+ {isOpen && (
+
+ {/* Unassigned option */}
+
{
+ setSelectedId('')
+ setIsOpen(false)
+ }}
+ >
+ Unassigned
+ {selectedId === '' && (
+
+
+
+ )}
+
+
+ {filtered.map((organizer) => (
+
{
+ setSelectedId(organizer._id)
+ setIsOpen(false)
+ setQuery('')
+ }}
+ >
+
+
+
+
{organizer.name}
+ {organizer.email && (
+
+ {organizer.email}
+
+ )}
+
+
+ {selectedId === organizer._id && (
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+ {selected && (
+
setSelectedId('')}
+ >
+
+ Clear selection
+
+ )}
+
+ )
+}
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const WithSelection: Story = {
+ render: () => {
+ const selected = mockOrganizers[0]
+ return (
+
+
+ Assigned To
+
+
+
+
+
+
+ {selected.name}
+
+
+
+
+
+
+
+
+
+ Clear selection
+
+
+ )
+ },
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ OrganizerCombobox
+
+
+ Searchable dropdown for assigning sponsors to team members. Shows
+ avatars, names, and emails. Supports filtering and clearing selection.
+
+
+
+
+
Props
+
+
+
+ value
+ {' '}
+ - Selected organizer ID
+
+
+
+ onChange
+ {' '}
+ - Callback when selection changes
+
+
+
+ organizers
+ {' '}
+ - Array of Organizer objects
+
+
+
+
+
+
+ Organizer Object
+
+
+ {`interface Organizer {
+ _id: string
+ name: string
+ email?: string
+ avatar?: string
+}`}
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor-crm/form/SponsorCombobox.stories.tsx b/src/components/admin/sponsor-crm/form/SponsorCombobox.stories.tsx
new file mode 100644
index 00000000..b73ca2ca
--- /dev/null
+++ b/src/components/admin/sponsor-crm/form/SponsorCombobox.stories.tsx
@@ -0,0 +1,257 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import {
+ ChevronUpDownIcon,
+ CheckIcon,
+ PlusIcon,
+} from '@heroicons/react/20/solid'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Form/SponsorCombobox',
+ parameters: {
+ layout: 'padded',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+interface Sponsor {
+ _id: string
+ name: string
+}
+
+const mockSponsors: Sponsor[] = [
+ { _id: 'sp-1', name: 'Acme Corporation' },
+ { _id: 'sp-2', name: 'TechStart Inc' },
+ { _id: 'sp-3', name: 'CloudCo' },
+ { _id: 'sp-4', name: 'DataSystems' },
+ { _id: 'sp-5', name: 'Nordic Software' },
+ { _id: 'sp-6', name: 'DevOps Labs' },
+]
+
+function SponsorComboboxDemo() {
+ const [selectedId, setSelectedId] = useState('')
+ const [query, setQuery] = useState('')
+ const [isOpen, setIsOpen] = useState(false)
+ const [isCreatingNew, setIsCreatingNew] = useState(false)
+
+ const selected = mockSponsors.find((s) => s._id === selectedId)
+
+ const filtered =
+ query === ''
+ ? mockSponsors
+ : mockSponsors.filter((s) =>
+ s.name.toLowerCase().includes(query.toLowerCase()),
+ )
+
+ const showCreateOption = query.length > 0 && filtered.length === 0
+
+ return (
+
+
+ Select Sponsor
+
+
+
setIsOpen(!isOpen)}
+ >
+ {selected && !isOpen ? (
+
+ {selected.name}
+
+ ) : (
+ {
+ setQuery(e.target.value)
+ setIsOpen(true)
+ }}
+ onFocus={() => setIsOpen(true)}
+ />
+ )}
+
+
+
+
+
+ {isOpen && (
+
+ {/* Create new option */}
+ {showCreateOption && (
+
{
+ setIsCreatingNew(true)
+ setIsOpen(false)
+ }}
+ >
+
+
+ )}
+
+ {filtered.map((sponsor) => (
+
{
+ setSelectedId(sponsor._id)
+ setIsOpen(false)
+ setQuery('')
+ }}
+ >
+ {sponsor.name}
+ {selectedId === sponsor._id && (
+
+
+
+ )}
+
+ ))}
+
+ {filtered.length === 0 && !showCreateOption && (
+
+ No sponsors found
+
+ )}
+
+ )}
+
+
+ {/* New sponsor form */}
+ {isCreatingNew && (
+
+
+ Create New Sponsor
+
+
+
+ )}
+
+ )
+}
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const WithSelection: Story = {
+ render: () => {
+ const selected = mockSponsors[2]
+ return (
+
+
+ Select Sponsor
+
+
+
+
+ {selected.name}
+
+
+
+
+
+
+
+ )
+ },
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorCombobox
+
+
+ Searchable sponsor selector with inline creation capability. When no
+ matching sponsor is found, offers to create a new one with the search
+ query as the initial name.
+
+
+
+
+
Props
+
+
+
+ value
+ {' '}
+ - Selected sponsor ID
+
+
+
+ onChange
+ {' '}
+ - Callback when selection changes
+
+
+
+ availableSponsors
+ {' '}
+ - Array of Sponsor objects
+
+
+
+ disabled?
+ {' '}
+ - Disable the combobox
+
+
+
+ onSponsorCreated?
+ {' '}
+ - Callback after new sponsor creation
+
+
+
+
+
+
+ Features
+
+
+ • Type-ahead search filtering
+ • Create new sponsor inline when not found
+ • Uses tRPC mutation for sponsor creation
+ • Auto-invalidates sponsor list cache on creation
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor-crm/form/SponsorGlobalInfoFields.stories.tsx b/src/components/admin/sponsor-crm/form/SponsorGlobalInfoFields.stories.tsx
new file mode 100644
index 00000000..554de15e
--- /dev/null
+++ b/src/components/admin/sponsor-crm/form/SponsorGlobalInfoFields.stories.tsx
@@ -0,0 +1,126 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { expect, fn, userEvent, within } from 'storybook/test'
+import { useState } from 'react'
+
+import { SponsorGlobalInfoFields } from './SponsorGlobalInfoFields'
+
+const meta = {
+ title: 'Systems/Sponsors/Form/SponsorGlobalInfoFields',
+ component: SponsorGlobalInfoFields,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Form fields for editing global sponsor information (name and website). Used within sponsor forms to collect basic company details.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ name: '',
+ website: '',
+ onNameChange: fn(),
+ onWebsiteChange: fn(),
+ },
+}
+
+export const WithValues: Story = {
+ args: {
+ name: 'Acme Corporation',
+ website: 'https://acme.com',
+ onNameChange: fn(),
+ onWebsiteChange: fn(),
+ },
+}
+
+export const Disabled: Story = {
+ args: {
+ name: 'Read Only Corp',
+ website: 'https://readonly.com',
+ onNameChange: fn(),
+ onWebsiteChange: fn(),
+ disabled: true,
+ },
+}
+
+/**
+ * Interactive story that demonstrates controlled form behavior.
+ */
+const InteractiveTemplate = () => {
+ const [name, setName] = useState('')
+ const [website, setWebsite] = useState('')
+
+ return (
+
+
+
+
+
+
+ Current values:
+
+
Name: {name || '(empty)'}
+
Website: {website || '(empty)'}
+
+
+ )
+}
+
+export const Interactive: Story = {
+ render: () => ,
+ args: {
+ name: '',
+ website: '',
+ onNameChange: fn(),
+ onWebsiteChange: fn(),
+ },
+}
+
+export const NameChangeTest: Story = {
+ args: {
+ name: '',
+ website: '',
+ onNameChange: fn(),
+ onWebsiteChange: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const nameInput = canvas.getByLabelText('Company Name *')
+ await userEvent.type(nameInput, 'Test Company')
+ await expect(args.onNameChange).toHaveBeenCalled()
+ },
+}
+
+export const WebsiteChangeTest: Story = {
+ args: {
+ name: '',
+ website: '',
+ onNameChange: fn(),
+ onWebsiteChange: fn(),
+ },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ const websiteInput = canvas.getByLabelText('Website *')
+ await userEvent.type(websiteInput, 'https://test.com')
+ await expect(args.onWebsiteChange).toHaveBeenCalled()
+ },
+}
diff --git a/src/components/admin/sponsor-crm/form/StatusListbox.stories.tsx b/src/components/admin/sponsor-crm/form/StatusListbox.stories.tsx
new file mode 100644
index 00000000..71b4980b
--- /dev/null
+++ b/src/components/admin/sponsor-crm/form/StatusListbox.stories.tsx
@@ -0,0 +1,207 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { StatusListbox } from './StatusListbox'
+import { STATUSES, INVOICE_STATUSES, CONTRACT_STATUSES } from './constants'
+import { useState } from 'react'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Admin/Form/StatusListbox',
+ component: StatusListbox,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Dropdown selector for sponsor, contract, and invoice statuses. Features icon-based visual indicators with brand colors: Cloud Blue for primary actions, Fresh Green for success states, and Sunbeam Yellow for warnings. Supports helper text and disabled states for workflow validation.',
+ },
+ },
+ },
+ argTypes: {
+ disabled: {
+ control: 'boolean',
+ description: 'Disable the listbox',
+ },
+ helperText: {
+ control: 'text',
+ description: 'Helper text displayed below the listbox',
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Interactive: Story = {
+ render: () => {
+ const [value, setValue] = useState('prospect')
+ return (
+
+ )
+ },
+}
+
+export const AllStates: Story = {
+ render: () => {
+ const [sponsorValue, setSponsorValue] = useState('negotiating')
+ const [contractValue, setContractValue] = useState('draft')
+ const [invoiceValue, setInvoiceValue] = useState('paid')
+ const [helpValue, setHelpValue] = useState('negotiating')
+ const [disabledValue, setDisabledValue] = useState('closed-won')
+
+ return (
+
+ {/* Status Types */}
+
+
+ Status Types
+
+
+
+
+ Sponsor Status
+
+
+
+
+
+ Contract Status
+
+
+
+
+
+ Invoice Status
+
+
+
+
+
+
+ {/* All Sponsor Statuses */}
+
+
+ All Sponsor Statuses
+
+
+ {STATUSES.map((status) => (
+
+
+ {status.label}
+
+ {}}
+ options={STATUSES}
+ />
+
+ ))}
+
+
+
+ {/* All Contract Statuses */}
+
+
+ All Contract Statuses
+
+
+ {CONTRACT_STATUSES.map((status) => (
+
+
+ {status.label}
+
+ {}}
+ options={CONTRACT_STATUSES}
+ />
+
+ ))}
+
+
+
+ {/* All Invoice Statuses */}
+
+
+ All Invoice Statuses
+
+
+ {INVOICE_STATUSES.map((status) => (
+
+
+ {status.label}
+
+ {}}
+ options={INVOICE_STATUSES}
+ />
+
+ ))}
+
+
+
+ {/* Special States */}
+
+
+ Special States
+
+
+
+
+ With Helper Text
+
+
+
+
+
Disabled
+
+
+
+
+
+ )
+ },
+}
diff --git a/src/components/admin/sponsor-crm/form/TagCombobox.stories.tsx b/src/components/admin/sponsor-crm/form/TagCombobox.stories.tsx
new file mode 100644
index 00000000..afc7a148
--- /dev/null
+++ b/src/components/admin/sponsor-crm/form/TagCombobox.stories.tsx
@@ -0,0 +1,77 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { TagCombobox } from './TagCombobox'
+import { useState } from 'react'
+import type { SponsorTag } from '@/lib/sponsor-crm/types'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Admin/Form/TagCombobox',
+ component: TagCombobox,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Multi-select combobox for tagging sponsors with classification labels (Local, Return Sponsor, Tech Partner, Media Partner, Previous Speaker, Referral, Strategic, VIP). Tags help organize and filter sponsors in the CRM pipeline. Uses Fresh Green badges for selected tags.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Interactive: Story = {
+ render: () => {
+ const [value, setValue] = useState([
+ 'warm-lead',
+ 'returning-sponsor',
+ ])
+ return
+ },
+}
+
+export const Empty: Story = {
+ render: () => {
+ const [value, setValue] = useState([])
+ return
+ },
+}
+
+export const SingleTag: Story = {
+ render: () => {
+ const [value, setValue] = useState(['warm-lead'])
+ return
+ },
+}
+
+export const MultipleTags: Story = {
+ render: () => {
+ const [value, setValue] = useState([
+ 'warm-lead',
+ 'returning-sponsor',
+ 'high-priority',
+ ])
+ return
+ },
+}
+
+export const ManyTags: Story = {
+ render: () => {
+ const [value, setValue] = useState([
+ 'warm-lead',
+ 'returning-sponsor',
+ 'high-priority',
+ 'needs-follow-up',
+ 'multi-year-potential',
+ ])
+ return
+ },
+}
diff --git a/src/components/admin/sponsor-crm/form/TierRadioGroup.stories.tsx b/src/components/admin/sponsor-crm/form/TierRadioGroup.stories.tsx
new file mode 100644
index 00000000..bd0b066c
--- /dev/null
+++ b/src/components/admin/sponsor-crm/form/TierRadioGroup.stories.tsx
@@ -0,0 +1,101 @@
+/* eslint-disable react-hooks/rules-of-hooks */
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { TierRadioGroup } from './TierRadioGroup'
+import { mockSponsorTier } from '@/__mocks__/sponsor-data'
+import { useState } from 'react'
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Admin/Form/TierRadioGroup',
+ component: TierRadioGroup,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Radio button group for selecting sponsor tiers (e.g., Ingress, Service, Pod). Each tier displays pricing, tagline, and sold-out status. Tiers are conference-specific and define sponsor benefits and perks. The mostPopular flag highlights recommended tiers with Nordic Purple accent.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+const mockTiers = [
+ mockSponsorTier({
+ _id: 'tier-ingress',
+ title: 'Ingress',
+ price: [{ _key: 'price-1', amount: 100000, currency: 'NOK' }],
+ }),
+ mockSponsorTier({
+ _id: 'tier-service',
+ title: 'Service',
+ price: [{ _key: 'price-2', amount: 50000, currency: 'NOK' }],
+ }),
+ mockSponsorTier({
+ _id: 'tier-pod',
+ title: 'Pod',
+ price: [{ _key: 'price-3', amount: 25000, currency: 'NOK' }],
+ }),
+]
+
+export const Interactive: Story = {
+ render: () => {
+ const [value, setValue] = useState('tier-ingress')
+ return (
+
+ )
+ },
+}
+
+export const MultipleTiers: Story = {
+ render: () => {
+ const [value, setValue] = useState('tier-ingress')
+ return (
+
+ )
+ },
+}
+
+export const NoTier: Story = {
+ render: () => {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ },
+}
+
+export const SingleTier: Story = {
+ render: () => {
+ const [value, setValue] = useState('')
+ return (
+
+ )
+ },
+}
+
+export const ManyTiers: Story = {
+ render: () => {
+ const [value, setValue] = useState('tier-service')
+ const manyTiers = [
+ ...mockTiers,
+ mockSponsorTier({ _id: 'tier-deployment', title: 'Deployment' }),
+ mockSponsorTier({ _id: 'tier-namespace', title: 'Namespace' }),
+ ]
+ return (
+
+ )
+ },
+}
diff --git a/src/components/admin/sponsor-crm/utils.ts b/src/components/admin/sponsor-crm/utils.ts
index 2f592961..1f0899db 100644
--- a/src/components/admin/sponsor-crm/utils.ts
+++ b/src/components/admin/sponsor-crm/utils.ts
@@ -18,6 +18,9 @@ import {
CalendarIcon,
FireIcon,
ArrowPathRoundedSquareIcon,
+ PaperAirplaneIcon,
+ ShieldCheckIcon,
+ BellAlertIcon,
} from '@heroicons/react/24/outline'
// Invoice Status Utilities
@@ -70,6 +73,12 @@ export function getActivityIcon(type: ActivityType) {
return PhoneIcon
case 'meeting':
return CalendarIcon
+ case 'signature_status_change':
+ return ShieldCheckIcon
+ case 'onboarding_complete':
+ return CheckCircleIcon
+ case 'contract_reminder_sent':
+ return BellAlertIcon
}
}
@@ -91,6 +100,12 @@ export function getActivityColor(type: ActivityType): string {
return 'text-orange-600 bg-orange-100 dark:text-orange-400 dark:bg-orange-900/20'
case 'meeting':
return 'text-pink-600 bg-pink-100 dark:text-pink-400 dark:bg-pink-900/20'
+ case 'signature_status_change':
+ return 'text-cyan-600 bg-cyan-100 dark:text-cyan-400 dark:bg-cyan-900/20'
+ case 'onboarding_complete':
+ return 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-900/20'
+ case 'contract_reminder_sent':
+ return 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-900/20'
}
}
@@ -104,6 +119,9 @@ export type ActionItemType =
| 'stale'
| 'high-priority'
| 'follow-up'
+ | 'signature-rejected'
+ | 'signature-expired'
+ | 'onboarding-pending'
export function getActionItemIcon(type: ActionItemType) {
switch (type) {
@@ -122,6 +140,12 @@ export function getActionItemIcon(type: ActionItemType) {
return FireIcon
case 'follow-up':
return ArrowPathRoundedSquareIcon
+ case 'signature-rejected':
+ return ExclamationTriangleIcon
+ case 'signature-expired':
+ return ClockIcon
+ case 'onboarding-pending':
+ return PaperAirplaneIcon
}
}
@@ -142,6 +166,12 @@ export function getActionItemColor(type: ActionItemType): string {
return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/20'
case 'follow-up':
return 'text-indigo-600 bg-indigo-100 dark:text-indigo-400 dark:bg-indigo-900/20'
+ case 'signature-rejected':
+ return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-900/20'
+ case 'signature-expired':
+ return 'text-orange-600 bg-orange-100 dark:text-orange-400 dark:bg-orange-900/20'
+ case 'onboarding-pending':
+ return 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20'
}
}
diff --git a/src/components/admin/sponsor/ContractTemplateEditorPage.tsx b/src/components/admin/sponsor/ContractTemplateEditorPage.tsx
new file mode 100644
index 00000000..76a83b88
--- /dev/null
+++ b/src/components/admin/sponsor/ContractTemplateEditorPage.tsx
@@ -0,0 +1,572 @@
+'use client'
+
+import { useState, useEffect, useCallback } from 'react'
+import { useRouter } from 'next/navigation'
+import { api } from '@/lib/trpc/client'
+import { AdminPageHeader } from '@/components/admin'
+import { useNotification } from '@/components/admin'
+import type { Conference } from '@/lib/conference/types'
+import type { PortableTextBlock } from '@/lib/sponsor/types'
+import { CONTRACT_VARIABLE_DESCRIPTIONS } from '@/lib/sponsor-crm/contract-variables'
+import {
+ DocumentTextIcon,
+ PlusIcon,
+ TrashIcon,
+ ChevronUpIcon,
+ ChevronDownIcon,
+ InformationCircleIcon,
+} from '@heroicons/react/24/outline'
+
+interface ContractSection {
+ heading: string
+ body?: PortableTextBlock[]
+}
+
+interface ContractTemplateEditorPageProps {
+ conference: Conference
+ templateId?: string
+}
+
+export function ContractTemplateEditorPage({
+ conference,
+ templateId,
+}: ContractTemplateEditorPageProps) {
+ const router = useRouter()
+ const { showNotification } = useNotification()
+
+ const [title, setTitle] = useState('')
+ const [language, setLanguage] = useState<'nb' | 'en'>('nb')
+ const [currency, setCurrency] = useState('NOK')
+ const [tier, setTier] = useState('')
+ const [headerText, setHeaderText] = useState('Cloud Native Days Norway')
+ const [footerText, setFooterText] = useState('')
+ const [isDefault, setIsDefault] = useState(false)
+ const [isActive, setIsActive] = useState(true)
+ const [sections, setSections] = useState([{ heading: '' }])
+ const [terms, setTerms] = useState([])
+ const [showVariables, setShowVariables] = useState(false)
+
+ const isEditing = !!templateId
+
+ const { data: existingTemplate, isLoading: isLoadingTemplate } =
+ api.sponsor.contractTemplates.get.useQuery(
+ { id: templateId! },
+ { enabled: isEditing },
+ )
+
+ const { data: tiers } = api.sponsor.tiers.listByConference.useQuery({
+ conferenceId: conference._id,
+ })
+
+ /* eslint-disable react-hooks/set-state-in-effect */
+ useEffect(() => {
+ if (existingTemplate) {
+ setTitle(existingTemplate.title)
+ setLanguage(existingTemplate.language)
+ setCurrency(existingTemplate.currency || 'NOK')
+ setTier(existingTemplate.tier?._id || '')
+ setHeaderText(existingTemplate.headerText || '')
+ setFooterText(existingTemplate.footerText || '')
+ setIsDefault(existingTemplate.isDefault)
+ setIsActive(existingTemplate.isActive)
+ setSections(
+ existingTemplate.sections.map((s) => ({
+ heading: s.heading,
+ body: s.body,
+ })),
+ )
+ if (existingTemplate.terms) {
+ setTerms(existingTemplate.terms)
+ }
+ }
+ }, [existingTemplate])
+ /* eslint-enable react-hooks/set-state-in-effect */
+
+ const createMutation = api.sponsor.contractTemplates.create.useMutation({
+ onSuccess: () => {
+ showNotification({
+ type: 'success',
+ title: 'Template created',
+ message: 'Contract template has been created',
+ })
+ router.push('/admin/sponsors/contracts')
+ },
+ onError: (error) => {
+ showNotification({
+ type: 'error',
+ title: 'Create failed',
+ message: error.message,
+ })
+ },
+ })
+
+ const updateMutation = api.sponsor.contractTemplates.update.useMutation({
+ onSuccess: () => {
+ showNotification({
+ type: 'success',
+ title: 'Template updated',
+ message: 'Contract template has been updated',
+ })
+ router.push('/admin/sponsors/contracts')
+ },
+ onError: (error) => {
+ showNotification({
+ type: 'error',
+ title: 'Update failed',
+ message: error.message,
+ })
+ },
+ })
+
+ const handleSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault()
+
+ const validSections = sections.filter((s) => s.heading.trim())
+ if (validSections.length === 0) {
+ showNotification({
+ type: 'error',
+ title: 'Validation error',
+ message: 'At least one section with a heading is required',
+ })
+ return
+ }
+
+ const data = {
+ title,
+ conference: conference._id,
+ tier: tier || undefined,
+ language,
+ currency,
+ sections: validSections,
+ headerText: headerText || undefined,
+ footerText: footerText || undefined,
+ terms: terms.length > 0 ? terms : undefined,
+ isDefault,
+ isActive,
+ }
+
+ if (isEditing && templateId) {
+ updateMutation.mutate({ id: templateId, ...data })
+ } else {
+ createMutation.mutate(data)
+ }
+ },
+ [
+ title,
+ conference._id,
+ tier,
+ language,
+ currency,
+ sections,
+ headerText,
+ footerText,
+ terms,
+ isDefault,
+ isActive,
+ isEditing,
+ templateId,
+ createMutation,
+ updateMutation,
+ showNotification,
+ ],
+ )
+
+ const addSection = () => {
+ setSections([...sections, { heading: '' }])
+ }
+
+ const removeSection = (index: number) => {
+ if (sections.length <= 1) return
+ setSections(sections.filter((_, i) => i !== index))
+ }
+
+ const moveSection = (index: number, direction: 'up' | 'down') => {
+ const newIndex = direction === 'up' ? index - 1 : index + 1
+ if (newIndex < 0 || newIndex >= sections.length) return
+ const newSections = [...sections]
+ ;[newSections[index], newSections[newIndex]] = [
+ newSections[newIndex],
+ newSections[index],
+ ]
+ setSections(newSections)
+ }
+
+ const updateSection = (
+ index: number,
+ field: keyof ContractSection,
+ value: string | PortableTextBlock[],
+ ) => {
+ const newSections = [...sections]
+ newSections[index] = { ...newSections[index], [field]: value }
+ setSections(newSections)
+ }
+
+ const isPending = createMutation.isPending || updateMutation.isPending
+
+ if (isEditing && isLoadingTemplate) {
+ return (
+
+ Loading template...
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/admin/sponsor/ContractTemplateListPage.tsx b/src/components/admin/sponsor/ContractTemplateListPage.tsx
new file mode 100644
index 00000000..5b632012
--- /dev/null
+++ b/src/components/admin/sponsor/ContractTemplateListPage.tsx
@@ -0,0 +1,205 @@
+'use client'
+
+import { useState } from 'react'
+import Link from 'next/link'
+import { api } from '@/lib/trpc/client'
+import { AdminPageHeader } from '@/components/admin'
+import { ConfirmationModal } from '@/components/admin/ConfirmationModal'
+import { useNotification } from '@/components/admin'
+import type { Conference } from '@/lib/conference/types'
+import {
+ DocumentTextIcon,
+ PlusIcon,
+ PencilSquareIcon,
+ TrashIcon,
+ CheckCircleIcon,
+ XCircleIcon,
+} from '@heroicons/react/24/outline'
+
+interface ContractTemplateListPageProps {
+ conference: Conference
+}
+
+export function ContractTemplateListPage({
+ conference,
+}: ContractTemplateListPageProps) {
+ const { showNotification } = useNotification()
+ const [deleteTarget, setDeleteTarget] = useState<{
+ _id: string
+ title: string
+ } | null>(null)
+
+ const {
+ data: templates,
+ isLoading,
+ refetch,
+ } = api.sponsor.contractTemplates.list.useQuery({
+ conferenceId: conference._id,
+ })
+
+ const deleteMutation = api.sponsor.contractTemplates.delete.useMutation({
+ onSuccess: () => {
+ showNotification({
+ type: 'success',
+ title: 'Template deleted',
+ message: 'Contract template has been deleted',
+ })
+ setDeleteTarget(null)
+ refetch()
+ },
+ onError: (error) => {
+ showNotification({
+ type: 'error',
+ title: 'Delete failed',
+ message: error.message,
+ })
+ },
+ })
+
+ return (
+
+
}
+ actions={
+
+
+ New Template
+
+ }
+ />
+
+ {isLoading ? (
+
+ Loading templates...
+
+ ) : !templates || templates.length === 0 ? (
+
+
+
+ No contract templates
+
+
+ Create your first contract template to start generating sponsor
+ agreements.
+
+
+
+ ) : (
+
+
+
+
+
+ Template
+
+
+ Language
+
+
+ Tier
+
+
+ Status
+
+
+ Actions
+
+
+
+
+ {templates.map((template) => (
+
+
+
+
+ {template.title}
+
+ {template.isDefault && (
+
+ Default
+
+ )}
+
+
+
+ {template.language === 'nb' ? '🇳🇴 Norwegian' : '🇬🇧 English'}
+
+
+ {template.tier?.title || '—'}
+
+
+ {template.isActive ? (
+
+
+ Active
+
+ ) : (
+
+
+ Inactive
+
+ )}
+
+
+
+
+
+
+
+ setDeleteTarget({
+ _id: template._id,
+ title: template.title,
+ })
+ }
+ className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
+ title="Delete"
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
setDeleteTarget(null)}
+ onConfirm={() => {
+ if (deleteTarget) {
+ deleteMutation.mutate({ id: deleteTarget._id })
+ }
+ }}
+ title="Delete Contract Template"
+ message={`Are you sure you want to delete "${deleteTarget?.title}"? This action cannot be undone.`}
+ confirmButtonText="Delete"
+ variant="danger"
+ isLoading={deleteMutation.isPending}
+ />
+
+ )
+}
diff --git a/src/components/admin/sponsor/SponsorActionItems.stories.tsx b/src/components/admin/sponsor/SponsorActionItems.stories.tsx
new file mode 100644
index 00000000..8116bf8a
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorActionItems.stories.tsx
@@ -0,0 +1,258 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ ExclamationTriangleIcon,
+ EnvelopeIcon,
+ DocumentTextIcon,
+ ClockIcon,
+ CheckCircleIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Dashboard/Action Items',
+ parameters: {
+ layout: 'padded',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+interface ActionItem {
+ id: string
+ type: 'follow_up' | 'contract' | 'onboarding' | 'invoice'
+ title: string
+ description: string
+ priority: 'high' | 'medium' | 'low'
+ dueDate?: string
+}
+
+const mockActionItems: ActionItem[] = [
+ {
+ id: '1',
+ type: 'follow_up',
+ title: 'Follow up with Acme Corp',
+ description: 'Last contact was 7 days ago, no response yet',
+ priority: 'high',
+ dueDate: 'Today',
+ },
+ {
+ id: '2',
+ type: 'contract',
+ title: 'Send contract to TechStart',
+ description: 'Verbal agreement reached, awaiting contract',
+ priority: 'high',
+ dueDate: 'Today',
+ },
+ {
+ id: '3',
+ type: 'onboarding',
+ title: 'Complete onboarding for CloudCo',
+ description: 'Missing logo and billing info',
+ priority: 'medium',
+ dueDate: 'This week',
+ },
+ {
+ id: '4',
+ type: 'invoice',
+ title: 'Invoice overdue: DataSys',
+ description: 'Invoice sent 30 days ago, payment pending',
+ priority: 'high',
+ dueDate: 'Overdue',
+ },
+]
+
+function getActionIcon(type: ActionItem['type']) {
+ switch (type) {
+ case 'follow_up':
+ return EnvelopeIcon
+ case 'contract':
+ return DocumentTextIcon
+ case 'onboarding':
+ return ClockIcon
+ case 'invoice':
+ return ExclamationTriangleIcon
+ default:
+ return ClockIcon
+ }
+}
+
+function getActionColor(type: ActionItem['type']) {
+ switch (type) {
+ case 'follow_up':
+ return 'bg-blue-100 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400'
+ case 'contract':
+ return 'bg-purple-100 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400'
+ case 'onboarding':
+ return 'bg-amber-100 text-amber-600 dark:bg-amber-900/20 dark:text-amber-400'
+ case 'invoice':
+ return 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400'
+ default:
+ return 'bg-gray-100 text-gray-600 dark:bg-gray-900/20 dark:text-gray-400'
+ }
+}
+
+function getPriorityBadge(priority: ActionItem['priority']) {
+ switch (priority) {
+ case 'high':
+ return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
+ case 'medium':
+ return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
+ case 'low':
+ return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
+ default:
+ return 'bg-gray-100 text-gray-600'
+ }
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+
+ Action Items
+
+
+ {mockActionItems.length} pending
+
+
+
+
+ {mockActionItems.map((item) => {
+ const Icon = getActionIcon(item.type)
+ return (
+
+
+
+
+
+
+
+
+ {item.title}
+
+
+ {item.priority}
+
+
+
+ {item.description}
+
+ {item.dueDate && (
+
+ Due: {item.dueDate}
+
+ )}
+
+
+
+ )
+ })}
+
+
+
+ ),
+}
+
+export const Empty: Story = {
+ render: () => (
+
+
+
+ Action Items
+
+
+
+
+ All caught up!
+
+
+ No urgent action items at the moment.
+
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorActionItems
+
+
+ Displays a prioritized list of pending tasks for sponsor management.
+ Uses tRPC to fetch action items generated from sponsor pipeline data.
+
+
+
+
+
Props
+
+
+
+ conferenceId
+ {' '}
+ - Conference to fetch actions for
+
+
+
+ organizerId?
+ {' '}
+ - Filter to show only assigned items
+
+
+
+
+
+
+ Action Types
+
+
+
+
+
+
+
+ follow_up - Contact follow-ups
+
+
+
+
+
+
+
+ contract - Contract actions
+
+
+
+
+
+
+
+ onboarding - Pending onboarding
+
+
+
+
+
+
+
+ invoice - Payment issues
+
+
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorActivityTimeline.stories.tsx b/src/components/admin/sponsor/SponsorActivityTimeline.stories.tsx
new file mode 100644
index 00000000..80b6f5d4
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorActivityTimeline.stories.tsx
@@ -0,0 +1,263 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Dashboard/Activity Timeline',
+ parameters: {
+ layout: 'centered',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function ActivityIcon({ type }: { type: string }) {
+ const icons: Record = {
+ status_change: (
+
+
+
+ ),
+ created: (
+
+
+
+ ),
+ contract_change: (
+
+
+
+ ),
+ }
+ return (
+
+ {icons[type] || icons.status_change}
+
+ )
+}
+
+export const ActivityTimeline: Story = {
+ render: () => (
+
+
+
+ Sponsor Activity Timeline
+
+
+ Shows a chronological feed of sponsor-related activities grouped by
+ day and sponsor.
+
+
+
+ {/* Live Example */}
+
+
+ Live Example
+
+
+
+ Recent Activity
+
+
+
+ {/* Today */}
+
+
+
+
+
+ Tieto Tech Consulting
+
+
+ 1
+
+
+
+
+
+ Status changed from Prospect to Negotiating
+
+
+
+ HK
+
+
+ about 2 hours ago
+
+
+
+
+
+
+ {/* Yesterday */}
+
+
+
+
+
+
+ Aiven
+
+
+ 1
+
+
+
+
+
+ Sponsor opportunity created in pipeline
+
+
+ about 15 hours ago
+
+
+
+
+
+
+
+ KS Digital
+
+
+ 2
+
+
+
+
+
+
+ Contract status changed from None to Verbal Agreement
+
+
+ about 20 hours ago
+
+
+
+
+
+ Status changed from Negotiating to Closed Won
+
+
+ about 20 hours ago
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Activity Types */}
+
+
+ Activity Types
+
+
+
+
+
+
+ Created
+
+
Sponsor added to pipeline
+
+
+
+
+
+
+ Status Change
+
+
Pipeline stage transition
+
+
+
+
+
+
+ Contract Change
+
+
Contract status updated
+
+
+
+
+
+ {/* Usage */}
+
+
+ Usage
+
+
+ {`import { SponsorActivityTimeline } from '@/components/admin/sponsor'
+
+// Dashboard view (with header and footer)
+
+
+// Single sponsor view (inline)
+ `}
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorAddModal.stories.tsx b/src/components/admin/sponsor/SponsorAddModal.stories.tsx
new file mode 100644
index 00000000..68eed5b6
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorAddModal.stories.tsx
@@ -0,0 +1,291 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { XMarkIcon, PhotoIcon, PlusIcon } from '@heroicons/react/24/outline'
+import { useState } from 'react'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Tiers/SponsorAddModal',
+ parameters: {
+ layout: 'centered',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const mockTiers = [
+ { _id: 'tier-1', title: 'Platinum', value: 100000 },
+ { _id: 'tier-2', title: 'Gold', value: 50000 },
+ { _id: 'tier-3', title: 'Silver', value: 25000 },
+ { _id: 'tier-4', title: 'Bronze', value: 10000 },
+]
+
+const mockExistingSponsors = [
+ { _id: 'sponsor-1', name: 'TechGiant Corp' },
+ { _id: 'sponsor-2', name: 'CloudPro Inc' },
+ { _id: 'sponsor-3', name: 'DataSys' },
+]
+
+function formatCurrency(value: number) {
+ return new Intl.NumberFormat('nb-NO', {
+ style: 'currency',
+ currency: 'NOK',
+ maximumFractionDigits: 0,
+ }).format(value)
+}
+
+function AddSponsorModal({ preselectedTier }: { preselectedTier?: string }) {
+ const [mode, setMode] = useState<'select' | 'create'>('select')
+ const [selectedTier, setSelectedTier] = useState(preselectedTier || '')
+ const [selectedSponsor, setSelectedSponsor] = useState('')
+ const [newSponsorName, setNewSponsorName] = useState('')
+ const [newSponsorWebsite, setNewSponsorWebsite] = useState('')
+
+ return (
+
+ {/* Header */}
+
+
+ Add Sponsor to Conference
+
+
+
+
+
+
+
+ {/* Mode toggle */}
+
+ setMode('select')}
+ className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
+ mode === 'select'
+ ? 'bg-white text-gray-900 shadow dark:bg-gray-600 dark:text-white'
+ : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
+ }`}
+ >
+ Select Existing
+
+ setMode('create')}
+ className={`flex-1 rounded-md px-4 py-2 text-sm font-medium transition-colors ${
+ mode === 'create'
+ ? 'bg-white text-gray-900 shadow dark:bg-gray-600 dark:text-white'
+ : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white'
+ }`}
+ >
+ Create New
+
+
+
+ {/* Tier selection */}
+
+
+ Sponsor Tier
+
+ setSelectedTier(e.target.value)}
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ >
+ Select a tier...
+ {mockTiers.map((tier) => (
+
+ {tier.title} ({formatCurrency(tier.value)})
+
+ ))}
+
+
+
+ {mode === 'select' ? (
+ /* Select existing sponsor */
+
+
+ Select Sponsor
+
+
setSelectedSponsor(e.target.value)}
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ >
+ Select a sponsor...
+ {mockExistingSponsors.map((sponsor) => (
+
+ {sponsor.name}
+
+ ))}
+
+
+ Sponsors already added to this conference are not shown
+
+
+ ) : (
+ /* Create new sponsor form */
+
+
+
+ Sponsor Name
+
+ setNewSponsorName(e.target.value)}
+ placeholder="e.g. Acme Corporation"
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 placeholder:text-gray-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ />
+
+
+
+
+ Website
+
+ setNewSponsorWebsite(e.target.value)}
+ placeholder="https://example.com"
+ className="mt-1 block w-full rounded-lg border border-gray-300 px-3 py-2 text-gray-900 placeholder:text-gray-400 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ />
+
+
+
+
+ Logo
+
+
+
+
+
+ Drop logo here or click to upload
+
+
+ SVG or PNG recommended
+
+
+
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+ Cancel
+
+
+
+
+ {mode === 'select' ? 'Add Sponsor' : 'Create & Add'}
+
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+ ),
+}
+
+export const WithPreselectedTier: Story = {
+ render: () => (
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorAddModal
+
+
+ Modal dialog for adding a sponsor to a conference. Supports both
+ selecting an existing sponsor from the database or creating a new
+ sponsor inline.
+
+
+
+
+
Props
+
+
+
+ open
+ {' '}
+ - Whether modal is visible
+
+
+
+ onClose
+ {' '}
+ - Callback when modal closes
+
+
+
+ conferenceId
+ {' '}
+ - Target conference for assignment
+
+
+
+ preselectedTierId
+ {' '}
+ - Optional tier to pre-select
+
+
+
+ availableTiers
+ {' '}
+ - Conference tier options
+
+
+
+ existingSponsors
+ {' '}
+ - Sponsors not yet added
+
+
+
+
+
+
+ Features
+
+
+ • Two modes: select existing or create new
+ • Tier selection with value display
+ • Filters out sponsors already in conference
+ • Logo upload for new sponsors
+ • Form validation before submit
+ • Creates SponsorForConference document on submit
+
+
+
+
+
+ Integration
+
+
+ Opened from SponsorTierManagement when clicking "Add
+ Sponsor" button on a tier row. The tier ID is passed as
+ preselectedTierId.
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorContactActions.stories.tsx b/src/components/admin/sponsor/SponsorContactActions.stories.tsx
new file mode 100644
index 00000000..a308d7b1
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorContactActions.stories.tsx
@@ -0,0 +1,165 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ EnvelopeIcon,
+ DocumentArrowDownIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Contacts/SponsorContactActions',
+ parameters: {
+ layout: 'padded',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function ActionButton({
+ icon,
+ label,
+ disabled = false,
+ variant = 'primary',
+}: {
+ icon: React.ReactNode
+ label: string
+ disabled?: boolean
+ variant?: 'primary' | 'secondary'
+}) {
+ const baseClasses =
+ 'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors'
+ const variantClasses =
+ variant === 'primary'
+ ? 'bg-indigo-600 text-white hover:bg-indigo-700 disabled:bg-indigo-300'
+ : 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
+
+ return (
+
+ {icon}
+ {label}
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+
+ Sponsor Contacts
+
+
+ 15 sponsors with contact information
+
+
+
+
}
+ label="Export Contacts"
+ variant="secondary"
+ />
+
}
+ label="Send Broadcast (15)"
+ />
+
+
+
+ ),
+}
+
+export const NoContacts: Story = {
+ render: () => (
+
+
+
+
+ Sponsor Contacts
+
+
+ No sponsors with contact information
+
+
+
+
}
+ label="Export Contacts"
+ variant="secondary"
+ disabled
+ />
+
}
+ label="Send Broadcast (0)"
+ disabled
+ />
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorContactActions
+
+
+ Action bar for sponsor contact management. Provides export and
+ broadcast email functionality for all sponsors with contact
+ information.
+
+
+
+
+
Props
+
+
+
+ sponsorsWithContactsCount
+ {' '}
+ - Number of sponsors with contacts
+
+
+
+ fromEmail
+ {' '}
+ - Email address for broadcast sender
+
+
+
+ conference
+ {' '}
+ - Conference object with event details
+
+
+
+
+
+
Actions
+
+
+ Export Contacts - Download CSV/Excel of all sponsor
+ contacts
+
+
+ Send Broadcast - Opens modal to compose and send
+ email to all sponsors
+
+
+
+
+
+
+ Integration
+
+
+ Used on the /admin/sponsors/contacts page as the header action bar.
+ Integrates with GeneralBroadcastModal for email composition.
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorContactEditor.stories.tsx b/src/components/admin/sponsor/SponsorContactEditor.stories.tsx
new file mode 100644
index 00000000..eda723b7
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorContactEditor.stories.tsx
@@ -0,0 +1,350 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import {
+ PlusIcon,
+ TrashIcon,
+ EnvelopeIcon,
+ PhoneIcon,
+ UserIcon,
+ CreditCardIcon,
+ StarIcon as StarIconOutline,
+} from '@heroicons/react/24/outline'
+import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'
+import { CONTACT_ROLE_OPTIONS } from '@/lib/sponsor/types'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Contacts/SponsorContactEditor',
+ parameters: {
+ layout: 'padded',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+interface Contact {
+ _key: string
+ name: string
+ email: string
+ phone: string
+ role: string
+ isPrimary: boolean
+}
+
+function ContactEditorDemo() {
+ const [contacts, setContacts] = useState([
+ {
+ _key: '1',
+ name: 'Maria Jensen',
+ email: 'maria@techgiant.com',
+ phone: '+47 900 00 001',
+ role: 'Marketing',
+ isPrimary: true,
+ },
+ {
+ _key: '2',
+ name: 'Erik Olsen',
+ email: 'erik@techgiant.com',
+ phone: '+47 900 00 002',
+ role: 'Finance',
+ isPrimary: false,
+ },
+ ])
+ const [billing, setBilling] = useState({
+ email: 'invoices@techgiant.com',
+ reference: 'PO-2025-001',
+ comments: 'Net 30 payment terms',
+ })
+
+ const handleAddContact = () => {
+ setContacts([
+ ...contacts,
+ {
+ _key: String(Date.now()),
+ name: '',
+ email: '',
+ phone: '',
+ role: '',
+ isPrimary: contacts.length === 0,
+ },
+ ])
+ }
+
+ const handleRemoveContact = (key: string) => {
+ setContacts(contacts.filter((c) => c._key !== key))
+ }
+
+ const handleSetPrimary = (key: string) => {
+ setContacts(
+ contacts.map((c) => ({
+ ...c,
+ isPrimary: c._key === key,
+ })),
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+ Contact Persons
+
+
+ Manage contact persons for TechGiant Corp
+
+
+
+
+ Add Contact
+
+
+
+ {/* Contacts List */}
+
+ {contacts.map((contact) => (
+
+
+ handleSetPrimary(contact._key)}
+ className={`flex items-center gap-1 text-sm ${
+ contact.isPrimary
+ ? 'text-amber-600 dark:text-amber-400'
+ : 'text-gray-400 hover:text-amber-600 dark:hover:text-amber-400'
+ }`}
+ >
+ {contact.isPrimary ? (
+
+ ) : (
+
+ )}
+ {contact.isPrimary ? 'Primary Contact' : 'Set as Primary'}
+
+ handleRemoveContact(contact._key)}
+ className="rounded p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
+ >
+
+
+
+
+
+
+
+
+ Name
+
+
+
+
+
+
+ Email
+
+
+
+
+
+
+ Role
+
+
+ Select role...
+ {CONTACT_ROLE_OPTIONS.map((role) => (
+
+ {role}
+
+ ))}
+
+
+
+
+ ))}
+
+
+ {/* Billing Information */}
+
+
+
+ Billing Information
+
+
+
+
+ Billing Email
+
+
+ setBilling({ ...billing, email: e.target.value })
+ }
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ placeholder="invoices@company.com"
+ />
+
+
+
+ Purchase Order Reference
+
+
+ setBilling({ ...billing, reference: e.target.value })
+ }
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ placeholder="PO-XXXX"
+ />
+
+
+
+ Comments
+
+
+ setBilling({ ...billing, comments: e.target.value })
+ }
+ rows={2}
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ placeholder="Payment terms, special instructions..."
+ />
+
+
+
+
+ {/* Actions */}
+
+
+ Cancel
+
+
+ Save Changes
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const Empty: Story = {
+ render: () => (
+
+
+
+
+ Contact Persons
+
+
+ Manage contact persons for New Sponsor Inc
+
+
+
+
+ Add Contact
+
+
+
+
+
+
+ No contacts added yet
+
+
+ Click "Add Contact" to get started
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorContactEditor
+
+
+ Form for managing sponsor contact persons and billing information.
+ Supports multiple contacts with primary designation and role
+ assignment.
+
+
+
+
+
Props
+
+
+
+ sponsorForConference
+ {' '}
+ - SponsorForConference record to edit
+
+
+
+ onSuccess
+ {' '}
+ - Callback when contacts are saved
+
+
+
+ onCancel
+ {' '}
+ - Callback when editing is cancelled
+
+
+
+
+
+
+ Features
+
+
+ • Multiple contact persons per sponsor
+ • Primary contact designation (star icon)
+ • Role assignment (Marketing, Finance, etc.)
+ • Billing email and PO reference
+ • First added contact auto-marked as primary
+ • Uses tRPC mutation for persistence
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorContactEditor.tsx b/src/components/admin/sponsor/SponsorContactEditor.tsx
index ec77b14f..327f9505 100644
--- a/src/components/admin/sponsor/SponsorContactEditor.tsx
+++ b/src/components/admin/sponsor/SponsorContactEditor.tsx
@@ -15,7 +15,7 @@ import {
StarIcon,
} from '@heroicons/react/24/outline'
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid'
-import { ContactRoleSelect } from '@/components/common/ContactRoleSelect'
+import { SponsorContactRoleSelect } from '@/components/admin/sponsor/SponsorContactRoleSelect'
import { nanoid } from 'nanoid'
import { api } from '@/lib/trpc/client'
import { useNotification } from '../NotificationProvider'
@@ -264,7 +264,7 @@ export function SponsorContactEditor({
Role / Position
-
handleUpdateContact(index, { role: value })
diff --git a/src/components/admin/sponsor/SponsorContactRoleSelect.stories.tsx b/src/components/admin/sponsor/SponsorContactRoleSelect.stories.tsx
new file mode 100644
index 00000000..e1c4f66d
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorContactRoleSelect.stories.tsx
@@ -0,0 +1,142 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import { CONTACT_ROLE_OPTIONS } from '@/lib/sponsor/types'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/SponsorContactRoleSelect',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function SponsorContactRoleSelectDemo() {
+ const [value, setValue] = useState('')
+
+ return (
+
+
+ Contact Role
+
+ setValue(e.target.value)}
+ className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
+ >
+ Select role...
+ {CONTACT_ROLE_OPTIONS.map((role) => (
+
+ {role}
+
+ ))}
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const WithSelection: Story = {
+ render: () => (
+
+
+ Contact Role
+
+ {}}
+ className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
+ >
+ Select role...
+ {CONTACT_ROLE_OPTIONS.map((role) => (
+
+ {role}
+
+ ))}
+
+
+ ),
+}
+
+export const AllRoles: Story = {
+ render: () => (
+
+
+ Available Roles
+
+
+ {CONTACT_ROLE_OPTIONS.map((role) => (
+
+ {role}
+
+ ))}
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorContactRoleSelect
+
+
+ Simple dropdown for selecting contact person roles. Used in sponsor
+ contact forms to categorize contact persons by their function.
+
+
+
+
+
Props
+
+
+
+ value
+ {' '}
+ - Currently selected role
+
+
+
+ onChange
+ {' '}
+ - Callback when role changes
+
+
+
+ placeholder
+ {' '}
+ - Custom placeholder text
+
+
+
+ disabled
+ {' '}
+ - Disable the select
+
+
+
+
+
+
+ Available Roles
+
+
+ {CONTACT_ROLE_OPTIONS.map((role) => (
+ • {role}
+ ))}
+
+
+
+ ),
+}
diff --git a/src/components/common/ContactRoleSelect.tsx b/src/components/admin/sponsor/SponsorContactRoleSelect.tsx
similarity index 85%
rename from src/components/common/ContactRoleSelect.tsx
rename to src/components/admin/sponsor/SponsorContactRoleSelect.tsx
index fb4feb23..565383c1 100644
--- a/src/components/common/ContactRoleSelect.tsx
+++ b/src/components/admin/sponsor/SponsorContactRoleSelect.tsx
@@ -1,6 +1,6 @@
import { CONTACT_ROLE_OPTIONS } from '@/lib/sponsor/types'
-interface ContactRoleSelectProps {
+interface SponsorContactRoleSelectProps {
value: string
onChange: (value: string) => void
placeholder?: string
@@ -8,13 +8,13 @@ interface ContactRoleSelectProps {
disabled?: boolean
}
-export function ContactRoleSelect({
+export function SponsorContactRoleSelect({
value,
onChange,
placeholder = 'Select role...',
className = '',
disabled = false,
-}: ContactRoleSelectProps) {
+}: SponsorContactRoleSelectProps) {
return (
+
+interface ContactPerson {
+ _key: string
+ name: string
+ email: string
+ phone?: string
+ role: string
+ isPrimary?: boolean
+}
+
+interface MockSponsor {
+ _id: string
+ sponsor: { name: string }
+ tier?: { title: string }
+ contactPersons: ContactPerson[]
+ billing?: { email: string; reference?: string }
+}
+
+const mockSponsors: MockSponsor[] = [
+ {
+ _id: 'sfc-1',
+ sponsor: { name: 'Acme Corporation' },
+ tier: { title: 'Gold' },
+ contactPersons: [
+ {
+ _key: 'c1',
+ name: 'John Smith',
+ email: 'john@acme.com',
+ phone: '+47 123 45 678',
+ role: 'Marketing Manager',
+ isPrimary: true,
+ },
+ {
+ _key: 'c2',
+ name: 'Jane Doe',
+ email: 'jane@acme.com',
+ role: 'Developer Relations',
+ },
+ ],
+ billing: { email: 'billing@acme.com', reference: 'PO-2024-001' },
+ },
+ {
+ _id: 'sfc-2',
+ sponsor: { name: 'TechStart Inc' },
+ tier: { title: 'Silver' },
+ contactPersons: [
+ {
+ _key: 'c3',
+ name: 'Bob Wilson',
+ email: 'bob@techstart.io',
+ phone: '+47 987 65 432',
+ role: 'CEO',
+ isPrimary: true,
+ },
+ ],
+ billing: { email: 'accounts@techstart.io' },
+ },
+ {
+ _id: 'sfc-3',
+ sponsor: { name: 'CloudCo' },
+ tier: { title: 'Gold' },
+ contactPersons: [
+ {
+ _key: 'c4',
+ name: 'Alice Brown',
+ email: 'alice@cloudco.no',
+ role: 'Partnership Lead',
+ isPrimary: true,
+ },
+ {
+ _key: 'c5',
+ name: 'Chris Green',
+ email: 'chris@cloudco.no',
+ role: 'Event Coordinator',
+ },
+ {
+ _key: 'c6',
+ name: 'Dana White',
+ email: 'dana@cloudco.no',
+ role: 'Finance',
+ },
+ ],
+ billing: { email: 'invoice@cloudco.no', reference: 'CONF-2024' },
+ },
+]
+
+function getTierColor(tier?: string) {
+ switch (tier?.toLowerCase()) {
+ case 'gold':
+ return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
+ case 'silver':
+ return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
+ case 'platinum':
+ return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
+ default:
+ return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
+ }
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+
+
+
+ Sponsor
+
+
+ Contact
+
+
+ Email
+
+
+ Role
+
+
+ Phone
+
+
+ Actions
+
+
+
+
+ {mockSponsors.flatMap((sfc) =>
+ sfc.contactPersons.map((contact, contactIdx) => (
+
+ {contactIdx === 0 ? (
+
+
+
+
+
+ {sfc.sponsor.name}
+
+ {sfc.tier && (
+
+ {sfc.tier.title}
+
+ )}
+
+
+
+ ) : null}
+
+
+
+ {contact.name}
+
+ {contact.isPrimary && (
+
+ Primary
+
+ )}
+
+
+
+
+
+
+ {contact.role}
+
+
+ {contact.phone || '—'}
+
+ {contactIdx === 0 ? (
+
+
+
+
+
+ ) : null}
+
+ )),
+ )}
+
+
+
+
+ ),
+}
+
+export const Empty: Story = {
+ render: () => (
+
+
+
+
+
+
+ Sponsor
+
+
+ Contact
+
+
+ Email
+
+
+ Role
+
+
+ Phone
+
+
+ Actions
+
+
+
+
+
+
+
+
+ No contacts found
+
+
+ Add contact information to sponsors in the CRM pipeline.
+
+
+
+
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorContactTable
+
+
+ Displays sponsor contacts in a grouped table format. Contacts are
+ grouped by sponsor with row spanning for visual organization.
+
+
+
+
+
+ Features
+
+
+ • Row spanning groups contacts by sponsor
+ • Primary contact badge indicator
+ • Copy email to clipboard with feedback
+ • Inline edit modal (via SponsorContactEditor)
+ • Tier badges with color coding
+ • Real-time updates via tRPC query invalidation
+
+
+
+
+
Props
+
+ {`interface SponsorContactTableProps {
+ sponsors: SponsorForConferenceExpanded[]
+}`}
+
+
+
+
+
+ Contact Roles
+
+
+ Common roles from CONTACT_ROLE_OPTIONS:
+
+
+ {[
+ 'CEO',
+ 'Marketing Manager',
+ 'Developer Relations',
+ 'Partnership Lead',
+ 'Event Coordinator',
+ 'Finance',
+ 'Other',
+ ].map((role) => (
+
+ {role}
+
+ ))}
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorDashboardMetrics.stories.tsx b/src/components/admin/sponsor/SponsorDashboardMetrics.stories.tsx
new file mode 100644
index 00000000..a0de3a13
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorDashboardMetrics.stories.tsx
@@ -0,0 +1,255 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Dashboard/Metrics',
+ parameters: {
+ layout: 'centered',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+// Compact metric card variant for display
+function MetricCard({
+ title,
+ value,
+ subtitle,
+ trend,
+ icon,
+}: {
+ title: string
+ value: string
+ subtitle?: string
+ trend?: 'up' | 'down' | 'neutral'
+ icon: React.ReactNode
+}) {
+ const trendColors = {
+ up: 'bg-green-100 text-green-600 dark:bg-green-900/20 dark:text-green-400',
+ down: 'bg-red-100 text-red-600 dark:bg-red-900/20 dark:text-red-400',
+ neutral: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
+ }
+
+ return (
+
+
+
+
+ {title}
+
+
+ {value}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+
+ {icon}
+
+
+
+ )
+}
+
+export const Metrics: Story = {
+ render: () => (
+
+
+
+ Sponsor Dashboard Metrics
+
+
+ Displays key sponsor pipeline metrics at a glance. Shows revenue,
+ deals, tier utilization, and invoice status.
+
+
+
+ {/* Live Example */}
+
+
+ Live Example
+
+
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ {/* Usage */}
+
+
+ Usage
+
+
+ {`import { SponsorDashboardMetrics } from '@/components/admin/sponsor'
+
+function DashboardPage({ conferenceId }: { conferenceId: string }) {
+ return
+}`}
+
+
+
+ {/* Props */}
+
+
+ Props
+
+
+
+
+
+
+ Prop
+
+
+ Type
+
+
+ Description
+
+
+
+
+
+
+ conferenceId
+
+
+ string
+
+
+ Conference ID for fetching sponsor metrics
+
+
+
+
+
+
+
+ {/* Features */}
+
+
+ Features
+
+
+
+ âś“
+
+ Multi-currency support - Converts all values to
+ NOK for unified display
+
+
+
+ âś“
+
+ Real-time updates - Uses tRPC + React Query for
+ automatic refresh
+
+
+
+ âś“
+
+ Trend indicators - Visual cues for positive,
+ negative, and neutral trends
+
+
+
+ âś“
+
+ Loading states - Skeleton animations during data
+ fetch
+
+
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorDiscountEmailModal.stories.tsx b/src/components/admin/sponsor/SponsorDiscountEmailModal.stories.tsx
new file mode 100644
index 00000000..676d966f
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorDiscountEmailModal.stories.tsx
@@ -0,0 +1,371 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import { XMarkIcon, EyeIcon, TicketIcon } from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Email/SponsorDiscountEmailModal',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'Specialized modal for sending discount code emails to sponsor contacts. Built on the base EmailModal, it adds template variable processing for sponsor-specific placeholders, a ticket URL field for registration links, and a BroadcastTemplate preview that includes a formatted discount code info block. Sends via the `/admin/api/sponsors/email/discount` endpoint.',
+ },
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+function DiscountEmailMockup() {
+ const [subject, setSubject] = useState(
+ 'Your Cloud Native Days Bergen 2025 Sponsor Discount Code',
+ )
+ const [ticketUrl, setTicketUrl] = useState(
+ 'https://bergen.cloudnativeday.no/tickets',
+ )
+ const [body, setBody] = useState(
+ "Dear TechGiant Corp team,\n\nWe're excited to share your sponsor discount code for Cloud Native Days Bergen 2025!\n\nAs a Gold sponsor, you're entitled to 5 complimentary tickets for the conference.",
+ )
+ const [showPreview, setShowPreview] = useState(false)
+
+ return (
+
+ {/* Header */}
+
+
+
+ Send Discount Code Email
+
+
+
+
+ SPONSOR-GOLD-2025
+
+ Gold tier
+
+
+
+
+
+
+
+ {/* Email fields */}
+
+
+
+
+ To:
+
+
+
+ TechGiant Corp contact persons
+
+
+
+
+
+
+ From:
+
+
+ conference@cloudnativebergen.no
+
+
+
+
+
+ Subject:
+
+ setSubject(e.target.value)}
+ className="font-inter w-full border-none bg-transparent px-0 py-1 text-sm placeholder-gray-400 focus:ring-0 focus:outline-none dark:text-white"
+ />
+
+
+
+
+ Ticket URL:
+
+ setTicketUrl(e.target.value)}
+ className="font-inter w-full border-none bg-transparent px-0 py-1 text-sm placeholder-gray-400 focus:ring-0 focus:outline-none dark:text-white"
+ placeholder="https://example.com/tickets"
+ />
+
+
+
+
+
+ Template variables:{' '}
+
+ {'{{{SPONSOR_NAME}}}'}
+
+ ,{' '}
+
+ {'{{{SPONSOR_TIER}}}'}
+
+ ,{' '}
+
+ {'{{{TICKET_COUNT}}}'}
+
+ ,{' '}
+
+ {'{{{TICKET_COUNT_PLURAL}}}'}
+
+
+ {showPreview ? (
+
+
+
+ Email Preview
+
+ setShowPreview(false)}
+ className="rounded-md border border-gray-300 px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-gray-50 dark:border-gray-600 dark:text-white dark:hover:bg-gray-800"
+ >
+ Back to Edit
+
+
+
+
+
Dear TechGiant Corp team,
+
+ We're excited to share your sponsor discount code for
+ Cloud Native Days Bergen 2025!
+
+
+ As a Gold sponsor, you're entitled to 5 complimentary
+ tickets for the conference.
+
+
+ {/* Discount code info block */}
+
+
+ Your Discount Code
+
+
+
+ Discount Code: {' '}
+
+ SPONSOR-GOLD-2025
+
+
+
+ Ticket Registration: {' '}
+
+ {ticketUrl}
+
+
+
+ Instructions: Enter the discount code
+ during checkout to receive your sponsor tickets
+
+
+
+
+
+ ) : (
+
+ setBody(e.target.value)}
+ rows={8}
+ className="w-full border-none bg-transparent text-sm text-gray-900 focus:ring-0 focus:outline-none dark:text-white"
+ />
+
+ )}
+
+
+
+ {/* Footer */}
+
+
setShowPreview(!showPreview)}
+ className={`inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-semibold ${
+ showPreview
+ ? 'border-indigo-500 bg-indigo-100 text-indigo-700'
+ : 'border-gray-300 text-gray-900 dark:border-gray-600 dark:text-white'
+ }`}
+ >
+
+ Preview Email
+
+
+
+ Cancel
+
+
+ Send Email
+
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'The compose view showing the discount code context badge, ticket URL field, template variable hints, and email body. Click "Preview Email" to see the rendered output with the formatted discount code info block.',
+ },
+ },
+ },
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorDiscountEmailModal
+
+
+ Specialized modal for sending discount code emails to sponsor
+ contacts. Uses the base EmailModal with template variable processing
+ and a formatted discount code info block in the preview.
+
+
+
+
+
Props
+
+
+
+ isOpen
+ {' '}
+ - Whether modal is visible
+
+
+
+ onClose
+ {' '}
+ - Callback when closed
+
+
+
+ sponsor
+ {' '}
+ - SponsorWithTierInfo (id, name, tier, ticketEntitlement)
+
+
+
+ discountCode
+ {' '}
+ - The discount code to include in the email
+
+
+
+ domain / fromEmail
+ {' '}
+ - Sending configuration
+
+
+
+ conference
+ {' '}
+ - Conference context (title, city, country, startDate, domains)
+
+
+
+
+
+
+ Template Variables
+
+
+
+
+
+ Variable
+ Replaced With
+
+
+
+
+
+
+ {'{{{SPONSOR_NAME}}}'}
+
+
+ Sponsor company name
+
+
+
+
+ {'{{{SPONSOR_TIER}}}'}
+
+
+ Sponsor tier title (e.g. Gold)
+
+
+
+
+ {'{{{TICKET_COUNT}}}'}
+
+
+ Number of entitled tickets
+
+
+
+
+ {'{{{TICKET_COUNT_PLURAL}}}'}
+
+
+
+ "s" if count > 1, empty otherwise
+
+
+
+
+
+
+
+
+
+ Key Features
+
+
+
+ • Template variable processing — Replaces
+ placeholders in subject, body, and preview with sponsor-specific
+ values
+
+
+ • Ticket URL field — Configurable registration URL,
+ defaults to conference domain /tickets
+
+
+ • Discount code info block — Formatted block
+ appended to preview showing code, URL and instructions
+
+
+ • BroadcastTemplate preview — Full email preview
+ with conference branding
+
+
+ • Localhost protection — Disables sending on
+ localhost
+
+
+ • Contact filtering — Sends to contact persons
+ only, excludes billing contacts
+
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorEmailTemplateEditor.stories.tsx b/src/components/admin/sponsor/SponsorEmailTemplateEditor.stories.tsx
new file mode 100644
index 00000000..b9498429
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorEmailTemplateEditor.stories.tsx
@@ -0,0 +1,178 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { http, HttpResponse } from 'msw'
+import { SponsorEmailTemplateEditor } from './SponsorEmailTemplateEditor'
+import { NotificationProvider } from '@/components/admin/NotificationProvider'
+import { fn } from 'storybook/test'
+import type { SponsorEmailTemplate } from '@/lib/sponsor/types'
+import type { Conference } from '@/lib/conference/types'
+
+const mockConference: Conference = {
+ _id: 'conf-1',
+ title: 'Cloud Native Day Bergen 2025',
+ organizer: 'Cloud Native Bergen',
+ city: 'Bergen',
+ country: 'Norway',
+ startDate: '2025-09-15',
+ endDate: '2025-09-15',
+ cfpStartDate: '2025-01-01',
+ cfpEndDate: '2025-03-01',
+ cfpNotifyDate: '2025-04-01',
+ cfpEmail: 'cfp@cloudnativeday.no',
+ sponsorEmail: 'sponsor@cloudnativeday.no',
+ contactEmail: 'contact@cloudnativeday.no',
+ programDate: '2025-09-15',
+ registrationEnabled: true,
+ domains: ['cloudnativeday.no'],
+ formats: [],
+ topics: [],
+ organizers: [],
+ sponsors: [],
+ sponsorshipCustomization: {
+ prospectusUrl: 'https://cloudnativeday.no/sponsors',
+ },
+}
+
+const mockExistingTemplate: SponsorEmailTemplate = {
+ _id: 'template-1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Initial Outreach (English)',
+ slug: { current: 'initial-outreach-english' },
+ description: 'First contact with potential sponsors',
+ subject: 'Partnership Opportunity - {{{CONFERENCE_TITLE}}}',
+ body: [
+ {
+ _key: 'p1',
+ _type: 'block',
+ children: [
+ {
+ _key: 's1',
+ _type: 'span',
+ text: 'Dear {{{CONTACT_NAMES}}},',
+ },
+ ],
+ markDefs: [],
+ style: 'normal',
+ },
+ {
+ _key: 'p2',
+ _type: 'block',
+ children: [
+ {
+ _key: 's1',
+ _type: 'span',
+ text: 'We are excited to invite {{{SPONSOR_NAME}}} to partner with us at {{{CONFERENCE_TITLE}}} in {{{CONFERENCE_CITY}}}.',
+ },
+ ],
+ markDefs: [],
+ style: 'normal',
+ },
+ {
+ _key: 'p3',
+ _type: 'block',
+ children: [
+ {
+ _key: 's1',
+ _type: 'span',
+ text: 'Best regards,',
+ },
+ ],
+ markDefs: [],
+ style: 'normal',
+ },
+ {
+ _key: 'p4',
+ _type: 'block',
+ children: [
+ {
+ _key: 's1',
+ _type: 'span',
+ text: '{{{SENDER_NAME}}}',
+ },
+ ],
+ markDefs: [],
+ style: 'normal',
+ },
+ ],
+ category: 'cold-outreach',
+ language: 'en',
+}
+
+const createMswHandlers = () => [
+ // Handle create mutation
+ http.post('/api/trpc/sponsor.emailTemplates.create', async () => {
+ return HttpResponse.json({
+ result: {
+ data: { _id: 'template-new', success: true },
+ },
+ })
+ }),
+ // Handle update mutation
+ http.post('/api/trpc/sponsor.emailTemplates.update', async () => {
+ return HttpResponse.json({
+ result: {
+ data: { success: true },
+ },
+ })
+ }),
+ // Handle list query (for cache invalidation)
+ http.get('/api/trpc/sponsor.emailTemplates.list', () => {
+ return HttpResponse.json({
+ result: {
+ data: [mockExistingTemplate],
+ },
+ })
+ }),
+]
+
+const meta = {
+ title: 'Systems/Sponsors/Email/SponsorEmailTemplateEditor',
+ component: SponsorEmailTemplateEditor,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ 'Two-column editor for creating and editing sponsor email templates. Features a rich text editor with template variable support and live preview. Supports categories (cold-outreach, follow-up, contract, post-event) and languages (Norwegian, English). Variables like {{{SPONSOR_NAME}}}, {{{CONFERENCE_TITLE}}} are resolved in the preview panel.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const CreateNew: Story = {
+ args: {
+ conference: mockConference,
+ onSaved: fn(),
+ },
+ parameters: {
+ msw: {
+ handlers: createMswHandlers(),
+ },
+ },
+}
+
+export const EditExisting: Story = {
+ args: {
+ conference: mockConference,
+ template: mockExistingTemplate,
+ onSaved: fn(),
+ },
+ parameters: {
+ msw: {
+ handlers: createMswHandlers(),
+ },
+ },
+}
diff --git a/src/components/admin/sponsor/SponsorIndividualEmailModal.stories.tsx b/src/components/admin/sponsor/SponsorIndividualEmailModal.stories.tsx
new file mode 100644
index 00000000..78d99b43
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorIndividualEmailModal.stories.tsx
@@ -0,0 +1,357 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import { XMarkIcon, EyeIcon, ClockIcon } from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Email/SponsorIndividualEmailModal',
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'Modal for composing and sending individual emails to sponsor contacts. Built on top of the base EmailModal component, it adds CRM-aware default subjects based on sponsor pipeline status, a SponsorTemplatePicker for applying pre-built templates, and BroadcastTemplate-based email preview. Sends via the `/admin/api/sponsors/email/send` endpoint.',
+ },
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const mockTemplates = [
+ { id: 'welcome', name: 'Welcome Email', subject: 'Welcome as a sponsor!' },
+ {
+ id: 'reminder',
+ name: 'Payment Reminder',
+ subject: 'Invoice reminder for Cloud Native Days Bergen 2025',
+ },
+ {
+ id: 'onboarding',
+ name: 'Onboarding Link',
+ subject: 'Complete your sponsor profile',
+ },
+ {
+ id: 'contract',
+ name: 'Contract Ready',
+ subject: 'Your sponsorship contract is ready',
+ },
+]
+
+function EmailModalMockup({
+ showDraftIndicator = false,
+}: {
+ showDraftIndicator?: boolean
+}) {
+ const [selectedTemplate, setSelectedTemplate] = useState('')
+ const [subject, setSubject] = useState(
+ 'Partnership opportunity: Cloud Native Days Bergen 2025',
+ )
+ const [body, setBody] = useState(
+ 'Dear TechGiant Corp team,\n\nWe would love to have you as a sponsor for our upcoming conference.\n\nBest regards,\nThe Cloud Native Days Team',
+ )
+ const [showPreview, setShowPreview] = useState(false)
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Compose Sponsor Email
+
+ {showDraftIndicator && (
+
+
+ Draft saved
+
+ )}
+
+
+ Sponsor: TechGiant Corp
+
+
+
+
+
+
+
+ {/* Email fields */}
+
+
+
+
+ To:
+
+
+
+ Maria Jensen <maria@techgiant.com>
+
+
+ Erik Olsen <erik@techgiant.com>
+
+
+
+
+
+
+
+ From:
+
+
+ conference@cloudnativebergen.no
+
+
+
+
+ Template:
+
+ setSelectedTemplate(e.target.value)}
+ className="flex-1 rounded-lg border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ >
+ Select template...
+ {mockTemplates.map((t) => (
+
+ {t.name}
+
+ ))}
+
+
+
+
+
+
+ Subject:
+
+ setSubject(e.target.value)}
+ className="font-inter w-full border-none bg-transparent px-0 py-1 text-sm placeholder-gray-400 focus:ring-0 focus:outline-none dark:text-white"
+ />
+
+
+
+
+ {showPreview ? (
+
+
+
+ Email Preview
+
+ setShowPreview(false)}
+ className="rounded-md border border-gray-300 px-3 py-2 text-sm font-semibold text-gray-900 hover:bg-gray-50 dark:border-gray-600 dark:text-white dark:hover:bg-gray-800"
+ >
+ Back to Edit
+
+
+
+
+
Dear TechGiant Corp team,
+
+ We would love to have you as a sponsor for our upcoming
+ conference.
+
+
Best regards,
+
The Cloud Native Days Team
+
+
+
+ ) : (
+
+ setBody(e.target.value)}
+ rows={8}
+ className="w-full border-none bg-transparent text-sm text-gray-900 focus:ring-0 focus:outline-none dark:text-white"
+ />
+
+ )}
+
+
+
+ {/* Footer */}
+
+
setShowPreview(!showPreview)}
+ className={`inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-semibold ${
+ showPreview
+ ? 'border-indigo-500 bg-indigo-100 text-indigo-700'
+ : 'border-gray-300 text-gray-900 dark:border-gray-600 dark:text-white'
+ }`}
+ >
+
+ Preview Email
+
+
+
+ Cancel
+
+
+ Send Email
+
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'The compose view with To recipients as badges, From address, template picker, CRM-aware subject, and rich text editor.',
+ },
+ },
+ },
+}
+
+export const WithDraftSaved: Story = {
+ render: () => (
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Drafts auto-save to localStorage keyed per sponsor. A badge in the header shows when a draft has been saved.',
+ },
+ },
+ },
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorIndividualEmailModal
+
+
+ Modal for composing and sending individual emails to sponsor contacts.
+ Built on the base EmailModal with CRM-specific features.
+
+
+
+
+
Props
+
+
+
+ isOpen
+ {' '}
+ - Whether modal is visible
+
+
+
+ onClose
+ {' '}
+ - Callback when closed
+
+
+
+ onSent
+ {' '}
+ - Optional callback after successful send
+
+
+
+ sponsorForConference
+ {' '}
+ - SponsorForConferenceExpanded with contacts
+
+
+
+ domain / fromEmail / senderName
+ {' '}
+ - Sending configuration
+
+
+
+ conference
+ {' '}
+ - Conference context (title, dates, domains, social links)
+
+
+
+
+
+
+ Key Features
+
+
+
+ • Smart subject defaults — Auto-generated from CRM
+ pipeline status (prospect, contacted, negotiating, closed-won)
+
+
+ • Template picker — SponsorTemplatePicker with
+ CRM-aware context (tags, status, currency)
+
+
+ • BroadcastTemplate preview — Rendered email
+ preview matching the actual sent format
+
+
+ • Auto-save drafts — Per-sponsor localStorage
+ persistence
+
+
+ • Localhost protection — Disables sending on
+ localhost with a warning
+
+
+
+
+
+
+ Subject Generation Logic
+
+
+
+
+
+ Status
+ Subject Pattern
+
+
+
+
+ prospect
+ Partnership opportunity: ...
+
+
+ contacted
+ Following up: Sponsorship for ...
+
+
+ negotiating
+ Sponsorship proposal - ...
+
+
+ closed-won + invoice
+ Sponsorship Invoice: ...
+
+
+ closed-won + contract
+ Sponsorship Contract: ...
+
+
+
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorLogoEditor.stories.tsx b/src/components/admin/sponsor/SponsorLogoEditor.stories.tsx
new file mode 100644
index 00000000..0ed200ad
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorLogoEditor.stories.tsx
@@ -0,0 +1,244 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import { ArrowDownTrayIcon, ArrowUpTrayIcon } from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Form/SponsorLogoEditor',
+ parameters: {
+ layout: 'padded',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+// Example SVG for demo
+const exampleSvg = `LOGO `
+const exampleBrightSvg = `LOGO `
+
+function LogoPreview({
+ svg,
+ label,
+ background,
+}: {
+ svg: string | null
+ label: string
+ background: 'light' | 'dark'
+}) {
+ return (
+
+
+ {label}
+
+
+ {svg ? (
+
+ ) : (
+
No logo
+ )}
+
+
+ )
+}
+
+function LogoEditorDemo() {
+ const [logo, setLogo] = useState(exampleSvg)
+ const [logoBright, setLogoBright] = useState(exampleBrightSvg)
+
+ return (
+
+
+ {/* Primary Logo */}
+
+
+ Logo (SVG) *
+
+
+ Primary logo for light backgrounds
+
+
+ {
+ const file = e.target.files?.[0]
+ if (file) {
+ const reader = new FileReader()
+ reader.onload = (e) => setLogo(e.target?.result as string)
+ reader.readAsText(file)
+ }
+ }}
+ />
+
+ {logo && (
+
+
+
+
setLogo(null)}
+ className="text-sm text-red-600 hover:text-red-700 dark:text-red-400"
+ >
+ Remove
+
+
+
+ Download
+
+
+
+ )}
+
+
+ {/* Bright Logo */}
+
+
+ Bright Logo (SVG)
+
+
+ Alternative for dark backgrounds
+
+
+ {
+ const file = e.target.files?.[0]
+ if (file) {
+ const reader = new FileReader()
+ reader.onload = (e) =>
+ setLogoBright(e.target?.result as string)
+ reader.readAsText(file)
+ }
+ }}
+ />
+
+ {logoBright && (
+
+
+ setLogoBright(null)}
+ className="text-sm text-red-600 hover:text-red-700 dark:text-red-400"
+ >
+ Remove
+
+
+ )}
+
+
+
+ {/* Preview Section */}
+
+
+ Logo Previews
+
+
+
+
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => ,
+}
+
+export const EmptyState: Story = {
+ render: () => (
+
+
+
+
+ Upload SVG Logo
+
+
+ SVG format required. Recommended: 200x80px or similar ratio.
+
+
+ Select File
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorLogoEditor
+
+
+ Dual-logo upload component supporting primary (light background) and
+ bright (dark background) variants. Accepts SVG files only and
+ sanitizes content for security.
+
+
+
+
+
Props
+
+
+
+ logo
+ {' '}
+ - Primary SVG string (for light backgrounds)
+
+
+
+ logoBright?
+ {' '}
+ - Alternative SVG for dark backgrounds
+
+
+
+ name
+ {' '}
+ - Sponsor name for download filename
+
+
+
+ onChange
+ {' '}
+ - Callback with updated logo values
+
+
+
+
+
+
+ Features
+
+
+ • SVG-only file validation
+ • Automatic SVG sanitization (removes scripts, unsafe attrs)
+ • Live preview on both light and dark backgrounds
+ • Download functionality for stored logos
+ • Support for bright variant fallback
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorTemplatePicker.stories.tsx b/src/components/admin/sponsor/SponsorTemplatePicker.stories.tsx
new file mode 100644
index 00000000..e32cb362
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorTemplatePicker.stories.tsx
@@ -0,0 +1,252 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { http, HttpResponse } from 'msw'
+import { fn } from 'storybook/test'
+import { SponsorTemplatePicker } from './SponsorTemplatePicker'
+
+const mockTemplates = [
+ {
+ _id: 'template-1',
+ title: 'Initial Outreach',
+ description: 'First contact with potential sponsors',
+ subject: 'Partnership Opportunity - {{{CONFERENCE_NAME}}}',
+ body: [
+ {
+ _key: 'p1',
+ _type: 'block',
+ children: [
+ {
+ _key: 's1',
+ _type: 'span',
+ text: 'Dear {{{CONTACT_NAME}}}, We are excited to invite {{{SPONSOR_NAME}}} to partner with us...',
+ },
+ ],
+ markDefs: [],
+ style: 'normal',
+ },
+ ],
+ category: 'outreach',
+ language: 'en',
+ isActive: true,
+ },
+ {
+ _id: 'template-2',
+ title: 'Avtaleforslag',
+ description: 'Norsk oppfølgingsmal',
+ subject: 'Oppfølging - {{{CONFERENCE_NAME}}} sponsoravtale',
+ body: [
+ {
+ _key: 'p1',
+ _type: 'block',
+ children: [
+ {
+ _key: 's1',
+ _type: 'span',
+ text: 'Hei {{{CONTACT_NAME}}}, Takk for interessen...',
+ },
+ ],
+ markDefs: [],
+ style: 'normal',
+ },
+ ],
+ category: 'outreach',
+ language: 'no',
+ isActive: true,
+ },
+ {
+ _id: 'template-3',
+ title: 'Contract Follow-up',
+ description: 'Follow up on pending contract',
+ subject: 'Contract Status - {{{CONFERENCE_NAME}}}',
+ body: [
+ {
+ _key: 'p1',
+ _type: 'block',
+ children: [
+ {
+ _key: 's1',
+ _type: 'span',
+ text: 'Hi {{{CONTACT_NAME}}}, We wanted to follow up on the contract...',
+ },
+ ],
+ markDefs: [],
+ style: 'normal',
+ },
+ ],
+ category: 'contract',
+ language: 'en',
+ isActive: true,
+ },
+ {
+ _id: 'template-4',
+ title: 'Thank You',
+ description: 'Thank sponsor after event',
+ subject: 'Thank You for Supporting {{{CONFERENCE_NAME}}}',
+ body: [
+ {
+ _key: 'p1',
+ _type: 'block',
+ children: [
+ {
+ _key: 's1',
+ _type: 'span',
+ text: 'Dear {{{CONTACT_NAME}}}, Thank you for your support...',
+ },
+ ],
+ markDefs: [],
+ style: 'normal',
+ },
+ ],
+ category: 'post_event',
+ language: 'en',
+ isActive: true,
+ },
+]
+
+const mockConference = {
+ title: 'Cloud Native Day Bergen 2025',
+ city: 'Bergen',
+ startDate: '2025-09-15',
+ organizer: 'Cloud Native Bergen',
+ domains: ['cloudnativeday.no'],
+ prospectusUrl: 'https://cloudnativeday.no/sponsors',
+}
+
+const meta = {
+ title: 'Systems/Sponsors/Email/SponsorTemplatePicker',
+ component: SponsorTemplatePicker,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Dropdown for selecting and applying email templates. Fetches templates from tRPC API and groups them by category. Supports template variable substitution with sponsor and conference data. Shows language flags and recommended templates based on CRM context.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ sponsorName: 'Acme Corporation',
+ contactNames: 'John Doe',
+ conference: mockConference,
+ senderName: 'Hans Kristian',
+ tierName: 'Gold',
+ onApply: fn(),
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/sponsor.emailTemplates.list', () => {
+ return HttpResponse.json({
+ result: {
+ data: mockTemplates,
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
+
+export const WithCRMContext: Story = {
+ args: {
+ sponsorName: 'Equinor',
+ contactNames: 'Lisa Hansen',
+ conference: mockConference,
+ senderName: 'Hans Kristian',
+ tierName: 'Platinum',
+ onApply: fn(),
+ crmContext: {
+ tags: ['norwegian', 'large-enterprise'],
+ status: 'negotiating',
+ currency: 'NOK',
+ orgNumber: '923609016',
+ website: 'https://equinor.com',
+ },
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/sponsor.emailTemplates.list', () => {
+ return HttpResponse.json({
+ result: {
+ data: mockTemplates,
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
+
+export const Loading: Story = {
+ args: {
+ sponsorName: 'Loading Corp',
+ contactNames: 'Test User',
+ conference: mockConference,
+ onApply: fn(),
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/sponsor.emailTemplates.list', async () => {
+ await new Promise((resolve) => setTimeout(resolve, 30000))
+ return HttpResponse.json({
+ result: {
+ data: mockTemplates,
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
+
+export const NoTemplates: Story = {
+ args: {
+ sponsorName: 'Empty Corp',
+ contactNames: 'Test User',
+ conference: mockConference,
+ onApply: fn(),
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/sponsor.emailTemplates.list', () => {
+ return HttpResponse.json({
+ result: {
+ data: [],
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
+
+export const SingleCategory: Story = {
+ args: {
+ sponsorName: 'Test Corp',
+ contactNames: 'Test User',
+ conference: mockConference,
+ onApply: fn(),
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/sponsor.emailTemplates.list', () => {
+ return HttpResponse.json({
+ result: {
+ data: mockTemplates.filter((t) => t.category === 'outreach'),
+ },
+ })
+ }),
+ ],
+ },
+ },
+}
diff --git a/src/components/admin/sponsor/SponsorTierEditor.stories.tsx b/src/components/admin/sponsor/SponsorTierEditor.stories.tsx
new file mode 100644
index 00000000..ea25d6a2
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorTierEditor.stories.tsx
@@ -0,0 +1,428 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { useState } from 'react'
+import {
+ PlusIcon,
+ PencilIcon,
+ TrashIcon,
+ XMarkIcon,
+ StarIcon,
+ CheckIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Tiers/SponsorTierEditor',
+ parameters: {
+ layout: 'centered',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+interface TierPerk {
+ label: string
+ description: string
+}
+
+const mockPerks: TierPerk[] = [
+ { label: 'Keynote slot', description: '30-minute keynote presentation' },
+ { label: 'Premium booth', description: 'Large corner booth location' },
+ { label: 'Logo placement', description: 'Logo on all marketing materials' },
+ { label: 'Attendee list', description: 'Access to attendee contact list' },
+]
+
+function TierEditorModal() {
+ const [formData, setFormData] = useState({
+ title: 'Platinum',
+ tagline: 'Premium sponsorship with maximum visibility',
+ tierType: 'standard' as 'standard' | 'community' | 'media',
+ price: 100000,
+ currency: 'NOK',
+ maxQuantity: 3,
+ soldOut: false,
+ mostPopular: true,
+ perks: mockPerks,
+ })
+
+ return (
+
+ {/* Header */}
+
+
+ Edit Sponsor Tier
+
+
+
+
+
+
+
+ {/* Basic Info */}
+
+
+
+ Tier Name
+
+
+ setFormData({ ...formData, title: e.target.value })
+ }
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ />
+
+
+
+ Tier Type
+
+
+ setFormData({
+ ...formData,
+ tierType: e.target.value as
+ | 'standard'
+ | 'community'
+ | 'media',
+ })
+ }
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ >
+ Standard
+ Community
+ Media Partner
+
+
+
+
+
+
+ Tagline
+
+
+ setFormData({ ...formData, tagline: e.target.value })
+ }
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ placeholder="Short description for the tier"
+ />
+
+
+ {/* Pricing */}
+
+
+
+ Price
+
+
+
+ setFormData({ ...formData, price: Number(e.target.value) })
+ }
+ className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ />
+
+ setFormData({ ...formData, currency: e.target.value })
+ }
+ className="w-24 rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ >
+ NOK
+ EUR
+ USD
+
+
+
+
+
+ Max Quantity
+
+
+ setFormData({
+ ...formData,
+ maxQuantity: Number(e.target.value),
+ })
+ }
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
+ placeholder="Unlimited"
+ />
+
+
+
+ {/* Flags */}
+
+
+
+ setFormData({ ...formData, mostPopular: e.target.checked })
+ }
+ className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
+ />
+
+
+ Most Popular
+
+
+
+
+ setFormData({ ...formData, soldOut: e.target.checked })
+ }
+ className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
+ />
+
+ Sold Out
+
+
+
+
+ {/* Perks */}
+
+
+
+ Perks
+
+
+
+ Add Perk
+
+
+
+ {formData.perks.map((perk, idx) => (
+
+ ))}
+
+
+
+
+ {/* Footer */}
+
+
+
+ Delete Tier
+
+
+
+ Cancel
+
+
+ Save Changes
+
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+}
+
+export const TierCards: Story = {
+ render: () => {
+ const tiers = [
+ {
+ title: 'Platinum',
+ price: 100000,
+ mostPopular: true,
+ soldOut: false,
+ count: 2,
+ max: 3,
+ },
+ {
+ title: 'Gold',
+ price: 50000,
+ mostPopular: false,
+ soldOut: false,
+ count: 4,
+ max: 6,
+ },
+ {
+ title: 'Silver',
+ price: 25000,
+ mostPopular: false,
+ soldOut: true,
+ count: 8,
+ max: 8,
+ },
+ {
+ title: 'Community',
+ price: 0,
+ mostPopular: false,
+ soldOut: false,
+ count: 3,
+ max: undefined,
+ },
+ ]
+
+ return (
+
+
+ Tier Management
+
+
+ {tiers.map((tier) => (
+
+
+
+
+ {tier.title}
+
+ {tier.mostPopular && (
+
+ )}
+ {tier.soldOut && (
+
+ Sold Out
+
+ )}
+
+
+ {tier.price > 0
+ ? new Intl.NumberFormat('nb-NO', {
+ style: 'currency',
+ currency: 'NOK',
+ maximumFractionDigits: 0,
+ }).format(tier.price)
+ : 'Free'}
+ {tier.max && (
+
+ • {tier.count}/{tier.max} sold
+
+ )}
+
+
+
+
+
+
+ ))}
+
+
+ Add New Tier
+
+
+
+ )
+ },
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorTierEditor
+
+
+ Modal form for creating and editing sponsor tier definitions. Includes
+ pricing, quantity limits, perks, and display flags.
+
+
+
+
+
Props
+
+
+
+ isOpen
+ {' '}
+ - Whether modal is visible
+
+
+
+ onClose
+ {' '}
+ - Callback when modal closes
+
+
+
+ tier
+ {' '}
+ - Existing tier to edit (optional)
+
+
+
+ conferenceId
+ {' '}
+ - Target conference
+
+
+
+ onSave
+ {' '}
+ - Callback with saved tier
+
+
+
+ onDelete
+ {' '}
+ - Callback when tier is deleted
+
+
+
+
+
+
+ Tier Types
+
+
+
+ Standard - Commercial sponsor tiers (Platinum,
+ Gold, etc.)
+
+
+ Community - Free/reduced tiers for community
+ projects
+
+
+ Media - Media partner arrangements
+
+
+
+
+ ),
+}
diff --git a/src/components/admin/sponsor/SponsorTierManagement.stories.tsx b/src/components/admin/sponsor/SponsorTierManagement.stories.tsx
new file mode 100644
index 00000000..3a2532af
--- /dev/null
+++ b/src/components/admin/sponsor/SponsorTierManagement.stories.tsx
@@ -0,0 +1,339 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ PlusIcon,
+ TrashIcon,
+ PencilIcon,
+ ArrowDownTrayIcon,
+ GlobeAltIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Tiers/SponsorTierManagement',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+interface MockSponsor {
+ _id: string
+ sponsor: {
+ name: string
+ website?: string
+ logo: string | null
+ }
+ tier: { title: string }
+}
+
+interface MockTier {
+ _id: string
+ title: string
+ value: number
+ perks?: string[]
+}
+
+const mockTiers: MockTier[] = [
+ {
+ _id: 'tier-1',
+ title: 'Platinum',
+ value: 100000,
+ perks: ['Keynote slot', 'Premium booth', 'Logo on all materials'],
+ },
+ {
+ _id: 'tier-2',
+ title: 'Gold',
+ value: 50000,
+ perks: ['Workshop slot', 'Standard booth', 'Logo on website'],
+ },
+ {
+ _id: 'tier-3',
+ title: 'Silver',
+ value: 25000,
+ perks: ['Logo on website', 'Social media mention'],
+ },
+ { _id: 'tier-4', title: 'Bronze', value: 10000, perks: ['Logo on website'] },
+]
+
+const mockSponsors: MockSponsor[] = [
+ {
+ _id: 'sfc-1',
+ sponsor: {
+ name: 'TechGiant Corp',
+ website: 'https://techgiant.com',
+ logo: null,
+ },
+ tier: { title: 'Platinum' },
+ },
+ {
+ _id: 'sfc-2',
+ sponsor: {
+ name: 'CloudPro Inc',
+ website: 'https://cloudpro.io',
+ logo: null,
+ },
+ tier: { title: 'Gold' },
+ },
+ {
+ _id: 'sfc-3',
+ sponsor: { name: 'DataSys', website: 'https://datasys.no', logo: null },
+ tier: { title: 'Gold' },
+ },
+ {
+ _id: 'sfc-4',
+ sponsor: { name: 'StartupX', website: 'https://startupx.com', logo: null },
+ tier: { title: 'Silver' },
+ },
+ {
+ _id: 'sfc-5',
+ sponsor: {
+ name: 'DevTools Ltd',
+ website: 'https://devtools.io',
+ logo: null,
+ },
+ tier: { title: 'Silver' },
+ },
+ {
+ _id: 'sfc-6',
+ sponsor: { name: 'LocalBusiness', website: 'https://local.no', logo: null },
+ tier: { title: 'Bronze' },
+ },
+]
+
+function getTierColor(tier: string) {
+ switch (tier.toLowerCase()) {
+ case 'platinum':
+ return 'border-purple-300 bg-purple-50 dark:border-purple-700 dark:bg-purple-900/20'
+ case 'gold':
+ return 'border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/20'
+ case 'silver':
+ return 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800'
+ case 'bronze':
+ return 'border-orange-300 bg-orange-50 dark:border-orange-700 dark:bg-orange-900/20'
+ default:
+ return 'border-gray-300 bg-gray-50 dark:border-gray-600 dark:bg-gray-800'
+ }
+}
+
+function getTierBadgeColor(tier: string) {
+ switch (tier.toLowerCase()) {
+ case 'platinum':
+ return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
+ case 'gold':
+ return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
+ case 'silver':
+ return 'bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
+ case 'bronze':
+ return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'
+ default:
+ return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
+ }
+}
+
+function formatCurrency(value: number) {
+ return new Intl.NumberFormat('nb-NO', {
+ style: 'currency',
+ currency: 'NOK',
+ maximumFractionDigits: 0,
+ }).format(value)
+}
+
+function SponsorCard({ sponsor }: { sponsor: MockSponsor }) {
+ return (
+
+
+
+ {sponsor.sponsor.name.slice(0, 2).toUpperCase()}
+
+
+
+
+
+ )
+}
+
+export const Default: Story = {
+ render: () => (
+
+
+ {mockTiers.map((tier) => {
+ const tierSponsors = mockSponsors.filter(
+ (s) => s.tier.title === tier.title,
+ )
+ return (
+
+
+
+
+ {tier.title}
+
+
+ {formatCurrency(tier.value)}
+
+
+ • {tierSponsors.length} sponsor
+ {tierSponsors.length !== 1 ? 's' : ''}
+
+
+
+
+ Add Sponsor
+
+
+
+ {tierSponsors.length > 0 ? (
+
+ {tierSponsors.map((sponsor) => (
+
+ ))}
+
+ ) : (
+
+
+ No sponsors in this tier yet
+
+
+ )}
+
+ )
+ })}
+
+
+ ),
+}
+
+export const SingleTier: Story = {
+ render: () => {
+ const tier = mockTiers[1] // Gold
+ const tierSponsors = mockSponsors.filter((s) => s.tier.title === tier.title)
+
+ return (
+
+
+
+
+
+ {tier.title}
+
+
+ {formatCurrency(tier.value)}
+
+
+
+
+ Add Sponsor
+
+
+
+
+ {tierSponsors.map((sponsor) => (
+
+ ))}
+
+
+
+ )
+ },
+}
+
+export const Documentation: Story = {
+ render: () => (
+
+
+
+ SponsorTierManagement
+
+
+ Displays sponsors organized by tier with add, edit, and remove
+ functionality. Supports drag-and-drop tier reassignment (not shown in
+ static demo).
+
+
+
+
+
Props
+
+
+
+ conferenceId
+ {' '}
+ - Conference to manage sponsors for
+
+
+
+ sponsors
+ {' '}
+ - Array of ConferenceSponsor objects
+
+
+
+ sponsorTiers
+ {' '}
+ - Available tier definitions
+
+
+
+ sponsorsByTier
+ {' '}
+ - Pre-grouped sponsors by tier name
+
+
+
+ sortedTierNames
+ {' '}
+ - Tier names sorted by value (highest first)
+
+
+
+
+
+
+ Features
+
+
+ • Tier sections sorted by value (highest first)
+ • Color-coded tier badges and backgrounds
+ • Add sponsor to specific tier via modal
+ • Edit existing sponsor assignments
+ • Download sponsor logos (SVG)
+ • Remove sponsor from conference
+ • Real-time state updates
+
+
+
+ ),
+}
diff --git a/src/components/branding/BrandingExampleHeroSection.tsx b/src/components/branding/BrandingExampleHeroSection.tsx
deleted file mode 100644
index 63d4f170..00000000
--- a/src/components/branding/BrandingExampleHeroSection.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-'use client'
-
-import { CloudNativePattern } from '@/components/CloudNativePattern'
-import { Button } from '@/components/Button'
-import { Conference } from '@/lib/conference/types'
-import { formatDatesSafe } from '@/lib/time'
-
-interface BrandingExampleHeroSectionProps {
- conference?: Conference
-}
-
-export function BrandingExampleHeroSection({
- conference,
-}: BrandingExampleHeroSectionProps) {
- const title = conference?.title || 'Cloud Native Days'
- const dateText = conference
- ? formatDatesSafe(conference.startDate, conference.endDate)
- : 'Coming Soon'
- const location =
- conference?.city && conference?.country
- ? `${conference.city}, ${conference.country}`
- : 'Location TBA'
-
- return (
-
-
-
-
-
- {title}
-
-
- {dateText} • {location}
-
-
- Join the Nordic cloud native community for a day of cutting-edge
- talks, hands-on workshops, and meaningful connections.
-
-
-
- Register Now
-
-
- Submit a Talk
-
-
-
-
- )
-}
diff --git a/src/components/branding/BrandingHeroSection.tsx b/src/components/branding/BrandingHeroSection.tsx
deleted file mode 100644
index 24ca825c..00000000
--- a/src/components/branding/BrandingHeroSection.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { CloudNativePattern } from '@/components/CloudNativePattern'
-import { Container } from '@/components/Container'
-import { Conference } from '@/lib/conference/types'
-
-interface BrandingHeroSectionProps {
- conference?: Conference
-}
-
-export function BrandingHeroSection({ conference }: BrandingHeroSectionProps) {
- const title = conference?.title || 'Cloud Native Days'
-
- return (
-
-
-
-
-
-
- {title}
-
-
- Brand Guidelines & Design System
-
-
- Our brand reflects the spirit of the cloud native community:
- innovative, open, collaborative, and forward-thinking. These
- guidelines ensure consistent and impactful communication across all
- touchpoints.
-
-
-
-
- )
-}
diff --git a/src/components/branding/ExpandableEmailTemplate.tsx b/src/components/branding/ExpandableEmailTemplate.tsx
deleted file mode 100644
index 73017cf6..00000000
--- a/src/components/branding/ExpandableEmailTemplate.tsx
+++ /dev/null
@@ -1,114 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'
-
-interface ExpandableEmailTemplateProps {
- children: React.ReactNode
- previewHeight?: number
- title: string
- description: string
- className?: string
-
- emailFrom: string
- emailTo: string
- emailSubject: string
- emailTime: string
-}
-
-export default function ExpandableEmailTemplate({
- children,
- previewHeight = 400,
- title,
- description,
- className = '',
- emailFrom,
- emailTo,
- emailSubject,
- emailTime,
-}: ExpandableEmailTemplateProps) {
- const [isExpanded, setIsExpanded] = useState(false)
-
- return (
-
-
{title}
-
- {description}
-
-
-
-
-
-
-
-
-
-
- From:
- {emailFrom}
-
-
{emailTime}
-
-
- To:
- {emailTo}
-
-
-
{emailSubject}
-
-
-
-
{children}
-
-
-
- {!isExpanded && (
-
-
-
-
- setIsExpanded(true)}
- className="group flex items-center space-x-2 rounded-lg bg-white px-4 py-2 text-sm font-medium text-brand-cloud-blue shadow-md transition-all duration-300 hover:scale-105 hover:bg-brand-cloud-blue hover:text-white hover:shadow-lg"
- >
- View Full Template
-
-
-
-
- )}
-
- {isExpanded && (
-
- setIsExpanded(false)}
- className="group flex items-center space-x-2 rounded-lg bg-white px-4 py-2 text-sm font-medium text-brand-cloud-blue shadow-md transition-all duration-300 hover:scale-105 hover:bg-brand-cloud-blue hover:text-white hover:shadow-lg"
- >
- Collapse Template
-
-
-
- )}
-
-
- )
-}
diff --git a/src/components/branding/IconShowcase.tsx b/src/components/branding/IconShowcase.tsx
deleted file mode 100644
index efd2e100..00000000
--- a/src/components/branding/IconShowcase.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-interface IconShowcaseProps {
- name: string
- description: string
- component: React.ReactNode
- usage: string
-}
-
-export function IconShowcase({
- name,
- description,
- component,
- usage,
-}: IconShowcaseProps) {
- return (
-
-
{component}
-
-
- {name}
-
-
-
- {description}
-
-
-
-
- Best for:
-
-
- {usage}
-
-
-
- )
-}
diff --git a/src/components/branding/InteractivePatternPreview.tsx b/src/components/branding/InteractivePatternPreview.tsx
deleted file mode 100644
index aa078d66..00000000
--- a/src/components/branding/InteractivePatternPreview.tsx
+++ /dev/null
@@ -1,246 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { CloudNativePattern } from '@/components/CloudNativePattern'
-
-export function InteractivePatternPreview() {
- const [opacity, setOpacity] = useState(0.15)
- const [animated, setAnimated] = useState(true)
- const [variant, setVariant] = useState<'dark' | 'light' | 'brand'>('brand')
- const [baseSize, setBaseSize] = useState(45)
- const [iconCount, setIconCount] = useState(50)
-
- const minDisplaySize = Math.round(baseSize * 0.5)
- const maxDisplaySize = Math.round(baseSize * 1.6)
-
- return (
-
-
-
-
-
-
-
- Cloud Native Elements
-
-
- Opacity: {opacity.toFixed(2)} • Base Size: {baseSize}px • Range:{' '}
- {minDisplaySize}-{maxDisplaySize}px • {iconCount} icons
-
-
-
-
-
-
-
- Pattern Controls
-
-
-
-
-
- Variant
-
-
- setVariant(e.target.value as 'dark' | 'light' | 'brand')
- }
- className="w-full rounded-lg border border-brand-frosted-steel bg-white px-3 py-2 focus:border-transparent focus:ring-2 focus:ring-brand-cloud-blue dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:focus:ring-blue-400"
- >
- Brand
- Dark
- Light
-
-
-
-
-
- Animation
-
-
- setAnimated(e.target.checked)}
- className="sr-only"
- />
-
-
- {animated ? 'Enabled' : 'Disabled'}
-
-
-
-
-
-
-
-
- Opacity: {opacity.toFixed(2)}
-
-
setOpacity(parseFloat(e.target.value))}
- className="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-brand-frosted-steel dark:bg-gray-600"
- />
-
- 0.05
- 0.30
-
-
-
-
-
- Base Size: {baseSize}px
-
-
setBaseSize(parseInt(e.target.value))}
- className="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-brand-frosted-steel dark:bg-gray-600"
- />
-
- 20px
- 100px
-
-
- Range: {minDisplaySize}-{maxDisplaySize}px
-
-
-
-
-
- Icon Count: {iconCount}
-
-
setIconCount(parseInt(e.target.value))}
- className="slider h-2 w-full cursor-pointer appearance-none rounded-lg bg-brand-frosted-steel dark:bg-gray-600"
- />
-
- 10
- 200
-
-
-
-
-
-
-
- Current Configuration
-
-
-
opacity={opacity}
-
variant="{variant}"
-
animated={animated.toString()}
-
baseSize={baseSize}
-
iconCount={iconCount}
-
-
-
-
-
- Quick Presets
-
-
- {
- setOpacity(0.08)
- setVariant('light')
- setBaseSize(25)
- setIconCount(18)
- setAnimated(true)
- }}
- className="font-inter rounded-md border border-brand-frosted-steel bg-white px-3 py-1 text-xs text-brand-slate-gray transition-colors hover:bg-brand-glacier-white dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
- >
- Content Background
-
- {
- setOpacity(0.15)
- setVariant('brand')
- setBaseSize(52)
- setIconCount(38)
- setAnimated(true)
- }}
- className="font-inter rounded-md border border-brand-frosted-steel bg-white px-3 py-1 text-xs text-brand-slate-gray transition-colors hover:bg-brand-glacier-white dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
- >
- Hero Section
-
- {
- setOpacity(0.2)
- setVariant('dark')
- setBaseSize(58)
- setIconCount(55)
- setAnimated(true)
- }}
- className="font-inter rounded-md border border-brand-frosted-steel bg-white px-3 py-1 text-xs text-brand-slate-gray transition-colors hover:bg-brand-glacier-white dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
- >
- Dramatic Background
-
- {
- setOpacity(0.06)
- setVariant('light')
- setBaseSize(23)
- setIconCount(22)
- setAnimated(false)
- }}
- className="font-inter rounded-md border border-brand-frosted-steel bg-white px-3 py-1 text-xs text-brand-slate-gray transition-colors hover:bg-brand-glacier-white dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
- >
- Subtle Static
-
-
-
-
-
-
- )
-}
diff --git a/src/components/branding/PatternExample.tsx b/src/components/branding/PatternExample.tsx
deleted file mode 100644
index d4f22300..00000000
--- a/src/components/branding/PatternExample.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-'use client'
-
-import { CloudNativePattern } from '@/components/CloudNativePattern'
-
-interface PatternExampleProps {
- title: string
- description: string
- opacity: number
- variant: 'dark' | 'light' | 'brand'
- baseSize: number
- iconCount: number
- animated?: boolean
- className?: string
-}
-
-export function PatternExample({
- title,
- description,
- opacity,
- variant,
- baseSize,
- iconCount,
- animated = true,
- className = 'h-64',
-}: PatternExampleProps) {
- const isLight = variant === 'light'
- const backgroundClass = isLight
- ? 'border-2 border-brand-frosted-steel bg-white'
- : variant === 'dark'
- ? 'bg-linear-to-br from-slate-900 to-blue-900'
- : 'bg-brand-gradient'
-
- return (
-
-
- {title}
-
-
-
- {!isLight &&
}
-
-
-
- {title}
-
-
- {description}
-
-
- {Math.round(baseSize * 0.5)}-{Math.round(baseSize * 1.6)}px •{' '}
- {iconCount} icons • {variant} variant
-
-
-
-
-
- )
-}
diff --git a/src/components/branding/index.ts b/src/components/branding/index.ts
deleted file mode 100644
index b17d116a..00000000
--- a/src/components/branding/index.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export { ColorSwatch } from './ColorSwatch'
-export { TypographyShowcase } from './TypographyShowcase'
-export { IconShowcase } from './IconShowcase'
-export { InteractivePatternPreview } from './InteractivePatternPreview'
-export { BrandingHeroSection } from './BrandingHeroSection'
-export { BrandingExampleHeroSection } from './BrandingExampleHeroSection'
-export { PatternExample } from './PatternExample'
-export { ButtonShowcase } from './ButtonShowcase'
-export { DownloadSpeakerImage } from './DownloadSpeakerImage'
-export { default as ExpandableEmailTemplate } from './ExpandableEmailTemplate'
diff --git a/src/components/cfp/CompactProposalList.stories.tsx b/src/components/cfp/CompactProposalList.stories.tsx
new file mode 100644
index 00000000..bd57fef8
--- /dev/null
+++ b/src/components/cfp/CompactProposalList.stories.tsx
@@ -0,0 +1,349 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { CompactProposalList } from './CompactProposalList'
+import {
+ ProposalExisting,
+ Format,
+ Language,
+ Level,
+ Audience,
+ Status,
+} from '@/lib/proposal/types'
+import { Flags, Speaker } from '@/lib/speaker/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead',
+ flags: [Flags.firstTimeSpeaker],
+ },
+]
+
+const createMockProposal = (
+ id: string,
+ title: string,
+ status: Status,
+ format: Format = Format.presentation_45,
+ overrides: Partial = {},
+): ProposalExisting => ({
+ _id: id,
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title,
+ description: convertStringToPortableTextBlocks('Test description'),
+ language: Language.english,
+ format,
+ level: Level.intermediate,
+ audiences: [Audience.developer],
+ status,
+ outline: '',
+ topics: [],
+ tos: true,
+ speakers: [mockSpeakers[0]],
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ ...overrides,
+})
+
+const mixedStatusProposals: ProposalExisting[] = [
+ createMockProposal(
+ 'talk-1',
+ 'Building Scalable Microservices with Kubernetes',
+ Status.confirmed,
+ ),
+ createMockProposal(
+ 'talk-2',
+ 'Introduction to GitOps',
+ Status.accepted,
+ Format.lightning_10,
+ ),
+ createMockProposal(
+ 'talk-3',
+ 'Advanced CI/CD Patterns',
+ Status.submitted,
+ Format.presentation_20,
+ ),
+ createMockProposal(
+ 'talk-4',
+ 'Service Mesh Deep Dive',
+ Status.rejected,
+ Format.presentation_45,
+ ),
+]
+
+const allSubmittedProposals: ProposalExisting[] = [
+ createMockProposal(
+ 'talk-1',
+ 'Cloud Native Security Best Practices',
+ Status.submitted,
+ ),
+ createMockProposal(
+ 'talk-2',
+ 'Observability for Kubernetes',
+ Status.submitted,
+ Format.lightning_10,
+ ),
+ createMockProposal(
+ 'talk-3',
+ 'Hands-on Helm Workshop',
+ Status.submitted,
+ Format.workshop_120,
+ ),
+]
+
+const meta = {
+ title: 'Systems/Proposals/CompactProposalList',
+ component: CompactProposalList,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'A compact list of proposals showing title, format icon, speaker avatars, status badge, and optional indicators for video, feedback, and missing attachments. Sorted by status priority (confirmed → accepted → submitted → draft → rejected → withdrawn).',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ proposals: mixedStatusProposals,
+ canEdit: true,
+ },
+}
+
+export const ReadOnly: Story = {
+ args: {
+ proposals: mixedStatusProposals,
+ canEdit: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Without edit capability - no pencil icon shown.',
+ },
+ },
+ },
+}
+
+export const AllSubmitted: Story = {
+ args: {
+ proposals: allSubmittedProposals,
+ canEdit: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'All proposals in submitted status awaiting review.',
+ },
+ },
+ },
+}
+
+export const AllRejected: Story = {
+ args: {
+ proposals: [
+ createMockProposal('talk-1', 'Proposal One', Status.rejected),
+ createMockProposal('talk-2', 'Proposal Two', Status.rejected),
+ createMockProposal('talk-3', 'Proposal Three', Status.withdrawn),
+ ],
+ canEdit: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'All proposals rejected/withdrawn - shows red badges without approved talk context.',
+ },
+ },
+ },
+}
+
+export const WithApprovedAndRejected: Story = {
+ args: {
+ proposals: [
+ createMockProposal('talk-1', 'Accepted Talk', Status.confirmed),
+ createMockProposal('talk-2', 'Another Proposal', Status.rejected),
+ createMockProposal('talk-3', 'Third Proposal', Status.submitted),
+ ],
+ canEdit: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'When speaker has an approved talk, rejected ones show "Not selected" with muted styling.',
+ },
+ },
+ },
+}
+
+export const SingleProposal: Story = {
+ args: {
+ proposals: [
+ createMockProposal(
+ 'talk-1',
+ 'My Conference Talk',
+ Status.submitted,
+ Format.presentation_45,
+ ),
+ ],
+ canEdit: true,
+ },
+}
+
+export const WithVideo: Story = {
+ args: {
+ proposals: [
+ createMockProposal(
+ 'talk-1',
+ 'Talk with Video Recording',
+ Status.confirmed,
+ Format.presentation_45,
+ {
+ attachments: [
+ {
+ _key: 'video-1',
+ _type: 'urlAttachment',
+ attachmentType: 'recording',
+ url: 'https://youtube.com/watch?v=abc123',
+ },
+ ],
+ },
+ ),
+ ],
+ canEdit: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Proposal with video recording shows purple Video badge.',
+ },
+ },
+ },
+}
+
+export const ConferenceEnded: Story = {
+ args: {
+ proposals: [
+ createMockProposal(
+ 'talk-1',
+ 'Past Conference Talk',
+ Status.confirmed,
+ Format.presentation_45,
+ {
+ audienceFeedback: {
+ greenCount: 42,
+ yellowCount: 15,
+ redCount: 3,
+ },
+ },
+ ),
+ createMockProposal(
+ 'talk-2',
+ 'Talk Without Slides',
+ Status.accepted,
+ Format.lightning_10,
+ ),
+ ],
+ canEdit: false,
+ conferenceHasEnded: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'After conference ends: shows audience feedback count and warning for missing slides.',
+ },
+ },
+ },
+}
+
+export const MultiSpeaker: Story = {
+ args: {
+ proposals: [
+ createMockProposal(
+ 'talk-1',
+ 'Joint Presentation on Platform Engineering',
+ Status.confirmed,
+ Format.presentation_45,
+ {
+ speakers: mockSpeakers,
+ },
+ ),
+ ],
+ canEdit: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Proposal with multiple speakers shows stacked avatars.',
+ },
+ },
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ proposals: [],
+ canEdit: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Empty state when speaker has no proposals.',
+ },
+ },
+ },
+}
+
+export const DraftProposals: Story = {
+ args: {
+ proposals: [
+ createMockProposal('talk-1', 'Draft Talk Idea', Status.draft),
+ createMockProposal(
+ 'talk-2',
+ 'Another Work in Progress',
+ Status.draft,
+ Format.workshop_120,
+ ),
+ ],
+ canEdit: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Proposals in draft status not yet submitted.',
+ },
+ },
+ },
+}
diff --git a/src/components/cfp/ProposalCoSpeaker.stories.tsx b/src/components/cfp/ProposalCoSpeaker.stories.tsx
new file mode 100644
index 00000000..6e54fb97
--- /dev/null
+++ b/src/components/cfp/ProposalCoSpeaker.stories.tsx
@@ -0,0 +1,204 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalCoSpeaker } from './ProposalCoSpeaker'
+import { fn } from 'storybook/test'
+import { http, HttpResponse } from 'msw'
+import { Format } from '@/lib/proposal/types'
+import { Speaker } from '@/lib/speaker/types'
+import { CoSpeakerInvitationMinimal } from '@/lib/cospeaker/types'
+
+const createMockSpeaker = (id: string, name: string, email: string): Speaker =>
+ ({
+ _id: id,
+ _rev: 'rev1',
+ _createdAt: '2025-01-01T00:00:00Z',
+ _updatedAt: '2025-01-01T00:00:00Z',
+ name,
+ email,
+ title: 'Engineer at TechCorp',
+ slug: name.toLowerCase().replace(/\s+/g, '-'),
+ }) as Speaker
+
+const mockCoSpeakers: Speaker[] = [
+ createMockSpeaker('speaker-2', 'Erik Larsen', 'erik@techcorp.no'),
+]
+
+const mockPendingInvitations: CoSpeakerInvitationMinimal[] = [
+ {
+ _id: 'inv-1',
+ invitedEmail: 'sofia@example.com',
+ invitedName: 'Sofia Berg',
+ status: 'pending',
+ token: 'token123',
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
+ },
+]
+
+const mixedInvitations: CoSpeakerInvitationMinimal[] = [
+ {
+ _id: 'inv-1',
+ invitedEmail: 'sofia@example.com',
+ invitedName: 'Sofia Berg',
+ status: 'pending',
+ token: 'token123',
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
+ },
+ {
+ _id: 'inv-2',
+ invitedEmail: 'magnus@example.com',
+ invitedName: 'Magnus Olsen',
+ status: 'declined',
+ token: 'token456',
+ expiresAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
+ declineReason: 'Schedule conflict',
+ },
+ {
+ _id: 'inv-3',
+ invitedEmail: 'ingrid@example.com',
+ invitedName: 'Ingrid Nilsen',
+ status: 'expired',
+ token: 'token789',
+ expiresAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(),
+ },
+]
+
+const handlers = [
+ http.post('/cfp/api/invitations/send', () => {
+ return HttpResponse.json({
+ success: true,
+ invitation: {
+ _id: 'new-inv',
+ invitedEmail: 'newco@example.com',
+ status: 'pending',
+ token: 'newtoken',
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
+ },
+ })
+ }),
+ http.post('/cfp/api/invitations/cancel', () => {
+ return HttpResponse.json({ success: true })
+ }),
+]
+
+const meta: Meta = {
+ title: 'Systems/Proposals/ProposalCoSpeaker',
+ component: ProposalCoSpeaker,
+ tags: ['autodocs'],
+ parameters: {
+ docs: {
+ description: {
+ component:
+ 'Component for managing co-speakers on a proposal. Allows inviting co-speakers via email, viewing pending invitations, and removing existing co-speakers. Respects format-specific speaker limits.',
+ },
+ },
+ msw: { handlers },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const EmptyPresentation: Story = {
+ args: {
+ selectedSpeakers: [],
+ onSpeakersChange: fn(),
+ format: Format.presentation_45,
+ proposalId: 'proposal-123',
+ pendingInvitations: [],
+ onInvitationSent: fn(),
+ onInvitationCanceled: fn(),
+ },
+}
+
+export const WithCoSpeakers: Story = {
+ args: {
+ selectedSpeakers: mockCoSpeakers,
+ onSpeakersChange: fn(),
+ format: Format.presentation_45,
+ proposalId: 'proposal-123',
+ pendingInvitations: [],
+ onInvitationSent: fn(),
+ onInvitationCanceled: fn(),
+ },
+}
+
+export const WithPendingInvitation: Story = {
+ args: {
+ selectedSpeakers: [],
+ onSpeakersChange: fn(),
+ format: Format.presentation_25,
+ proposalId: 'proposal-123',
+ pendingInvitations: mockPendingInvitations,
+ onInvitationSent: fn(),
+ onInvitationCanceled: fn(),
+ },
+}
+
+export const MixedInvitationStatuses: Story = {
+ args: {
+ selectedSpeakers: mockCoSpeakers,
+ onSpeakersChange: fn(),
+ format: Format.workshop_120,
+ proposalId: 'proposal-123',
+ pendingInvitations: mixedInvitations,
+ onInvitationSent: fn(),
+ onInvitationCanceled: fn(),
+ },
+}
+
+export const LightningTalk: Story = {
+ args: {
+ selectedSpeakers: [],
+ onSpeakersChange: fn(),
+ format: Format.lightning_10,
+ proposalId: 'proposal-123',
+ pendingInvitations: [],
+ onInvitationSent: fn(),
+ onInvitationCanceled: fn(),
+ },
+}
+
+export const Workshop: Story = {
+ args: {
+ selectedSpeakers: mockCoSpeakers,
+ onSpeakersChange: fn(),
+ format: Format.workshop_240,
+ proposalId: 'proposal-123',
+ pendingInvitations: mockPendingInvitations,
+ onInvitationSent: fn(),
+ onInvitationCanceled: fn(),
+ },
+}
+
+export const MaxCoSpeakersReached: Story = {
+ args: {
+ selectedSpeakers: [
+ createMockSpeaker('speaker-2', 'Erik Larsen', 'erik@techcorp.no'),
+ createMockSpeaker('speaker-3', 'Sofia Berg', 'sofia@devops.io'),
+ ],
+ onSpeakersChange: fn(),
+ format: Format.presentation_25,
+ proposalId: 'proposal-123',
+ pendingInvitations: [],
+ onInvitationSent: fn(),
+ onInvitationCanceled: fn(),
+ },
+}
+
+export const NoProposalId: Story = {
+ args: {
+ selectedSpeakers: [],
+ onSpeakersChange: fn(),
+ format: Format.presentation_45,
+ proposalId: undefined,
+ pendingInvitations: [],
+ onInvitationSent: fn(),
+ onInvitationCanceled: fn(),
+ },
+}
diff --git a/src/components/cfp/ProposalGuidanceSidebar.stories.tsx b/src/components/cfp/ProposalGuidanceSidebar.stories.tsx
new file mode 100644
index 00000000..46437383
--- /dev/null
+++ b/src/components/cfp/ProposalGuidanceSidebar.stories.tsx
@@ -0,0 +1,200 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProposalGuidanceSidebar } from './ProposalGuidanceSidebar'
+import { Conference } from '@/lib/conference/types'
+import { Format } from '@/lib/proposal/types'
+import { Topic } from '@/lib/topic/types'
+
+const mockTopics: Topic[] = [
+ {
+ _id: 'topic-1',
+ _type: 'topic',
+ title: 'Kubernetes',
+ color: '#326CE5',
+ slug: { current: 'kubernetes' },
+ },
+ {
+ _id: 'topic-2',
+ _type: 'topic',
+ title: 'Cloud Native',
+ color: '#0091DA',
+ slug: { current: 'cloud-native' },
+ },
+ {
+ _id: 'topic-3',
+ _type: 'topic',
+ title: 'DevOps',
+ color: '#FF6B6B',
+ slug: { current: 'devops' },
+ },
+ {
+ _id: 'topic-4',
+ _type: 'topic',
+ title: 'Security',
+ color: '#4ECDC4',
+ slug: { current: 'security' },
+ },
+ {
+ _id: 'topic-5',
+ _type: 'topic',
+ title: 'Observability',
+ color: '#FFE66D',
+ slug: { current: 'observability' },
+ },
+ {
+ _id: 'topic-6',
+ _type: 'topic',
+ title: 'Platform Engineering',
+ color: '#95D5B2',
+ slug: { current: 'platform-engineering' },
+ },
+]
+
+const createMockConference = (
+ overrides: Partial = {},
+): Conference => ({
+ _id: 'conf-2025',
+ title: 'Cloud Native Day Bergen 2025',
+ organizer: 'Cloud Native Bergen',
+ city: 'Bergen',
+ country: 'Norway',
+ startDate: '2025-09-18',
+ endDate: '2025-09-18',
+ cfpStartDate: '2025-03-01',
+ cfpEndDate: '2025-06-15',
+ cfpNotifyDate: '2025-07-01',
+ cfpEmail: 'cfp@cloudnativeday.no',
+ sponsorEmail: 'sponsor@cloudnativeday.no',
+ programDate: '2025-07-15',
+ contactEmail: 'info@cloudnativeday.no',
+ registrationEnabled: true,
+ organizers: [],
+ domains: ['cloudnativeday.no'],
+ formats: [
+ Format.lightning_10,
+ Format.presentation_25,
+ Format.presentation_45,
+ Format.workshop_120,
+ ],
+ topics: mockTopics,
+ ...overrides,
+})
+
+const meta = {
+ title: 'Systems/Proposals/ProposalGuidanceSidebar',
+ component: ProposalGuidanceSidebar,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'A sidebar component displayed alongside the proposal form, showing important CFP dates, submission tips, accepted formats, and topics of interest.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ conference: createMockConference(),
+ },
+}
+
+export const MultiDayConference: Story = {
+ args: {
+ conference: createMockConference({
+ title: 'Cloud Native Days Norway 2025',
+ startDate: '2025-09-18',
+ endDate: '2025-09-19',
+ }),
+ },
+}
+
+export const ManyTopics: Story = {
+ args: {
+ conference: createMockConference({
+ topics: [
+ ...mockTopics,
+ {
+ _id: 'topic-7',
+ _type: 'topic',
+ title: 'GitOps',
+ color: '#FC5185',
+ slug: { current: 'gitops' },
+ },
+ {
+ _id: 'topic-8',
+ _type: 'topic',
+ title: 'Service Mesh',
+ color: '#3FC1C9',
+ slug: { current: 'service-mesh' },
+ },
+ {
+ _id: 'topic-9',
+ _type: 'topic',
+ title: 'Serverless',
+ color: '#F5B461',
+ slug: { current: 'serverless' },
+ },
+ {
+ _id: 'topic-10',
+ _type: 'topic',
+ title: 'AI/ML',
+ color: '#A855F7',
+ slug: { current: 'ai-ml' },
+ },
+ ],
+ }),
+ },
+}
+
+export const NoTopics: Story = {
+ args: {
+ conference: createMockConference({
+ topics: [],
+ }),
+ },
+}
+
+export const MinimalFormats: Story = {
+ args: {
+ conference: createMockConference({
+ formats: [Format.presentation_25],
+ topics: mockTopics.slice(0, 3),
+ }),
+ },
+}
+
+export const NoCfpEmail: Story = {
+ args: {
+ conference: createMockConference({
+ cfpEmail: '',
+ }),
+ },
+}
+
+export const AllFormats: Story = {
+ args: {
+ conference: createMockConference({
+ formats: [
+ Format.lightning_10,
+ Format.presentation_20,
+ Format.presentation_25,
+ Format.presentation_40,
+ Format.presentation_45,
+ Format.workshop_120,
+ Format.workshop_240,
+ ],
+ }),
+ },
+}
diff --git a/src/components/cfp/SpeakerDetailsForm.stories.tsx b/src/components/cfp/SpeakerDetailsForm.stories.tsx
new file mode 100644
index 00000000..2ae08fbd
--- /dev/null
+++ b/src/components/cfp/SpeakerDetailsForm.stories.tsx
@@ -0,0 +1,293 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { fn } from 'storybook/test'
+import { useState } from 'react'
+import { SpeakerDetailsForm } from './SpeakerDetailsForm'
+import { SpeakerInput, Flags } from '@/lib/speaker/types'
+import { ProfileEmail } from '@/lib/profile/types'
+
+const mockEmails: ProfileEmail[] = [
+ {
+ email: 'alice@gmail.com',
+ primary: true,
+ verified: true,
+ visibility: 'public',
+ },
+ {
+ email: 'alice.work@company.io',
+ primary: false,
+ verified: true,
+ visibility: 'private',
+ },
+ {
+ email: 'alice.dev@github.com',
+ primary: false,
+ verified: true,
+ visibility: 'private',
+ },
+]
+
+const emptySpeaker: SpeakerInput = {
+ name: '',
+}
+
+const filledSpeaker: SpeakerInput = {
+ name: 'Alice Johnson',
+ title: 'Senior Platform Engineer at Google Cloud',
+ bio: 'Alice is a passionate advocate for cloud native technologies with over 10 years of experience in distributed systems and Kubernetes.',
+ flags: [Flags.localSpeaker],
+ links: [
+ 'https://linkedin.com/in/alicejohnson',
+ 'https://github.com/alicejohnson',
+ ],
+ image: 'https://placehold.co/200x200/EEE/31343C?text=AJ',
+ consent: {
+ dataProcessing: { granted: true, grantedAt: '2024-01-01T00:00:00Z' },
+ marketing: { granted: false },
+ publicProfile: { granted: true, grantedAt: '2024-01-01T00:00:00Z' },
+ photography: { granted: true, grantedAt: '2024-01-01T00:00:00Z' },
+ },
+}
+
+const meta = {
+ title: 'Systems/Speakers/SpeakerDetailsForm',
+ component: SpeakerDetailsForm,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Form for collecting and editing speaker details including name, title, bio, photo, social links, speaker flags (local, first-time, diverse, requires funding), and privacy consent checkboxes. Used in both CFP proposal submissions and speaker profile pages.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ speaker: emptySpeaker,
+ setSpeaker: fn(),
+ email: 'alice@gmail.com',
+ emails: mockEmails,
+ },
+}
+
+export const FilledOut: Story = {
+ args: {
+ speaker: filledSpeaker,
+ setSpeaker: fn(),
+ email: 'alice@gmail.com',
+ emails: mockEmails,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Form with pre-filled speaker data and consents granted.',
+ },
+ },
+ },
+}
+
+export const ProfileMode: Story = {
+ args: {
+ speaker: filledSpeaker,
+ setSpeaker: fn(),
+ email: 'alice@gmail.com',
+ emails: mockEmails,
+ mode: 'profile',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Profile mode shows a streamlined view without the section header. Help text is adjusted for profile editing context.',
+ },
+ },
+ },
+}
+
+export const WithoutEmailField: Story = {
+ args: {
+ speaker: filledSpeaker,
+ setSpeaker: fn(),
+ showEmailField: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Form without the email dropdown field.',
+ },
+ },
+ },
+}
+
+export const WithoutImageUpload: Story = {
+ args: {
+ speaker: filledSpeaker,
+ setSpeaker: fn(),
+ email: 'alice@gmail.com',
+ emails: mockEmails,
+ showImageUpload: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Form without the photo upload section.',
+ },
+ },
+ },
+}
+
+export const WithoutLinks: Story = {
+ args: {
+ speaker: filledSpeaker,
+ setSpeaker: fn(),
+ email: 'alice@gmail.com',
+ emails: mockEmails,
+ showLinks: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Form without the social profiles and links section.',
+ },
+ },
+ },
+}
+
+export const MinimalForm: Story = {
+ args: {
+ speaker: emptySpeaker,
+ setSpeaker: fn(),
+ showEmailField: false,
+ showImageUpload: false,
+ showLinks: false,
+ mode: 'profile',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Minimal form showing only name, title, bio, speaker flags, and consent checkboxes.',
+ },
+ },
+ },
+}
+
+export const FirstTimeSpeaker: Story = {
+ args: {
+ speaker: {
+ name: 'Bob Smith',
+ title: 'Junior Developer',
+ bio: 'First conference talk, excited to share my learning journey!',
+ flags: [Flags.firstTimeSpeaker, Flags.requiresTravelFunding],
+ },
+ setSpeaker: fn(),
+ email: 'bob@example.com',
+ emails: [
+ {
+ email: 'bob@example.com',
+ primary: true,
+ verified: true,
+ visibility: 'public',
+ },
+ ],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Speaker marked as first-time speaker who requires travel funding.',
+ },
+ },
+ },
+}
+
+export const DiverseSpeaker: Story = {
+ args: {
+ speaker: {
+ name: 'Carol Williams',
+ title: 'Staff Engineer',
+ flags: [Flags.diverseSpeaker, Flags.localSpeaker],
+ },
+ setSpeaker: fn(),
+ email: 'carol@example.com',
+ emails: [
+ {
+ email: 'carol@example.com',
+ primary: true,
+ verified: true,
+ visibility: 'public',
+ },
+ ],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Speaker from an underrepresented group who is also local.',
+ },
+ },
+ },
+}
+
+export const Interactive: Story = {
+ args: {
+ speaker: emptySpeaker,
+ setSpeaker: fn(),
+ email: 'alice@gmail.com',
+ emails: mockEmails,
+ },
+ render: (args) => {
+ const InteractiveDemo = () => {
+ const [speaker, setSpeaker] = useState(args.speaker)
+
+ return (
+
+
{
+ await new Promise((resolve) => setTimeout(resolve, 1000))
+ return {
+ assetId: 'mock-asset-id',
+ url: URL.createObjectURL(file),
+ }
+ }}
+ onEmailSelect={async (email: string) => {
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ console.log('Selected email:', email)
+ }}
+ />
+
+
+ Form State:
+
+
+ {JSON.stringify(speaker, null, 2)}
+
+
+
+ )
+ }
+ return
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Interactive demo showing form state updates as you fill in the fields.',
+ },
+ },
+ },
+}
diff --git a/src/components/cfp/SpeakerShareWrapper.tsx b/src/components/cfp/SpeakerShareWrapper.tsx
index a41ef8ac..6bb277b2 100644
--- a/src/components/cfp/SpeakerShareWrapper.tsx
+++ b/src/components/cfp/SpeakerShareWrapper.tsx
@@ -1,6 +1,6 @@
'use client'
-import { SpeakerSharingActions } from '@/components/branding/SpeakerSharingActions'
+import { SpeakerSharingActions } from '@/components/speaker/SpeakerSharingActions'
import { MissingAvatar } from '@/components/common/MissingAvatar'
import { QrCodeIcon } from '@heroicons/react/24/outline'
import { MicrophoneIcon, StarIcon } from '@heroicons/react/24/solid'
diff --git a/src/components/common/DownloadableImage.stories.tsx b/src/components/common/DownloadableImage.stories.tsx
new file mode 100644
index 00000000..25caa49e
--- /dev/null
+++ b/src/components/common/DownloadableImage.stories.tsx
@@ -0,0 +1,43 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { DownloadableImage } from './DownloadableImage'
+
+const meta = {
+ title: 'Components/Data Display/DownloadableImage',
+ component: DownloadableImage,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'centered',
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ filename: 'example-card',
+ children: (
+
+
Jane Smith
+
Cloud Native Days Norway 2025
+
Speaker
+
+ ),
+ },
+}
+
+export const WithCustomContent: Story = {
+ args: {
+ filename: 'sponsor-badge',
+ children: (
+
+
+ Any Content
+
+
+ Wrap any component to make it downloadable as PNG
+
+
+ ),
+ },
+}
diff --git a/src/components/branding/DownloadSpeakerImage.tsx b/src/components/common/DownloadableImage.tsx
similarity index 98%
rename from src/components/branding/DownloadSpeakerImage.tsx
rename to src/components/common/DownloadableImage.tsx
index 808b2001..526a2763 100644
--- a/src/components/branding/DownloadSpeakerImage.tsx
+++ b/src/components/common/DownloadableImage.tsx
@@ -4,15 +4,15 @@ import { useState, useRef } from 'react'
import { ArrowDownTrayIcon } from '@heroicons/react/24/outline'
import html2canvas from 'html2canvas-pro'
-interface DownloadSpeakerImageProps {
+interface DownloadableImageProps {
filename?: string
children: React.ReactNode
}
-export function DownloadSpeakerImage({
+export function DownloadableImage({
filename = 'speaker-image',
children,
-}: DownloadSpeakerImageProps) {
+}: DownloadableImageProps) {
const [isDownloading, setIsDownloading] = useState(false)
const componentRef = useRef(null)
diff --git a/src/components/common/MissingAvatar.stories.tsx b/src/components/common/MissingAvatar.stories.tsx
new file mode 100644
index 00000000..56fd51f8
--- /dev/null
+++ b/src/components/common/MissingAvatar.stories.tsx
@@ -0,0 +1,213 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { MissingAvatar } from './MissingAvatar'
+
+const meta = {
+ title: 'Components/Layout/MissingAvatar',
+ component: MissingAvatar,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+ argTypes: {
+ name: {
+ control: 'text',
+ description: 'Name to generate initials from',
+ },
+ size: {
+ control: { type: 'range', min: 24, max: 200, step: 8 },
+ description: 'Size of the avatar in pixels',
+ },
+ className: {
+ control: 'text',
+ description: 'Additional CSS classes',
+ },
+ textSizeClass: {
+ control: 'text',
+ description: 'Override text size class (e.g., text-xs, text-sm)',
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ name: 'John Doe',
+ size: 64,
+ },
+}
+
+export const SingleName: Story = {
+ args: {
+ name: 'Alice',
+ size: 64,
+ },
+}
+
+export const SingleLetter: Story = {
+ args: {
+ name: 'X',
+ size: 64,
+ },
+}
+
+export const EmptyName: Story = {
+ args: {
+ name: '',
+ size: 64,
+ },
+}
+
+export const DifferentSizes: Story = {
+ args: { name: 'Demo', size: 64 },
+ render: () => (
+
+
+
+
+
+
+
+ ),
+}
+
+export const ColorVariations: Story = {
+ args: { name: 'Demo', size: 48 },
+ render: () => (
+
+ {[
+ 'Alice Brown',
+ 'Bob Clark',
+ 'Carol Davis',
+ 'David Evans',
+ 'Eva Fisher',
+ 'Frank Garcia',
+ 'Grace Hall',
+ 'Henry Ivy',
+ 'Iris Jones',
+ 'Jack King',
+ 'Karen Lee',
+ 'Leo Martin',
+ 'Mary Nelson',
+ 'Nick Owen',
+ 'Olivia Price',
+ 'Paul Quinn',
+ 'Quinn Ross',
+ ].map((name) => (
+
+
+ {name.split(' ')[0]}
+
+ ))}
+
+ ),
+}
+
+export const WithRoundedClass: Story = {
+ args: {
+ name: 'Jane Smith',
+ size: 64,
+ className: 'rounded-full',
+ },
+}
+
+export const WithSquareClass: Story = {
+ args: {
+ name: 'Jane Smith',
+ size: 64,
+ className: 'rounded-lg',
+ },
+}
+
+export const Documentation: Story = {
+ args: { name: 'Demo', size: 64 },
+ render: () => (
+
+
+ MissingAvatar Component
+
+
+ A placeholder avatar component that displays initials when no image is
+ available. The background color is deterministically generated based on
+ the first letter of the name.
+
+
+
+
+ Features
+
+
+
+ Automatic initials: Generates up to 2-letter
+ initials from the name
+
+
+ Consistent colors: Same name always produces the
+ same background color
+
+
+ Responsive text: Text size automatically scales
+ with avatar size
+
+
+ Fallback handling: Shows "?" for empty or
+ invalid names
+
+
+
+
+
+
+ Initials Logic
+
+
+
+
+ Multi-word name
+
+
+
+
+ "John Doe" → JD
+
+
+
+
+
+ Single word name
+
+
+
+
+ "Alice" → AL
+
+
+
+
+
+ Single letter
+
+
+
+
+ "X" → X
+
+
+
+
+
+ Empty name
+
+
+
+
+ "" → ?
+
+
+
+
+
+
+ ),
+}
diff --git a/src/components/icons/OSIcons.stories.tsx b/src/components/icons/OSIcons.stories.tsx
new file mode 100644
index 00000000..df90d3c3
--- /dev/null
+++ b/src/components/icons/OSIcons.stories.tsx
@@ -0,0 +1,209 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ ExperienceLevelIcon,
+ AppleIcon,
+ WindowsIcon,
+ LinuxIcon,
+} from './OSIcons'
+
+const meta = {
+ title: 'Components/Icons/OSIcons',
+ component: ExperienceLevelIcon,
+ parameters: {
+ layout: 'centered',
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const ExperienceLevels: Story = {
+ args: { level: 'beginner' },
+ render: () => (
+
+
+
+
+ Beginner
+
+
+
+
+
+ Intermediate
+
+
+
+
+
+ Advanced
+
+
+
+ ),
+}
+
+export const Beginner: Story = {
+ args: {
+ level: 'beginner',
+ className: 'h-8 w-8',
+ },
+}
+
+export const Intermediate: Story = {
+ args: {
+ level: 'intermediate',
+ className: 'h-8 w-8',
+ },
+}
+
+export const Advanced: Story = {
+ args: {
+ level: 'advanced',
+ className: 'h-8 w-8',
+ },
+}
+
+export const OSLogos: Story = {
+ args: { level: 'beginner' },
+ render: () => (
+
+
+
+
+
+ Windows
+
+
+
+
+ Linux
+
+
+ ),
+}
+
+export const IconSizes: Story = {
+ args: { level: 'beginner' },
+ render: () => (
+
+
+
+ Experience Level Icons - Different Sizes
+
+
+
+
+
+
+
+
+
+
+ OS Icons - Different Sizes
+
+
+
+
+ ),
+}
+
+export const Documentation: Story = {
+ args: { level: 'beginner' },
+ render: () => (
+
+
+ OS & Experience Level Icons
+
+
+ A collection of SVG icons for operating systems and experience levels.
+ These are used in workshop listings and speaker profiles.
+
+
+
+
+ Experience Level Icon
+
+
+ A bar chart icon that visually represents difficulty levels. Colors
+ are semantic: green for beginner, yellow for intermediate, red for
+ advanced.
+
+
+
+
+
+ 1 bar = Beginner
+
+
+
+
+
+ 2 bars = Intermediate
+
+
+
+
+
+ 3 bars = Advanced
+
+
+
+
+
+
+
+ Operating System Icons
+
+
+ Standard monochrome icons for major operating systems. Use with
+ text-gray-* classes for appropriate coloring.
+
+
+
+
+
+
+ WindowsIcon
+
+
+
+
+
+ LinuxIcon
+
+
+
+
+
+
+
+ Usage
+
+
+ {`import { ExperienceLevelIcon, AppleIcon } from '@/components/icons/OSIcons'
+
+// Experience level (beginner | intermediate | advanced)
+
+
+// OS icons - use text color classes
+ `}
+
+
+
+ ),
+}
diff --git a/src/components/program/ProgramAgendaView.stories.tsx b/src/components/program/ProgramAgendaView.stories.tsx
new file mode 100644
index 00000000..b4ba604f
--- /dev/null
+++ b/src/components/program/ProgramAgendaView.stories.tsx
@@ -0,0 +1,154 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProgramAgendaView } from './ProgramAgendaView'
+import { BookmarksProvider } from '@/contexts/BookmarksContext'
+import { Format, Language, Level, Audience, Status } from '@/lib/proposal/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+import type { FilteredProgramData } from '@/hooks/useProgramFilter'
+import type { Speaker } from '@/lib/speaker/types'
+import { Flags } from '@/lib/speaker/types'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+]
+
+const mockTopics = [
+ {
+ _id: 'topic-1',
+ _type: 'topic' as const,
+ title: 'Kubernetes',
+ slug: { current: 'kubernetes' },
+ color: '326CE5',
+ },
+]
+
+const createMockTalk = (
+ id: string,
+ title: string,
+ startTime: string,
+ endTime: string,
+ trackTitle: string,
+ trackIndex: number,
+ scheduleDate: string,
+) => ({
+ startTime,
+ endTime,
+ scheduleDate,
+ trackTitle,
+ trackIndex,
+ talk: {
+ _id: id,
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title,
+ description: convertStringToPortableTextBlocks(`${title} session.`),
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [Audience.developer],
+ status: Status.confirmed,
+ outline: '',
+ topics: mockTopics,
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+})
+
+const mockData: FilteredProgramData = {
+ schedules: [
+ {
+ _id: 'schedule-day1',
+ date: '2025-09-15',
+ tracks: [{ trackTitle: 'Main Stage', trackDescription: '', talks: [] }],
+ },
+ ],
+ allTalks: [
+ createMockTalk(
+ 't1',
+ 'Building Scalable Cloud Native Apps',
+ '09:00',
+ '09:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't2',
+ 'GitOps Best Practices',
+ '10:00',
+ '10:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't3',
+ 'Kubernetes Security Workshop',
+ '13:00',
+ '13:45',
+ 'Workshop Room',
+ 1,
+ '2025-09-15',
+ ),
+ ],
+ availableFilters: {
+ days: ['2025-09-15'],
+ tracks: ['Main Stage', 'Workshop Room'],
+ formats: [Format.presentation_45],
+ levels: [Level.intermediate],
+ audiences: [Audience.developer],
+ topics: mockTopics,
+ },
+}
+
+const meta = {
+ title: 'Systems/Program/ProgramAgendaView',
+ component: ProgramAgendaView,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Personal agenda view that shows only bookmarked talks. Uses the BookmarksContext to filter talks the user has saved. Shows empty states for both no bookmarks and no matching talks after filtering. Bookmark talks from other views to see them here.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const ProgramAgendaView_: Story = {
+ name: 'ProgramAgendaView',
+ args: {
+ data: mockData,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Default state when no talks have been bookmarked. Shows a prompt to start building your agenda.',
+ },
+ },
+ },
+}
diff --git a/src/components/program/ProgramFilters.stories.tsx b/src/components/program/ProgramFilters.stories.tsx
new file mode 100644
index 00000000..f99f352c
--- /dev/null
+++ b/src/components/program/ProgramFilters.stories.tsx
@@ -0,0 +1,183 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { fn } from 'storybook/test'
+import { ProgramFilters } from './ProgramFilters'
+import { Format, Level, Audience } from '@/lib/proposal/types'
+import type {
+ ProgramViewMode,
+ ViewModeConfig,
+} from '@/hooks/useProgramViewMode'
+
+const mockTopics = [
+ {
+ _id: 'topic-1',
+ _type: 'topic' as const,
+ title: 'Kubernetes',
+ slug: { current: 'kubernetes' },
+ color: '326CE5',
+ },
+ {
+ _id: 'topic-2',
+ _type: 'topic' as const,
+ title: 'DevOps',
+ slug: { current: 'devops' },
+ color: 'FF6B35',
+ },
+ {
+ _id: 'topic-3',
+ _type: 'topic' as const,
+ title: 'Observability',
+ slug: { current: 'observability' },
+ color: '00C7B7',
+ },
+]
+
+const mockViewModes: ViewModeConfig[] = [
+ {
+ id: 'schedule',
+ label: 'Schedule View',
+ description: 'Traditional time-based schedule layout',
+ icon: 'calendar',
+ suitableFor: ['time-oriented navigation', 'conference flow'],
+ },
+ {
+ id: 'grid',
+ label: 'Card Grid',
+ description: 'Browse talks as cards with filtering',
+ icon: 'grid',
+ suitableFor: ['talk discovery', 'content browsing'],
+ },
+ {
+ id: 'list',
+ label: 'List View',
+ description: 'Compact list with detailed information',
+ icon: 'list',
+ suitableFor: ['quick scanning', 'mobile browsing'],
+ },
+ {
+ id: 'agenda',
+ label: 'Personal Agenda',
+ description: 'Build your personalized conference agenda',
+ icon: 'bookmark',
+ suitableFor: ['planning', 'personal schedule'],
+ },
+]
+
+const defaultFilters = {
+ searchQuery: '',
+ selectedDay: '',
+ selectedTrack: '',
+ selectedFormat: '' as const,
+ selectedLevel: '' as const,
+ selectedAudience: '' as const,
+ selectedTopic: '',
+}
+
+const defaultAvailableFilters = {
+ days: ['2025-09-15', '2025-09-16'],
+ tracks: ['Main Stage', 'Workshop Room', 'Community Track'],
+ formats: [
+ Format.lightning_10,
+ Format.presentation_25,
+ Format.presentation_45,
+ Format.workshop_120,
+ ],
+ levels: [Level.beginner, Level.intermediate, Level.advanced],
+ audiences: [
+ Audience.developer,
+ Audience.architect,
+ Audience.operator,
+ Audience.devopsEngineer,
+ ],
+ topics: mockTopics,
+}
+
+const meta = {
+ title: 'Systems/Program/ProgramFilters',
+ component: ProgramFilters,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Filter bar for the program page. Includes search input, expandable dropdowns for day/track/format/level/audience/topic, a count indicator, and an integrated ViewModeSelector. Filters expand on click to reveal additional options.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ args: {
+ filters: defaultFilters,
+ availableFilters: defaultAvailableFilters,
+ onFilterChange: fn(),
+ onClearFilters: fn(),
+ hasActiveFilters: false,
+ totalTalks: 42,
+ filteredTalks: 42,
+ viewMode: 'schedule' as ProgramViewMode,
+ viewModes: mockViewModes,
+ onViewModeChange: fn(),
+ currentViewConfig: mockViewModes[0],
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const WithActiveFilters: Story = {
+ args: {
+ filters: {
+ ...defaultFilters,
+ selectedLevel: Level.intermediate,
+ selectedTrack: 'Main Stage',
+ },
+ hasActiveFilters: true,
+ filteredTalks: 12,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'When filters are active, a Clear button appears and the count indicator updates.',
+ },
+ },
+ },
+}
+
+export const WithSearch: Story = {
+ args: {
+ filters: {
+ ...defaultFilters,
+ searchQuery: 'kubernetes',
+ },
+ hasActiveFilters: true,
+ filteredTalks: 8,
+ },
+}
+
+export const SingleDay: Story = {
+ args: {
+ availableFilters: {
+ ...defaultAvailableFilters,
+ days: ['2025-09-15'],
+ tracks: ['Main Stage'],
+ },
+ totalTalks: 15,
+ filteredTalks: 15,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'When there is only one day or track, those filter dropdowns are hidden.',
+ },
+ },
+ },
+}
+
+export const GridViewMode: Story = {
+ args: {
+ viewMode: 'grid' as ProgramViewMode,
+ currentViewConfig: mockViewModes[1],
+ },
+}
diff --git a/src/components/program/ProgramFilters.tsx b/src/components/program/ProgramFilters.tsx
index bdc85f43..38924db2 100644
--- a/src/components/program/ProgramFilters.tsx
+++ b/src/components/program/ProgramFilters.tsx
@@ -197,7 +197,7 @@ export const ProgramFilters = React.memo(function ProgramFilters({
)}
{isExpanded ? (
<>
diff --git a/src/components/program/ProgramGridView.stories.tsx b/src/components/program/ProgramGridView.stories.tsx
new file mode 100644
index 00000000..0651a217
--- /dev/null
+++ b/src/components/program/ProgramGridView.stories.tsx
@@ -0,0 +1,407 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProgramGridView } from './ProgramGridView'
+import { BookmarksProvider } from '@/contexts/BookmarksContext'
+import { Format, Language, Level, Audience, Status } from '@/lib/proposal/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+import type { FilteredProgramData } from '@/hooks/useProgramFilter'
+import type { Speaker } from '@/lib/speaker/types'
+import { Flags } from '@/lib/speaker/types'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker],
+ },
+]
+
+const mockTopics = [
+ {
+ _id: 'topic-1',
+ _type: 'topic' as const,
+ title: 'Kubernetes',
+ slug: { current: 'kubernetes' },
+ color: '326CE5',
+ },
+ {
+ _id: 'topic-2',
+ _type: 'topic' as const,
+ title: 'DevOps',
+ slug: { current: 'devops' },
+ color: 'FF6B35',
+ },
+]
+
+const createMockTalk = (
+ id: string,
+ title: string,
+ startTime: string,
+ endTime: string,
+ trackTitle: string,
+ trackIndex: number,
+ scheduleDate: string,
+) => ({
+ startTime,
+ endTime,
+ scheduleDate,
+ trackTitle,
+ trackIndex,
+ talk: {
+ _id: id,
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title,
+ description: convertStringToPortableTextBlocks(
+ `An insightful session about ${title.toLowerCase()}.`,
+ ),
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [Audience.developer],
+ status: Status.confirmed,
+ outline: '',
+ topics: mockTopics,
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+})
+
+const createServiceSession = (
+ placeholder: string,
+ startTime: string,
+ endTime: string,
+ trackTitle: string,
+ trackIndex: number,
+ scheduleDate: string,
+) => ({
+ startTime,
+ endTime,
+ scheduleDate,
+ trackTitle,
+ trackIndex,
+ placeholder,
+})
+
+const mockData: FilteredProgramData = {
+ schedules: [
+ {
+ _id: 'schedule-day1',
+ date: '2025-09-15',
+ tracks: [
+ {
+ trackTitle: 'Main Stage',
+ trackDescription: 'Keynotes and featured talks',
+ talks: [
+ {
+ talk: createMockTalk(
+ 't1',
+ 'Building Scalable Cloud Native Apps',
+ '09:00',
+ '09:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ).talk!,
+ startTime: '09:00',
+ endTime: '09:45',
+ },
+ {
+ placeholder: 'Coffee Break',
+ startTime: '09:45',
+ endTime: '10:00',
+ },
+ {
+ talk: createMockTalk(
+ 't2',
+ 'GitOps Best Practices',
+ '10:00',
+ '10:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ).talk!,
+ startTime: '10:00',
+ endTime: '10:45',
+ },
+ ],
+ },
+ {
+ trackTitle: 'Workshop Room',
+ trackDescription: 'Hands-on workshops and tutorials',
+ talks: [
+ {
+ talk: createMockTalk(
+ 't3',
+ 'Kubernetes Security Workshop',
+ '09:00',
+ '09:45',
+ 'Workshop Room',
+ 1,
+ '2025-09-15',
+ ).talk!,
+ startTime: '09:00',
+ endTime: '09:45',
+ },
+ {
+ placeholder: 'Coffee Break',
+ startTime: '09:45',
+ endTime: '10:00',
+ },
+ {
+ talk: createMockTalk(
+ 't4',
+ 'Observability Deep Dive',
+ '10:00',
+ '10:45',
+ 'Workshop Room',
+ 1,
+ '2025-09-15',
+ ).talk!,
+ startTime: '10:00',
+ endTime: '10:45',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ allTalks: [
+ createMockTalk(
+ 't1',
+ 'Building Scalable Cloud Native Apps',
+ '09:00',
+ '09:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createServiceSession(
+ 'Coffee Break',
+ '09:45',
+ '10:00',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't2',
+ 'GitOps Best Practices',
+ '10:00',
+ '10:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't3',
+ 'Kubernetes Security Workshop',
+ '09:00',
+ '09:45',
+ 'Workshop Room',
+ 1,
+ '2025-09-15',
+ ),
+ createServiceSession(
+ 'Coffee Break',
+ '09:45',
+ '10:00',
+ 'Workshop Room',
+ 1,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't4',
+ 'Observability Deep Dive',
+ '10:00',
+ '10:45',
+ 'Workshop Room',
+ 1,
+ '2025-09-15',
+ ),
+ ],
+ availableFilters: {
+ days: ['2025-09-15'],
+ tracks: ['Main Stage', 'Workshop Room'],
+ formats: [Format.presentation_45],
+ levels: [Level.intermediate],
+ audiences: [Audience.developer],
+ topics: mockTopics,
+ },
+}
+
+const multiDayData: FilteredProgramData = {
+ schedules: [
+ ...mockData.schedules,
+ {
+ _id: 'schedule-day2',
+ date: '2025-09-16',
+ tracks: [
+ {
+ trackTitle: 'Main Stage',
+ trackDescription: 'Day 2 keynotes',
+ talks: [
+ {
+ talk: createMockTalk(
+ 't5',
+ 'The Future of Platform Engineering',
+ '09:00',
+ '09:45',
+ 'Main Stage',
+ 0,
+ '2025-09-16',
+ ).talk!,
+ startTime: '09:00',
+ endTime: '09:45',
+ },
+ {
+ talk: createMockTalk(
+ 't6',
+ 'Service Mesh Patterns',
+ '10:00',
+ '10:45',
+ 'Main Stage',
+ 0,
+ '2025-09-16',
+ ).talk!,
+ startTime: '10:00',
+ endTime: '10:45',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ allTalks: [
+ ...mockData.allTalks,
+ createMockTalk(
+ 't5',
+ 'The Future of Platform Engineering',
+ '09:00',
+ '09:45',
+ 'Main Stage',
+ 0,
+ '2025-09-16',
+ ),
+ createMockTalk(
+ 't6',
+ 'Service Mesh Patterns',
+ '10:00',
+ '10:45',
+ 'Main Stage',
+ 0,
+ '2025-09-16',
+ ),
+ ],
+ availableFilters: {
+ ...mockData.availableFilters,
+ days: ['2025-09-15', '2025-09-16'],
+ },
+}
+
+const emptyData: FilteredProgramData = {
+ schedules: [],
+ allTalks: [],
+ availableFilters: {
+ days: [],
+ tracks: [],
+ formats: [],
+ levels: [],
+ audiences: [],
+ topics: [],
+ },
+}
+
+const meta = {
+ title: 'Systems/Program/ProgramGridView',
+ component: ProgramGridView,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Responsive card grid view for the program page. Displays talks as TalkCard components in a 1/2/3-column responsive grid. Shows an empty state when no talks match filters.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ data: mockData,
+ },
+}
+
+export const MultiDay: Story = {
+ args: {
+ data: multiDayData,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Grid view spanning multiple conference days.',
+ },
+ },
+ },
+}
+
+export const WithLiveStatus: Story = {
+ args: {
+ data: mockData,
+ talkStatusMap: new Map([
+ ['2025-09-15|09:00|0|t1', 'happening-now'],
+ ['2025-09-15|10:00|0|t2', 'upcoming'],
+ ]),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Cards can show live status indicators for happening-now or upcoming talks.',
+ },
+ },
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ data: emptyData,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Empty state shown when no talks match the current filters.',
+ },
+ },
+ },
+}
diff --git a/src/components/program/ProgramListView.stories.tsx b/src/components/program/ProgramListView.stories.tsx
new file mode 100644
index 00000000..fa4c056b
--- /dev/null
+++ b/src/components/program/ProgramListView.stories.tsx
@@ -0,0 +1,325 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProgramListView } from './ProgramListView'
+import { BookmarksProvider } from '@/contexts/BookmarksContext'
+import { Format, Language, Level, Audience, Status } from '@/lib/proposal/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+import type { FilteredProgramData } from '@/hooks/useProgramFilter'
+import type { Speaker } from '@/lib/speaker/types'
+import { Flags } from '@/lib/speaker/types'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker],
+ },
+]
+
+const mockTopics = [
+ {
+ _id: 'topic-1',
+ _type: 'topic' as const,
+ title: 'Kubernetes',
+ slug: { current: 'kubernetes' },
+ color: '326CE5',
+ },
+ {
+ _id: 'topic-2',
+ _type: 'topic' as const,
+ title: 'DevOps',
+ slug: { current: 'devops' },
+ color: 'FF6B35',
+ },
+]
+
+const createMockTalk = (
+ id: string,
+ title: string,
+ startTime: string,
+ endTime: string,
+ trackTitle: string,
+ trackIndex: number,
+ scheduleDate: string,
+) => ({
+ startTime,
+ endTime,
+ scheduleDate,
+ trackTitle,
+ trackIndex,
+ talk: {
+ _id: id,
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title,
+ description: convertStringToPortableTextBlocks(
+ `An insightful session about ${title.toLowerCase()}.`,
+ ),
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [Audience.developer],
+ status: Status.confirmed,
+ outline: '',
+ topics: mockTopics,
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+})
+
+const createServiceSession = (
+ placeholder: string,
+ startTime: string,
+ endTime: string,
+ trackTitle: string,
+ trackIndex: number,
+ scheduleDate: string,
+) => ({
+ startTime,
+ endTime,
+ scheduleDate,
+ trackTitle,
+ trackIndex,
+ placeholder,
+})
+
+const singleDayData: FilteredProgramData = {
+ schedules: [
+ {
+ _id: 'schedule-day1',
+ date: '2025-09-15',
+ tracks: [
+ {
+ trackTitle: 'Main Stage',
+ trackDescription: 'Keynotes and featured talks',
+ talks: [],
+ },
+ ],
+ },
+ ],
+ allTalks: [
+ createMockTalk(
+ 't1',
+ 'Building Scalable Cloud Native Apps',
+ '09:00',
+ '09:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createServiceSession(
+ 'Coffee Break',
+ '09:45',
+ '10:00',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't2',
+ 'GitOps Best Practices',
+ '10:00',
+ '10:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't3',
+ 'Kubernetes Security Workshop',
+ '10:00',
+ '10:45',
+ 'Workshop Room',
+ 1,
+ '2025-09-15',
+ ),
+ createServiceSession(
+ 'Lunch Break',
+ '12:00',
+ '13:00',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't4',
+ 'Observability Deep Dive',
+ '13:00',
+ '13:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ ],
+ availableFilters: {
+ days: ['2025-09-15'],
+ tracks: ['Main Stage', 'Workshop Room'],
+ formats: [Format.presentation_45],
+ levels: [Level.intermediate],
+ audiences: [Audience.developer],
+ topics: mockTopics,
+ },
+}
+
+const multiDayData: FilteredProgramData = {
+ schedules: [
+ {
+ _id: 'schedule-day1',
+ date: '2025-09-15',
+ tracks: [{ trackTitle: 'Main Stage', trackDescription: '', talks: [] }],
+ },
+ {
+ _id: 'schedule-day2',
+ date: '2025-09-16',
+ tracks: [{ trackTitle: 'Main Stage', trackDescription: '', talks: [] }],
+ },
+ ],
+ allTalks: [
+ createMockTalk(
+ 't1',
+ 'Building Scalable Cloud Native Apps',
+ '09:00',
+ '09:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't2',
+ 'GitOps Best Practices',
+ '10:00',
+ '10:45',
+ 'Main Stage',
+ 0,
+ '2025-09-15',
+ ),
+ createMockTalk(
+ 't3',
+ 'The Future of Platform Engineering',
+ '09:00',
+ '09:45',
+ 'Main Stage',
+ 0,
+ '2025-09-16',
+ ),
+ createMockTalk(
+ 't4',
+ 'Service Mesh Patterns',
+ '10:00',
+ '10:45',
+ 'Main Stage',
+ 0,
+ '2025-09-16',
+ ),
+ ],
+ availableFilters: {
+ days: ['2025-09-15', '2025-09-16'],
+ tracks: ['Main Stage'],
+ formats: [Format.presentation_45],
+ levels: [Level.intermediate],
+ audiences: [Audience.developer],
+ topics: mockTopics,
+ },
+}
+
+const emptyData: FilteredProgramData = {
+ schedules: [],
+ allTalks: [],
+ availableFilters: {
+ days: [],
+ tracks: [],
+ formats: [],
+ levels: [],
+ audiences: [],
+ topics: [],
+ },
+}
+
+const meta = {
+ title: 'Systems/Program/ProgramListView',
+ component: ProgramListView,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Chronological list view for the program page. Groups talks by day with compact TalkCard rendering. Service sessions with the same placeholder are merged across tracks. Ideal for quick scanning and mobile browsing.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ data: singleDayData,
+ },
+}
+
+export const MultiDay: Story = {
+ args: {
+ data: multiDayData,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'When the schedule has multiple days, day headings and item counts are shown.',
+ },
+ },
+ },
+}
+
+export const WithLiveStatus: Story = {
+ args: {
+ data: singleDayData,
+ talkStatusMap: new Map([
+ ['2025-09-15|09:00|0|t1', 'past'],
+ ['2025-09-15|10:00|0|t2', 'happening-now'],
+ ['2025-09-15|13:00|0|t4', 'upcoming'],
+ ]),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Talks display live status indicators (past, happening-now, upcoming).',
+ },
+ },
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ data: emptyData,
+ },
+}
diff --git a/src/components/program/ProgramScheduleView.stories.tsx b/src/components/program/ProgramScheduleView.stories.tsx
new file mode 100644
index 00000000..6eab7c57
--- /dev/null
+++ b/src/components/program/ProgramScheduleView.stories.tsx
@@ -0,0 +1,296 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ProgramScheduleView } from './ProgramScheduleView'
+import { BookmarksProvider } from '@/contexts/BookmarksContext'
+import { Format, Language, Level, Audience, Status } from '@/lib/proposal/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+import type { FilteredProgramData } from '@/hooks/useProgramFilter'
+import type { Speaker } from '@/lib/speaker/types'
+import { Flags } from '@/lib/speaker/types'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker],
+ },
+]
+
+const mockTopics = [
+ {
+ _id: 'topic-1',
+ _type: 'topic' as const,
+ title: 'Kubernetes',
+ slug: { current: 'kubernetes' },
+ color: '326CE5',
+ },
+]
+
+const createTalk = (
+ id: string,
+ title: string,
+ format = Format.presentation_45,
+) => ({
+ _id: id,
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title,
+ description: convertStringToPortableTextBlocks(`${title} - a great session.`),
+ language: Language.english,
+ format,
+ level: Level.intermediate,
+ audiences: [Audience.developer] as Audience[],
+ status: Status.confirmed,
+ outline: '',
+ topics: mockTopics,
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+})
+
+const twoTrackData: FilteredProgramData = {
+ schedules: [
+ {
+ _id: 'schedule-day1',
+ date: '2025-09-15',
+ tracks: [
+ {
+ trackTitle: 'Main Stage',
+ trackDescription: 'Keynotes and featured presentations',
+ talks: [
+ {
+ talk: createTalk('t1', 'Opening Keynote'),
+ startTime: '09:00',
+ endTime: '09:45',
+ },
+ {
+ placeholder: 'Coffee Break',
+ startTime: '09:45',
+ endTime: '10:00',
+ },
+ {
+ talk: createTalk('t2', 'GitOps Best Practices'),
+ startTime: '10:00',
+ endTime: '10:45',
+ },
+ { placeholder: 'Lunch', startTime: '12:00', endTime: '13:00' },
+ {
+ talk: createTalk('t5', 'Closing Keynote'),
+ startTime: '13:00',
+ endTime: '13:45',
+ },
+ ],
+ },
+ {
+ trackTitle: 'Workshop Room',
+ trackDescription: 'Hands-on workshops and tutorials',
+ talks: [
+ {
+ talk: createTalk(
+ 't3',
+ 'Kubernetes Security Workshop',
+ Format.workshop_120,
+ ),
+ startTime: '09:00',
+ endTime: '09:45',
+ },
+ {
+ placeholder: 'Coffee Break',
+ startTime: '09:45',
+ endTime: '10:00',
+ },
+ {
+ talk: createTalk('t4', 'Observability Deep Dive'),
+ startTime: '10:00',
+ endTime: '10:45',
+ },
+ { placeholder: 'Lunch', startTime: '12:00', endTime: '13:00' },
+ {
+ talk: createTalk('t6', 'Service Mesh Patterns'),
+ startTime: '13:00',
+ endTime: '13:45',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ allTalks: [],
+ availableFilters: {
+ days: ['2025-09-15'],
+ tracks: ['Main Stage', 'Workshop Room'],
+ formats: [Format.presentation_45, Format.workshop_120],
+ levels: [Level.intermediate],
+ audiences: [Audience.developer],
+ topics: mockTopics,
+ },
+}
+
+const multiDayData: FilteredProgramData = {
+ schedules: [
+ ...twoTrackData.schedules,
+ {
+ _id: 'schedule-day2',
+ date: '2025-09-16',
+ tracks: [
+ {
+ trackTitle: 'Main Stage',
+ trackDescription: 'Day 2 keynotes',
+ talks: [
+ {
+ talk: createTalk('t7', 'Platform Engineering in 2025'),
+ startTime: '09:00',
+ endTime: '09:45',
+ },
+ {
+ talk: createTalk('t8', 'eBPF for Beginners'),
+ startTime: '10:00',
+ endTime: '10:45',
+ },
+ ],
+ },
+ {
+ trackTitle: 'Community Track',
+ trackDescription: 'Community-driven sessions',
+ talks: [
+ {
+ talk: createTalk('t9', 'Open Source Sustainability'),
+ startTime: '09:00',
+ endTime: '09:45',
+ },
+ {
+ talk: createTalk('t10', 'Contributing to CNCF Projects'),
+ startTime: '10:00',
+ endTime: '10:45',
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ allTalks: [],
+ availableFilters: {
+ days: ['2025-09-15', '2025-09-16'],
+ tracks: ['Main Stage', 'Workshop Room', 'Community Track'],
+ formats: [Format.presentation_45, Format.workshop_120],
+ levels: [Level.intermediate],
+ audiences: [Audience.developer],
+ topics: mockTopics,
+ },
+}
+
+const emptyData: FilteredProgramData = {
+ schedules: [],
+ allTalks: [],
+ availableFilters: {
+ days: [],
+ tracks: [],
+ formats: [],
+ levels: [],
+ audiences: [],
+ topics: [],
+ },
+}
+
+const meta = {
+ title: 'Systems/Program/ProgramScheduleView',
+ component: ProgramScheduleView,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ 'Full schedule view with a tabbed mobile layout and a time-based grid on desktop. Shows tracks as columns with time slots as rows. Supports live scroll-to-current and status indicators. Resize the browser to see the mobile tabbed interface.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ data: twoTrackData,
+ },
+}
+
+export const MultiDay: Story = {
+ args: {
+ data: multiDayData,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Multi-day schedule with day headings and different track configurations per day.',
+ },
+ },
+ },
+}
+
+export const WithLiveStatus: Story = {
+ args: {
+ data: twoTrackData,
+ talkStatusMap: new Map([
+ ['2025-09-15|09:00|0|t1', 'past'],
+ ['2025-09-15|10:00|0|t2', 'happening-now'],
+ ['2025-09-15|10:00|1|t4', 'happening-now'],
+ ['2025-09-15|13:00|0|t5', 'upcoming'],
+ ]),
+ isLive: true,
+ currentPosition: {
+ scheduleIndex: 0,
+ trackIndex: 0,
+ talkIndex: 2,
+ talk: {
+ talk: createTalk('t2', 'GitOps Best Practices'),
+ startTime: '10:00',
+ endTime: '10:45',
+ },
+ scheduleDate: '2025-09-15',
+ },
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Live mode with status indicators and auto-scroll to the current talk.',
+ },
+ },
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ data: emptyData,
+ },
+}
diff --git a/src/components/program/ProgramScheduleView.tsx b/src/components/program/ProgramScheduleView.tsx
index dcbf57ed..e33a0567 100644
--- a/src/components/program/ProgramScheduleView.tsx
+++ b/src/components/program/ProgramScheduleView.tsx
@@ -333,7 +333,7 @@ const ScheduleStatic = React.memo(function ScheduleStatic({
return (
{talk ? (
@@ -357,7 +357,7 @@ const ScheduleStatic = React.memo(function ScheduleStatic({
fixedHeight={true}
/>
) : (
-
+
)}
)
diff --git a/src/components/program/TalkCard.stories.tsx b/src/components/program/TalkCard.stories.tsx
new file mode 100644
index 00000000..ed8f8ee3
--- /dev/null
+++ b/src/components/program/TalkCard.stories.tsx
@@ -0,0 +1,473 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { TalkCard } from './TalkCard'
+import { BookmarksProvider } from '@/contexts/BookmarksContext'
+import { Format, Language, Level, Audience, Status } from '@/lib/proposal/types'
+import { Flags, Speaker } from '@/lib/speaker/types'
+import { convertStringToPortableTextBlocks } from '@/lib/proposal'
+import type { TrackTalk } from '@/lib/conference/types'
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Alice Johnson',
+ email: 'alice@example.com',
+ slug: 'alice-johnson',
+ title: 'Senior Engineer at Google',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Bob Smith',
+ email: 'bob@example.com',
+ slug: 'bob-smith',
+ title: 'DevOps Lead at Microsoft',
+ flags: [Flags.firstTimeSpeaker],
+ },
+]
+
+const mockTopics = [
+ {
+ _id: 'topic-1',
+ _type: 'topic' as const,
+ title: 'Kubernetes',
+ slug: { current: 'kubernetes' },
+ color: '326CE5',
+ },
+ {
+ _id: 'topic-2',
+ _type: 'topic' as const,
+ title: 'DevOps',
+ slug: { current: 'devops' },
+ color: 'FF6B35',
+ },
+]
+
+type TalkCardTalk = TrackTalk & {
+ scheduleDate: string
+ trackTitle: string
+ trackIndex: number
+}
+
+const createMockTalk = (
+ overrides: Partial = {},
+): TalkCardTalk => ({
+ startTime: '10:00',
+ endTime: '10:45',
+ scheduleDate: '2025-09-15',
+ trackTitle: 'Main Stage',
+ trackIndex: 0,
+ talk: {
+ _id: 'talk-1',
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Building Scalable Cloud Native Applications with Kubernetes',
+ description: convertStringToPortableTextBlocks(
+ 'In this talk, we will explore best practices for building and deploying scalable applications on Kubernetes. We will cover topics like horizontal pod autoscaling, resource management, and observability patterns that help you build resilient systems.',
+ ),
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [Audience.developer, Audience.architect],
+ status: Status.confirmed,
+ outline: '',
+ topics: mockTopics,
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+ ...overrides,
+})
+
+const createServiceSession = (
+ overrides: Partial = {},
+): TalkCardTalk => ({
+ startTime: '09:00',
+ endTime: '09:30',
+ scheduleDate: '2025-09-15',
+ trackTitle: 'Main Stage',
+ trackIndex: 0,
+ placeholder: 'Registration & Welcome Coffee',
+ ...overrides,
+})
+
+const meta = {
+ title: 'Systems/Program/TalkCard',
+ component: TalkCard,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Displays a talk in the program schedule with speaker information, time, track, format badges, and expandable description. Supports different states: confirmed, TBA, cancelled, happening now/soon, and past. Part of the program grid/list views.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+
+
+ ),
+ ],
+ argTypes: {
+ showDate: {
+ control: 'boolean',
+ description: 'Show the date in the card',
+ },
+ showTrack: {
+ control: 'boolean',
+ description: 'Show the track name in the card',
+ },
+ compact: {
+ control: 'boolean',
+ description: 'Use compact layout',
+ },
+ fixedHeight: {
+ control: 'boolean',
+ description: 'Use fixed height (clips content)',
+ },
+ status: {
+ control: 'select',
+ options: [undefined, 'past', 'happening-now', 'happening-soon'],
+ description: 'Talk status indicator',
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ talk: createMockTalk(),
+ },
+}
+
+export const WithDateAndTrack: Story = {
+ args: {
+ talk: createMockTalk(),
+ showDate: true,
+ showTrack: true,
+ },
+}
+
+export const Compact: Story = {
+ args: {
+ talk: createMockTalk(),
+ compact: true,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Compact mode hides description and uses smaller typography.',
+ },
+ },
+ },
+}
+
+export const HappeningNow: Story = {
+ args: {
+ talk: createMockTalk(),
+ status: 'happening-now',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Shows a pulsing green indicator when the talk is currently happening.',
+ },
+ },
+ },
+}
+
+export const HappeningSoon: Story = {
+ args: {
+ talk: createMockTalk(),
+ status: 'happening-soon',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Shows a pulsing yellow indicator when the talk is about to start.',
+ },
+ },
+ },
+}
+
+export const Past: Story = {
+ args: {
+ talk: createMockTalk(),
+ status: 'past',
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Past talks are shown with reduced opacity.',
+ },
+ },
+ },
+}
+
+export const ServiceSession: Story = {
+ args: {
+ talk: createServiceSession(),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Service sessions like breaks, registration, and lunch use a placeholder instead of talk data.',
+ },
+ },
+ },
+}
+
+export const ServiceSessionLunch: Story = {
+ args: {
+ talk: createServiceSession({
+ startTime: '12:00',
+ endTime: '13:00',
+ placeholder: 'Lunch Break',
+ }),
+ },
+}
+
+export const TBA: Story = {
+ args: {
+ talk: createMockTalk({
+ talk: {
+ _id: 'talk-tba',
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'TBA',
+ description: [],
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [],
+ status: Status.submitted,
+ outline: '',
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+ }),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Talks that are scheduled but not yet confirmed show a TBA indicator.',
+ },
+ },
+ },
+}
+
+export const Cancelled: Story = {
+ args: {
+ talk: createMockTalk({
+ talk: {
+ _id: 'talk-cancelled',
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Cancelled Talk',
+ description: [],
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [],
+ status: Status.withdrawn,
+ outline: '',
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+ }),
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Withdrawn or rejected talks display a cancelled state with distinctive styling.',
+ },
+ },
+ },
+}
+
+export const SingleSpeaker: Story = {
+ args: {
+ talk: createMockTalk({
+ talk: {
+ _id: 'talk-single',
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Solo Presentation on Cloud Native Patterns',
+ description: convertStringToPortableTextBlocks(
+ 'A deep dive into cloud native architectural patterns.',
+ ),
+ language: Language.english,
+ format: Format.presentation_45,
+ level: Level.intermediate,
+ audiences: [Audience.developer],
+ status: Status.confirmed,
+ outline: '',
+ topics: [mockTopics[0]],
+ tos: true,
+ speakers: [mockSpeakers[0]],
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+ }),
+ },
+}
+
+export const LightningTalk: Story = {
+ args: {
+ talk: createMockTalk({
+ startTime: '14:00',
+ endTime: '14:10',
+ talk: {
+ _id: 'talk-lightning',
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: '5 Tips for Better Kubernetes Debugging',
+ description: convertStringToPortableTextBlocks(
+ 'Quick tips for debugging Kubernetes applications effectively.',
+ ),
+ language: Language.english,
+ format: Format.lightning_10,
+ level: Level.beginner,
+ audiences: [Audience.developer],
+ status: Status.confirmed,
+ outline: '',
+ topics: [mockTopics[0]],
+ tos: true,
+ speakers: [mockSpeakers[0]],
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+ }),
+ },
+}
+
+export const Workshop: Story = {
+ args: {
+ talk: createMockTalk({
+ startTime: '09:00',
+ endTime: '12:00',
+ talk: {
+ _id: 'talk-workshop',
+ _rev: '1',
+ _type: 'talk',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ title: 'Hands-on Kubernetes Workshop',
+ description: convertStringToPortableTextBlocks(
+ 'Learn Kubernetes from scratch in this hands-on workshop. Bring your laptop!',
+ ),
+ language: Language.english,
+ format: Format.workshop_120,
+ level: Level.beginner,
+ audiences: [Audience.developer, Audience.operator],
+ status: Status.confirmed,
+ outline: '',
+ topics: mockTopics,
+ tos: true,
+ speakers: mockSpeakers,
+ conference: { _id: 'conf-2025', _ref: 'conf-2025', _type: 'reference' },
+ },
+ }),
+ showDate: true,
+ showTrack: true,
+ },
+}
+
+export const ProgramGrid: Story = {
+ args: {
+ talk: createMockTalk(),
+ },
+ render: () => (
+
+
+ Program Schedule
+
+
+
+
+
+
+
+
+
+
+ ),
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'Example of multiple TalkCards in a grid layout, showing various states.',
+ },
+ },
+ },
+}
diff --git a/src/components/program/ViewModeSelector.stories.tsx b/src/components/program/ViewModeSelector.stories.tsx
new file mode 100644
index 00000000..6d0aeeae
--- /dev/null
+++ b/src/components/program/ViewModeSelector.stories.tsx
@@ -0,0 +1,139 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ViewModeSelector } from './ViewModeSelector'
+import { useState } from 'react'
+import {
+ type ProgramViewMode,
+ type ViewModeConfig,
+} from '@/hooks/useProgramViewMode'
+
+const defaultViewModes: ViewModeConfig[] = [
+ {
+ id: 'schedule',
+ label: 'Schedule View',
+ description: 'View the full schedule with time slots',
+ icon: 'calendar',
+ suitableFor: ['desktop', 'tablet'],
+ },
+ {
+ id: 'grid',
+ label: 'Card Grid',
+ description: 'Browse sessions in a card layout',
+ icon: 'grid',
+ suitableFor: ['desktop', 'tablet', 'mobile'],
+ },
+ {
+ id: 'list',
+ label: 'List View',
+ description: 'Compact list of all sessions',
+ icon: 'list',
+ suitableFor: ['desktop', 'tablet', 'mobile'],
+ },
+ {
+ id: 'agenda',
+ label: 'Personal Agenda',
+ description: 'Your bookmarked sessions',
+ icon: 'bookmark',
+ suitableFor: ['desktop', 'tablet', 'mobile'],
+ },
+]
+
+const meta = {
+ title: 'Systems/Program/ViewModeSelector',
+ component: ViewModeSelector,
+ parameters: {
+ layout: 'centered',
+ docs: {
+ description: {
+ component:
+ 'A view mode toggle for the program page, allowing users to switch between schedule, grid, list, and personal agenda views.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ viewMode: 'schedule',
+ viewModes: defaultViewModes,
+ onViewModeChange: () => {},
+ currentViewConfig: defaultViewModes[0],
+ },
+}
+
+export const GridSelected: Story = {
+ args: {
+ viewMode: 'grid',
+ viewModes: defaultViewModes,
+ onViewModeChange: () => {},
+ currentViewConfig: defaultViewModes[1],
+ },
+}
+
+export const ListSelected: Story = {
+ args: {
+ viewMode: 'list',
+ viewModes: defaultViewModes,
+ onViewModeChange: () => {},
+ currentViewConfig: defaultViewModes[2],
+ },
+}
+
+export const BookmarkSelected: Story = {
+ args: {
+ viewMode: 'agenda',
+ viewModes: defaultViewModes,
+ onViewModeChange: () => {},
+ currentViewConfig: defaultViewModes[3],
+ },
+}
+
+export const ThreeOptions: Story = {
+ args: {
+ viewMode: 'schedule',
+ viewModes: defaultViewModes.slice(0, 3),
+ onViewModeChange: () => {},
+ currentViewConfig: defaultViewModes[0],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'With only three view modes (without personal agenda).',
+ },
+ },
+ },
+}
+
+export const Interactive: Story = {
+ args: {
+ viewMode: 'schedule',
+ viewModes: defaultViewModes,
+ onViewModeChange: () => {},
+ currentViewConfig: defaultViewModes[0],
+ },
+ render: (args) => {
+ const InteractiveDemo = () => {
+ const [viewMode, setViewMode] = useState('schedule')
+ const currentConfig = defaultViewModes.find((m) => m.id === viewMode)!
+
+ return (
+
+
+
+ Selected: {currentConfig.label} -{' '}
+ {currentConfig.description}
+
+
+ )
+ }
+ return
+ },
+}
diff --git a/src/components/proposal/AttachmentDisplay.stories.tsx b/src/components/proposal/AttachmentDisplay.stories.tsx
new file mode 100644
index 00000000..0bc9d6a4
--- /dev/null
+++ b/src/components/proposal/AttachmentDisplay.stories.tsx
@@ -0,0 +1,171 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { AttachmentDisplay } from './AttachmentDisplay'
+import type { Attachment } from '@/lib/attachment/types'
+
+const slidesAttachment: Attachment = {
+ _key: 'slides-1',
+ _type: 'urlAttachment',
+ attachmentType: 'slides',
+ title: 'Presentation Slides',
+ description: 'PDF version of the talk slides',
+ url: 'https://speakerdeck.com/example/my-talk',
+}
+
+const recordingAttachment: Attachment = {
+ _key: 'recording-1',
+ _type: 'urlAttachment',
+ attachmentType: 'recording',
+ title: 'Talk Recording',
+ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+}
+
+const resourceAttachment: Attachment = {
+ _key: 'resource-1',
+ _type: 'urlAttachment',
+ attachmentType: 'resource',
+ title: 'GitHub Repository',
+ description: 'Demo code and examples',
+ url: 'https://github.com/example/demo',
+}
+
+const fileAttachment: Attachment = {
+ _key: 'file-1',
+ _type: 'fileAttachment',
+ attachmentType: 'slides',
+ title: 'Downloadable Slides',
+ filename: 'talk-slides.pdf',
+ url: '/api/files/talk-slides.pdf',
+ file: {
+ _type: 'file',
+ asset: {
+ _ref: 'file-abc123',
+ _type: 'reference',
+ },
+ },
+}
+
+const meta = {
+ title: 'Systems/Proposals/AttachmentDisplay',
+ component: AttachmentDisplay,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Displays talk attachments organized by type: slides, recordings (with video embeds), and additional resources.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+ decorators: [
+ (Story: React.ComponentType) => (
+
+
+
+ ),
+ ],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const SlidesOnly: Story = {
+ args: {
+ attachments: [slidesAttachment, fileAttachment],
+ },
+}
+
+export const RecordingOnly: Story = {
+ args: {
+ attachments: [recordingAttachment],
+ },
+}
+
+export const ResourcesOnly: Story = {
+ args: {
+ attachments: [resourceAttachment],
+ },
+}
+
+export const AllTypes: Story = {
+ args: {
+ attachments: [
+ slidesAttachment,
+ fileAttachment,
+ recordingAttachment,
+ resourceAttachment,
+ ],
+ },
+}
+
+export const WithoutVideoEmbed: Story = {
+ args: {
+ attachments: [recordingAttachment],
+ showVideos: false,
+ },
+ parameters: {
+ docs: {
+ description: {
+ story:
+ 'With video embedding disabled - shows recording as a link instead.',
+ },
+ },
+ },
+}
+
+export const MultipleRecordings: Story = {
+ args: {
+ attachments: [
+ {
+ _key: 'recording-1',
+ _type: 'urlAttachment',
+ attachmentType: 'recording',
+ title: 'Full Session Recording',
+ url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
+ },
+ {
+ _key: 'recording-2',
+ _type: 'urlAttachment',
+ attachmentType: 'recording',
+ title: 'Q&A Session',
+ url: 'https://vimeo.com/123456789',
+ },
+ ],
+ },
+}
+
+export const MultipleResources: Story = {
+ args: {
+ attachments: [
+ resourceAttachment,
+ {
+ _key: 'resource-2',
+ _type: 'urlAttachment',
+ attachmentType: 'resource',
+ title: 'Blog Post',
+ description: 'Deep dive article on the topic',
+ url: 'https://blog.example.com/my-talk',
+ },
+ {
+ _key: 'resource-3',
+ _type: 'urlAttachment',
+ attachmentType: 'resource',
+ title: 'Companion Documentation',
+ url: 'https://docs.example.com',
+ },
+ ],
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ attachments: [],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Returns null when there are no attachments.',
+ },
+ },
+ },
+}
diff --git a/src/components/branding/SpeakerSharingActions.tsx b/src/components/speaker/SpeakerSharingActions.tsx
similarity index 100%
rename from src/components/branding/SpeakerSharingActions.tsx
rename to src/components/speaker/SpeakerSharingActions.tsx
diff --git a/src/components/sponsor/SponsorOnboardingForm.stories.tsx b/src/components/sponsor/SponsorOnboardingForm.stories.tsx
new file mode 100644
index 00000000..1ad2d042
--- /dev/null
+++ b/src/components/sponsor/SponsorOnboardingForm.stories.tsx
@@ -0,0 +1,224 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { http, HttpResponse, delay } from 'msw'
+import { SponsorOnboardingForm } from './SponsorOnboardingForm'
+
+const meta = {
+ title: 'Systems/Sponsors/Onboarding/SponsorOnboardingForm',
+ component: SponsorOnboardingForm,
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Form for sponsors to complete their onboarding by providing contact information, billing details, and company information.',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const mockSponsorData = {
+ _id: 'sfc-123',
+ sponsorName: 'Acme Corp',
+ sponsorWebsite: 'https://acme.example.com',
+ sponsorLogo: null,
+ sponsorLogoBright: null,
+ sponsorOrgNumber: null,
+ sponsorAddress: null,
+ tierTitle: 'Gold',
+ conferenceName: 'Cloud Native Days Bergen 2026',
+ conferenceStartDate: '2026-10-15',
+ contactPersons: [],
+ billing: null,
+ onboardingComplete: false,
+}
+
+const mockSponsorWithExistingData = {
+ ...mockSponsorData,
+ sponsorOrgNumber: '123 456 789',
+ sponsorAddress: 'Innovation Street 42, 5020 Bergen, Norway',
+ contactPersons: [
+ {
+ _key: 'contact-1',
+ name: 'Jane Smith',
+ email: 'jane@acme.example.com',
+ phone: '+47 123 45 678',
+ role: 'Marketing',
+ isPrimary: true,
+ },
+ ],
+ billing: {
+ email: 'billing@acme.example.com',
+ reference: 'PO-2026-001',
+ comments: 'Please send invoice before end of month',
+ },
+}
+
+const mockCompletedSponsor = {
+ ...mockSponsorWithExistingData,
+ onboardingComplete: true,
+}
+
+function createTRPCBatchResponse(results: unknown[]) {
+ return results.map((result) => ({
+ result: {
+ data: result,
+ },
+ }))
+}
+
+function createTRPCBatchError(message: string, code: string) {
+ return [
+ {
+ error: {
+ message,
+ code: -32600,
+ data: {
+ code,
+ httpStatus: code === 'NOT_FOUND' ? 404 : 500,
+ },
+ },
+ },
+ ]
+}
+
+/**
+ * Default state showing the onboarding form for a new sponsor without any pre-filled data.
+ */
+export const Default: Story = {
+ args: {
+ token: 'valid-token-123',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.validate', () => {
+ return HttpResponse.json(createTRPCBatchResponse([mockSponsorData]))
+ }),
+ http.post('/api/trpc/onboarding.complete', async () => {
+ await delay(1000)
+ return HttpResponse.json(createTRPCBatchResponse([{ success: true }]))
+ }),
+ ],
+ },
+ },
+}
+
+/**
+ * Form pre-populated with existing sponsor data from a previous partial submission.
+ */
+export const WithExistingData: Story = {
+ args: {
+ token: 'existing-data-token',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.validate', () => {
+ return HttpResponse.json(
+ createTRPCBatchResponse([mockSponsorWithExistingData]),
+ )
+ }),
+ http.post('/api/trpc/onboarding.complete', async () => {
+ await delay(1000)
+ return HttpResponse.json(createTRPCBatchResponse([{ success: true }]))
+ }),
+ ],
+ },
+ },
+}
+
+/**
+ * Loading state while validating the onboarding token.
+ */
+export const Loading: Story = {
+ args: {
+ token: 'loading-token',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.validate', async () => {
+ await delay(999999)
+ return HttpResponse.json(createTRPCBatchResponse([mockSponsorData]))
+ }),
+ ],
+ },
+ },
+}
+
+/**
+ * Error state when the onboarding token is invalid or expired.
+ */
+export const InvalidToken: Story = {
+ args: {
+ token: 'invalid-token',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.validate', () => {
+ return HttpResponse.json(
+ createTRPCBatchError(
+ 'Invalid or expired onboarding token',
+ 'NOT_FOUND',
+ ),
+ )
+ }),
+ ],
+ },
+ },
+}
+
+/**
+ * Success state after onboarding has already been completed.
+ */
+export const AlreadyCompleted: Story = {
+ args: {
+ token: 'completed-token',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.validate', () => {
+ return HttpResponse.json(
+ createTRPCBatchResponse([mockCompletedSponsor]),
+ )
+ }),
+ ],
+ },
+ },
+}
+
+/**
+ * Shows the form for a sponsor without a tier assignment (community partner).
+ */
+export const CommunityPartner: Story = {
+ args: {
+ token: 'community-token',
+ },
+ parameters: {
+ msw: {
+ handlers: [
+ http.get('/api/trpc/onboarding.validate', () => {
+ return HttpResponse.json(
+ createTRPCBatchResponse([
+ {
+ ...mockSponsorData,
+ tierTitle: null,
+ sponsorName: 'Local Tech Meetup',
+ },
+ ]),
+ )
+ }),
+ http.post('/api/trpc/onboarding.complete', async () => {
+ await delay(1000)
+ return HttpResponse.json(createTRPCBatchResponse([{ success: true }]))
+ }),
+ ],
+ },
+ },
+}
diff --git a/src/components/sponsor/SponsorOnboardingForm.tsx b/src/components/sponsor/SponsorOnboardingForm.tsx
new file mode 100644
index 00000000..2f5184b3
--- /dev/null
+++ b/src/components/sponsor/SponsorOnboardingForm.tsx
@@ -0,0 +1,472 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { api } from '@/lib/trpc/client'
+import {
+ CheckCircleIcon,
+ ExclamationTriangleIcon,
+ PlusIcon,
+ TrashIcon,
+ UserIcon,
+} from '@heroicons/react/24/outline'
+import { CONTACT_ROLE_OPTIONS } from '@/lib/sponsor/types'
+
+interface ContactPersonForm {
+ name: string
+ email: string
+ phone: string
+ role: string
+ isPrimary: boolean
+}
+
+interface BillingForm {
+ email: string
+ reference: string
+ comments: string
+}
+
+interface CompanyForm {
+ orgNumber: string
+ address: string
+}
+
+export function SponsorOnboardingForm({ token }: { token: string }) {
+ const {
+ data: sponsor,
+ isLoading,
+ error: fetchError,
+ } = api.onboarding.validate.useQuery({ token })
+
+ const completeMutation = api.onboarding.complete.useMutation({
+ onSuccess: () => setSubmitted(true),
+ onError: (error) => setError(error.message),
+ })
+
+ const [contacts, setContacts] = useState([
+ { name: '', email: '', phone: '', role: '', isPrimary: true },
+ ])
+ const [billing, setBilling] = useState({
+ email: '',
+ reference: '',
+ comments: '',
+ })
+ const [company, setCompany] = useState({
+ orgNumber: '',
+ address: '',
+ })
+ const [error, setError] = useState(null)
+ const [submitted, setSubmitted] = useState(false)
+
+ // Initialize form state from sponsor data when it becomes available
+ // This is intentional - we need to populate the form when async data arrives
+ useEffect(() => {
+ if (!sponsor) return
+
+ /* eslint-disable react-hooks/set-state-in-effect */
+ if (sponsor.contactPersons?.length) {
+ setContacts(
+ sponsor.contactPersons.map((c) => ({
+ name: c.name,
+ email: c.email,
+ phone: String(c.phone || ''),
+ role: String(c.role || ''),
+ isPrimary: Boolean(c.isPrimary),
+ })),
+ )
+ }
+ if (sponsor.billing) {
+ setBilling({
+ email: sponsor.billing.email,
+ reference: sponsor.billing.reference || '',
+ comments: sponsor.billing.comments || '',
+ })
+ }
+ setCompany({
+ orgNumber: sponsor.sponsorOrgNumber || '',
+ address: sponsor.sponsorAddress || '',
+ })
+ /* eslint-enable react-hooks/set-state-in-effect */
+ }, [sponsor])
+
+ if (isLoading) {
+ return (
+
+
+
+
Loading onboarding form…
+
+
+ )
+ }
+
+ if (fetchError) {
+ return (
+
+
+
+ Invalid Onboarding Link
+
+
+ This onboarding link is invalid or has expired. Please contact the
+ event organizers for a new link.
+
+
+ )
+ }
+
+ if (submitted || sponsor?.onboardingComplete) {
+ return (
+
+
+
+ Onboarding Complete
+
+
+ Thank you for completing the sponsor onboarding for{' '}
+ {sponsor?.conferenceName} . The event organizers will
+ be in touch with next steps.
+
+
+ )
+ }
+
+ const addContact = () => {
+ setContacts([
+ ...contacts,
+ { name: '', email: '', phone: '', role: '', isPrimary: false },
+ ])
+ }
+
+ const removeContact = (index: number) => {
+ if (contacts.length <= 1) return
+ const updated = contacts.filter((_, i) => i !== index)
+ if (!updated.some((c) => c.isPrimary) && updated.length > 0) {
+ updated[0].isPrimary = true
+ }
+ setContacts(updated)
+ }
+
+ const updateContact = (
+ index: number,
+ field: keyof ContactPersonForm,
+ value: string | boolean,
+ ) => {
+ const updated = [...contacts]
+ if (field === 'isPrimary' && value === true) {
+ updated.forEach((c) => (c.isPrimary = false))
+ }
+ updated[index] = { ...updated[index], [field]: value }
+ setContacts(updated)
+ }
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ setError(null)
+
+ const hasAtLeastOneContact = contacts.some(
+ (c) => c.name.trim() && c.email.trim(),
+ )
+ if (!hasAtLeastOneContact) {
+ setError(
+ 'Please provide at least one contact person with name and email.',
+ )
+ return
+ }
+
+ if (!billing.email.trim()) {
+ setError('Please provide a billing email address.')
+ return
+ }
+
+ const validContacts = contacts
+ .filter((c) => c.name.trim() && c.email.trim())
+ .map((c, index) => ({
+ _key: `contact-${Date.now()}-${index}`,
+ name: c.name.trim(),
+ email: c.email.trim(),
+ phone: c.phone.trim() || undefined,
+ role: c.role || undefined,
+ isPrimary: c.isPrimary,
+ }))
+
+ completeMutation.mutate({
+ token,
+ contactPersons: validContacts,
+ billing: {
+ email: billing.email.trim(),
+ reference: billing.reference.trim() || undefined,
+ comments: billing.comments.trim() || undefined,
+ },
+ orgNumber: company.orgNumber.trim() || undefined,
+ address: company.address.trim() || undefined,
+ })
+ }
+
+ return (
+
+
+
Sponsor Onboarding
+
+ Welcome, {sponsor?.sponsorName} ! Please complete the
+ form below to finalize your sponsorship for{' '}
+ {sponsor?.conferenceName}
+ {sponsor?.tierTitle && (
+ <>
+ {' '}
+ as a {sponsor.tierTitle} sponsor
+ >
+ )}
+ .
+
+
+
+
+ {error && (
+
+ )}
+
+
+
+ Company Information
+
+
+ Provide your company's registration details for the sponsorship
+ contract.
+
+
+
+
+
+
+
+
+ Contact Persons
+
+
+
+ Add Contact
+
+
+
+ Provide contact details for people involved in the sponsorship. Mark
+ one person as the primary contact.
+
+
+
+ {contacts.map((contact, index) => (
+
+
+
+
+
+ Contact {index + 1}
+
+ {contact.isPrimary && (
+
+ Primary
+
+ )}
+
+
+ {!contact.isPrimary && (
+ updateContact(index, 'isPrimary', true)}
+ className="text-xs text-blue-600 hover:text-blue-800"
+ >
+ Set as primary
+
+ )}
+ {contacts.length > 1 && (
+ removeContact(index)}
+ className="text-gray-400 hover:text-red-500"
+ >
+
+
+ )}
+
+
+
+
+
+
+ Name *
+
+
+ updateContact(index, 'name', e.target.value)
+ }
+ className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
+ required
+ />
+
+
+
+ Email *
+
+
+ updateContact(index, 'email', e.target.value)
+ }
+ className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
+ required
+ />
+
+
+
+ Phone
+
+
+ updateContact(index, 'phone', e.target.value)
+ }
+ className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
+ />
+
+
+
+ Role
+
+
+ updateContact(index, 'role', e.target.value)
+ }
+ className="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
+ >
+ Select role…
+ {CONTACT_ROLE_OPTIONS.map((role) => (
+
+ {role}
+
+ ))}
+
+
+
+
+ ))}
+
+
+
+
+
+ Billing Information
+
+
+ Provide billing details for invoicing purposes.
+
+
+
+
+
+
+
+ {completeMutation.isPending
+ ? 'Submitting\u2026'
+ : 'Complete Onboarding'}
+
+
+
+
+ )
+}
diff --git a/src/components/stream/SponsorBanner.stories.tsx b/src/components/stream/SponsorBanner.stories.tsx
new file mode 100644
index 00000000..baf33afd
--- /dev/null
+++ b/src/components/stream/SponsorBanner.stories.tsx
@@ -0,0 +1,162 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { SponsorBanner } from './SponsorBanner'
+import type { ConferenceSponsor } from '@/lib/sponsor/types'
+
+const mockSponsors: ConferenceSponsor[] = [
+ {
+ _sfcId: 'cs-1',
+ sponsor: {
+ _id: 'sponsor-1',
+ name: 'Equinor',
+ website: 'https://equinor.com',
+ logo: 'EQUINOR ',
+ },
+ tier: {
+ _id: 'tier-1',
+ title: 'Platinum',
+ tagline: 'Top tier sponsor',
+ tierType: 'standard',
+ price: [{ _key: 'nok', amount: 150000, currency: 'NOK' }],
+ },
+ },
+ {
+ _sfcId: 'cs-2',
+ sponsor: {
+ _id: 'sponsor-2',
+ name: 'DNB',
+ website: 'https://dnb.no',
+ logo: 'DNB ',
+ },
+ tier: {
+ _id: 'tier-2',
+ title: 'Gold',
+ tagline: 'Premium sponsor',
+ tierType: 'standard',
+ price: [{ _key: 'nok', amount: 100000, currency: 'NOK' }],
+ },
+ },
+ {
+ _sfcId: 'cs-3',
+ sponsor: {
+ _id: 'sponsor-3',
+ name: 'Bekk',
+ website: 'https://bekk.no',
+ logo: 'BEKK ',
+ },
+ tier: {
+ _id: 'tier-3',
+ title: 'Silver',
+ tagline: 'Supporting sponsor',
+ tierType: 'standard',
+ price: [{ _key: 'nok', amount: 50000, currency: 'NOK' }],
+ },
+ },
+ {
+ _sfcId: 'cs-4',
+ sponsor: {
+ _id: 'sponsor-4',
+ name: 'Bouvet',
+ website: 'https://bouvet.no',
+ logo: 'BOUVET ',
+ },
+ tier: {
+ _id: 'tier-3',
+ title: 'Silver',
+ tagline: 'Supporting sponsor',
+ tierType: 'standard',
+ price: [{ _key: 'nok', amount: 50000, currency: 'NOK' }],
+ },
+ },
+]
+
+const meta: Meta = {
+ title: 'Systems/Sponsors/Stream/SponsorBanner',
+ component: SponsorBanner,
+ tags: ['autodocs'],
+ parameters: {
+ layout: 'padded',
+ docs: {
+ description: {
+ component:
+ 'Animated marquee banner displaying sponsor logos. Used during live streaming to showcase sponsors. Features smooth infinite scrolling animation with configurable speed. Automatically respects reduced motion preferences.',
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ sponsors: mockSponsors,
+ },
+}
+
+export const SlowSpeed: Story = {
+ args: {
+ sponsors: mockSponsors,
+ speed: 60,
+ },
+}
+
+export const FastSpeed: Story = {
+ args: {
+ sponsors: mockSponsors,
+ speed: 15,
+ },
+}
+
+export const SingleSponsor: Story = {
+ args: {
+ sponsors: [mockSponsors[0]],
+ },
+}
+
+export const ManySponsors: Story = {
+ args: {
+ sponsors: [
+ ...mockSponsors,
+ {
+ _sfcId: 'cs-5',
+ sponsor: {
+ _id: 'sponsor-5',
+ name: 'Microsoft',
+ website: 'https://microsoft.com',
+ logo: 'MICROSOFT ',
+ },
+ tier: mockSponsors[0].tier,
+ },
+ {
+ _sfcId: 'cs-6',
+ sponsor: {
+ _id: 'sponsor-6',
+ name: 'Google Cloud',
+ website: 'https://cloud.google.com',
+ logo: 'GOOGLE CLOUD ',
+ },
+ tier: mockSponsors[0].tier,
+ },
+ ],
+ },
+}
+
+export const Empty: Story = {
+ args: {
+ sponsors: [],
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'When no sponsors are provided, the banner renders nothing.',
+ },
+ },
+ },
+}
diff --git a/src/docs/DeveloperGuide.stories.tsx b/src/docs/DeveloperGuide.stories.tsx
new file mode 100644
index 00000000..2ee2106e
--- /dev/null
+++ b/src/docs/DeveloperGuide.stories.tsx
@@ -0,0 +1,534 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ CommandLineIcon,
+ FolderIcon,
+ DocumentTextIcon,
+ BeakerIcon,
+ CheckCircleIcon,
+ WrenchScrewdriverIcon,
+ ExclamationTriangleIcon,
+ LightBulbIcon,
+ PlayIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Getting Started/Developer Guide',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const CodeBlock = ({
+ children,
+ title,
+}: {
+ children: string
+ title?: string
+}) => (
+
+ {title && (
+
+
+ {title}
+
+
+ )}
+
+
+ {children}
+
+
+
+)
+
+const Tip = ({
+ children,
+ variant = 'info',
+}: {
+ children: React.ReactNode
+ variant?: 'info' | 'warning'
+}) => {
+ const isWarning = variant === 'warning'
+ return (
+
+ {isWarning ? (
+
+ ) : (
+
+ )}
+
+ {children}
+
+
+ )
+}
+
+export const DeveloperGuide: Story = {
+ render: () => (
+
+
+
+ Developer Guide
+
+
+ Setup, conventions, and everything you need to contribute to the Cloud
+ Native Days Norway component library.
+
+
+ {/* Prerequisites */}
+
+
+
+ Quick start
+
+
+
+ {`git clone https://github.com/cloudnativebergen/website.git
+cd website
+pnpm install`}
+
+
+ {`pnpm run dev # Next.js dev server (Turbopack)
+pnpm storybook # Storybook on port 6006`}
+
+
+ {`pnpm run check # typecheck + lint + knip + format`}
+
+
+
+ Always run{' '}
+
+ pnpm run check
+ {' '}
+ before pushing. It runs TypeScript type checking, ESLint, Knip
+ (unused exports), and Prettier in sequence.
+
+
+
+ {/* Project Structure */}
+
+
+
+ Project structure
+
+
+ {`src/
+├── components/ # React components
+│ ├── admin/ # Admin interface (organizer-only)
+│ │ ├── sponsor/ # Sponsor management
+│ │ └── sponsor-crm/ # CRM pipeline & boards
+│ ├── program/ # Schedule & agenda views
+│ ├── proposal/ # CFP submission flow
+│ ├── speaker/ # Speaker profiles
+│ ├── email/ # Email templates
+│ └── Button.tsx # Shared components at root
+├── docs/ # Storybook documentation pages
+│ └── design-system/ # Brand, foundation, examples
+├── lib/ # Business logic & utilities
+│ ├── conference/ # Conference config & phases
+│ ├── sponsor/ # Sponsor types & queries
+│ ├── speaker/ # Speaker types & queries
+│ ├── proposal/ # Proposal state machine
+│ └── time.ts # Date/time utilities
+├── server/ # tRPC routers & schemas
+│ ├── routers/ # API route handlers
+│ └── schemas/ # Zod validation schemas
+└── app/ # Next.js App Router pages
+ ├── (main)/ # Public-facing routes
+ └── admin/ # Admin routes`}
+
+
+
+ {/* Coding Conventions */}
+
+
+
+ Coding conventions
+
+
+
+
+ TypeScript
+
+
+ Strict mode enabled. Export prop interfaces for reuse. Use{' '}
+
+ satisfies
+ {' '}
+ for type narrowing. Prefer explicit return types on exported
+ functions.
+
+
+
+
+
+ Tailwind CSS
+
+
+ Utility-first with brand tokens from{' '}
+
+ tailwind.config.ts
+
+ . Use{' '}
+
+ brand-cloud-blue
+
+ ,{' '}
+
+ brand-fresh-green
+
+ , etc. Always support dark mode.
+
+
+
+
+
+ Icons
+
+
+ Always use{' '}
+
+ @heroicons/react
+
+ . Import from{' '}
+
+ /24/outline
+ {' '}
+ for UI chrome,{' '}
+
+ /24/solid
+ {' '}
+ for emphasis. Never create custom SVGs.
+
+
+
+
+
+ Date & time
+
+
+ Use helpers from{' '}
+
+ @/lib/time
+ {' '}
+ instead of raw{' '}
+
+ new Date()
+
+ . Conference dates use Europe/Oslo timezone.
+
+
+
+
+
+ JSX content
+
+
+ Use HTML entities ({' '}
+
+ '
+ {' '}
+
+ "
+
+ ) instead of raw quotes in JSX text.
+
+
+
+
+
+ {/* Available Commands */}
+
+
+
+ Available commands
+
+
+
+
+
+
+ Command
+
+
+ Description
+
+
+
+
+ {[
+ ['pnpm run dev', 'Next.js dev server with Turbopack'],
+ ['pnpm storybook', 'Storybook on localhost:6006'],
+ [
+ 'pnpm run check',
+ 'Full validation: typecheck + lint + knip + format',
+ ],
+ ['pnpm run test', 'Run Jest test suite'],
+ ['pnpm run test:watch', 'Jest in watch mode'],
+ ['pnpm run lint:fix', 'Auto-fix linting issues'],
+ ['pnpm run build', 'Production build'],
+ [
+ 'pnpm run storybook:test-ci',
+ 'Build Storybook and run interaction tests',
+ ],
+ ].map(([cmd, desc]) => (
+
+
+
+ {cmd}
+
+
+
+ {desc}
+
+
+ ))}
+
+
+
+
+
+ Use{' '}
+
+ pnpm sanity
+ {' '}
+ for Sanity CLI commands — never{' '}
+
+ npx sanity
+
+ .
+
+
+
+
+ {/* Writing Stories */}
+
+
+
+ Writing stories
+
+
+
+ Story files live alongside their components. Use{' '}
+
+ tags: ['autodocs']
+ {' '}
+ to auto-generate a docs page from props. Documentation-only stories
+ (like architecture pages) go in{' '}
+
+ src/docs/
+
+ .
+
+
+
+
+ {`import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { MyWidget } from './MyWidget'
+
+const meta = {
+ title: 'Components/Layout/MyWidget',
+ component: MyWidget,
+ tags: ['autodocs'],
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: { variant: 'primary' },
+}
+
+export const WithIcon: Story = {
+ args: { variant: 'primary', icon: true },
+}`}
+
+
+
+ {`import { expect, fn, userEvent, within } from 'storybook/test'
+
+export const ClickTest: Story = {
+ args: { onClick: fn() },
+ play: async ({ args, canvasElement }) => {
+ const canvas = within(canvasElement)
+ await userEvent.click(canvas.getByRole('button'))
+ await expect(args.onClick).toHaveBeenCalled()
+ },
+}`}
+
+
+
+
+
+ Story organization
+
+
+
+
+
+
+ Title prefix
+
+
+ What goes here
+
+
+
+
+ {[
+ [
+ 'Components/{Category}/',
+ 'Generic reusable UI (layout, forms, feedback, icons)',
+ ],
+ [
+ 'Systems/{Name}/',
+ 'Domain components (Program, Proposals, Speakers, Sponsors)',
+ ],
+ [
+ 'Systems/{Name}/Admin/',
+ 'Organizer-only admin components for that system',
+ ],
+ [
+ 'Design System/',
+ 'Brand tokens, foundation, integration examples',
+ ],
+ ].map(([prefix, desc]) => (
+
+
+
+ {prefix}
+
+
+
+ {desc}
+
+
+ ))}
+
+
+
+
+ Match the export name to the last segment of the title to avoid
+ Storybook creating a subfolder with a single page. For example,
+ title{' '}
+
+ Brand/Typography
+ {' '}
+ should export{' '}
+
+ Typography
+
+ , not{' '}
+
+ TypographySystem
+
+ .
+
+
+
+
+ {/* Testing */}
+
+
+
+ Testing
+
+
+
+
+ Unit tests
+
+
+ Jest with Testing Library. Tests live in{' '}
+
+ __tests__/
+ {' '}
+ mirroring the{' '}
+
+ src/
+ {' '}
+ structure. Run with{' '}
+
+ pnpm run test
+
+ .
+
+
+
+
+ Storybook interaction tests
+
+
+ Stories with{' '}
+
+ play
+ {' '}
+ functions are tested automatically in CI via{' '}
+
+ pnpm run storybook:test-ci
+
+ . Run locally with{' '}
+
+ pnpm run storybook:test
+ {' '}
+ (requires Storybook running).
+
+
+
+
+ Visual regression
+
+
+ Chromatic runs on every PR, comparing visual snapshots against
+ the main branch. Changes to main are auto-accepted as the new
+ baseline.
+
+
+
+
+
+ {/* Best Practices */}
+
+
+
+ Best practices
+
+
+ {[
+ 'Run pnpm run check before every commit',
+ 'Support both light and dark themes',
+ 'Keep components focused on a single responsibility',
+ 'Use semantic HTML for accessibility',
+ 'Wrap admin components with NotificationProvider in stories',
+ 'Export TypeScript interfaces for component props',
+ 'Minimize comments — prefer self-documenting code',
+ 'Use Heroicons, never custom SVGs',
+ ].map((practice, index) => (
+
+
+
+ {practice}
+
+
+ ))}
+
+
+
+
+ ),
+}
diff --git a/src/docs/ProgramSystem.stories.tsx b/src/docs/ProgramSystem.stories.tsx
new file mode 100644
index 00000000..0dafb1c5
--- /dev/null
+++ b/src/docs/ProgramSystem.stories.tsx
@@ -0,0 +1,374 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Systems/Program',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Architecture: Story = {
+ render: () => (
+
+
+
+ Program System
+
+
+ The program system manages conference schedules, time-grid views, and
+ live session tracking. It supports multiple view modes, real-time
+ status updates, and a drag-and-drop admin schedule editor.
+
+
+ {/* Data Model */}
+
+
+ Data Model
+
+
+
+
+ schedule
+
+
+ One document per conference day. Contains tracks with time
+ slots.
+
+
+
+
+
+
+
+
+
+
+
+ scheduledTalk (inline object)
+
+
+ A time slot within a track. Links to a talk or uses a
+ placeholder for service sessions.
+
+
+
+
+
+
+
+
+
+
+
+ talk (referenced)
+
+
+ Shared with the Proposals system. Only{' '}
+
+ confirmed
+ {' '}
+ talks appear on the public schedule.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* View Modes */}
+
+
+ View Modes
+
+
+
+
+
+
+
+
+
+ {/* Live Features */}
+
+
+ Live Session Tracking
+
+
+ During the conference, the program automatically tracks session
+ status and highlights the current talk.
+
+
+
+
+
+
+
+
+
+ {/* Component Hierarchy */}
+
+
+ Component Hierarchy
+
+
+
+ {`/program (Server Component, cached)
+└── ProgramClient (client orchestrator)
+ ├── ProgramFilters
+ │ └── ViewModeSelector
+ ├── ProgramScheduleView (default)
+ │ ├── ScheduleTabbed (mobile)
+ │ └── ScheduleStatic (desktop grid)
+ ├── ProgramGridView
+ ├── ProgramListView
+ ├── ProgramAgendaView (bookmarks only)
+ └── TalkCard (core card component)`}
+
+
+
+
+ {/* Client Hooks */}
+
+
+ Client Hooks
+
+
+
+
+
+
+
+
+
+ {/* Admin */}
+
+
+ Admin: Schedule Editor
+
+
+ Drag-and-drop interface for building the conference schedule from
+ accepted proposals.
+
+
+
+
+
+
+
+
+ Editor Architecture
+
+
+
+ • ScheduleEditor — main orchestrator with track
+ columns and sidebar
+
+
+ • DroppableTrack — drop zone for a single track
+
+
+ • DraggableProposal — draggable
+ accepted/confirmed talks
+
+
+ • DraggableServiceSession — draggable
+ placeholders (breaks, lunch)
+
+
+ • UnassignedProposals — sidebar of proposals
+ not yet on schedule
+
+
+ • Auto-creates empty schedule documents for each conference day
+
+
+ • Saves via POST to{' '}
+ /admin/api/schedule then
+ invalidates cache
+
+
+
+
+
+ {/* Data Flow */}
+
+
+ Data Flow
+
+
+
+ {`Sanity (schedule + talk documents)
+ ↓ GROQ query (confirmedTalksOnly on public)
+Server Component — cacheLife('hours'), cacheTag('content:program')
+ ↓ props
+ProgramClient
+ ↓ hooks (filter, viewMode, live status, bookmarks)
+View Components (Schedule / Grid / List / Agenda)
+ ↓
+TalkCard — renders individual talks with status, badges, bookmarks`}
+
+
+
+
+
+ ),
+}
+
+function Field({ name, desc }: { name: string; desc: string }) {
+ return (
+
+
+ {name}
+
+
+ {desc}
+
+
+ )
+}
+
+function ViewModeCard({
+ name,
+ desc,
+ badge,
+}: {
+ name: string
+ desc: string
+ badge: string
+}) {
+ return (
+
+
+
+ {name}
+
+ {badge && (
+
+ {badge}
+
+ )}
+
+
+ {desc}
+
+
+ )
+}
+
+function StatusCard({
+ label,
+ color,
+ desc,
+}: {
+ label: string
+ color: string
+ desc: string
+}) {
+ return (
+
+ )
+}
+
+function RouteCard({ path, label }: { path: string; label: string }) {
+ return (
+
+ )
+}
+
+function HookCard({ name, desc }: { name: string; desc: string }) {
+ return (
+
+
+ {name}
+
+
+ {desc}
+
+
+ )
+}
diff --git a/src/docs/ProposalSystem.stories.tsx b/src/docs/ProposalSystem.stories.tsx
new file mode 100644
index 00000000..7c72a74f
--- /dev/null
+++ b/src/docs/ProposalSystem.stories.tsx
@@ -0,0 +1,505 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Systems/Proposals',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Architecture: Story = {
+ render: () => (
+
+
+
+ Proposals & CFP System
+
+
+ Manages the full lifecycle of talk proposals — from Call for Papers
+ submission through peer review, acceptance, and confirmation. Includes
+ co-speaker invitations, attachment management, and email
+ notifications.
+
+
+ {/* Data Model */}
+
+
+ Data Model
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Status State Machine */}
+
+
+ Proposal Lifecycle
+
+
+ Status transitions are enforced by the{' '}
+
+ actionStateMachine()
+ {' '}
+ function. Speaker and organizer actions are distinct.
+
+
+ {/* Visual Status Flow */}
+
+
+
+ Rejected
+
+
+ Withdrawn
+
+
+ Deleted
+
+
+
+ {/* Transition Table */}
+
+
+
+
+
+ From
+
+
+ Action
+
+
+ To
+
+
+ Who
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Review System */}
+
+
+ Review System
+
+
+
+
+ Scoring
+
+
+
+ • Content — quality and depth (0-10)
+
+
+ • Relevance — topic fit for conference (0-10)
+
+
+ • Speaker — experience and delivery (0-10)
+
+ • Max 30 points per review
+
+ • Average rating normalized to 0-5 stars:{' '}
+
+ (total / (reviewCount × 15)) × 5
+
+
+
+
+
+
+ Workflow
+
+
+
+ • Organizers review via{' '}
+ /admin/proposals/[id]
+
+ • One review per organizer per proposal
+
+ • "Next unreviewed" button for efficient review
+ queues
+
+
+ • Reviews are created/updated via REST API, fetched inline
+ with GROQ
+
+
+ • Status changes trigger email notifications to speakers
+
+
+
+
+
+
+ {/* Routes */}
+
+
+ Routes
+
+
+
+
+ Speaker (Public)
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* tRPC API */}
+
+
+ tRPC API
+
+
+
+
+ Speaker Procedures
+
+
+
+ proposal.list — list own proposals
+
+
+ proposal.getById — get single proposal
+
+
+ proposal.create — create (max 3/conference, CFP
+ must be open)
+
+
+ proposal.update — edit (CFP must be open)
+
+
+ proposal.action — state machine transitions
+
+
+ proposal.uploadAttachment
+
+
+ proposal.invitation.send/respond/list/cancel
+
+
+
+
+
+ Admin Procedures
+
+
+
+ proposal.admin.list — all proposals with reviews
+
+
+ proposal.admin.getById — full detail
+
+
+ proposal.admin.create/update/delete
+
+
+ proposal.admin.updateAudienceFeedback
+
+
+ proposal.admin.updateAttachments
+
+
+ proposals.searchTalks — search for featured talk
+ selection
+
+
+
+
+
+
+ {/* Data Flow */}
+
+
+ Data Flow
+
+
+
+ {`Speaker submits proposal
+ → ProposalForm → tRPC proposal.create → Sanity (talk document)
+
+Organizer reviews
+ → /admin/proposals/[id] → ProposalReviewForm
+ → POST /admin/api/proposals/[id]/review → Sanity (review document)
+
+Status transitions
+ → AdminActionBar → tRPC proposal.action
+ → actionStateMachine() → updateProposalStatus()
+ → eventBus → email notification (accept/reject templates)
+
+Co-speaker invitations
+ → tRPC proposal.invitation.send → token email
+ → Invitee clicks link → proposal.invitation.respond → links speaker`}
+
+
+
+
+
+ ),
+}
+
+function SchemaCard({
+ name,
+ desc,
+ fields,
+}: {
+ name: string
+ desc: string
+ fields: [string, string][]
+}) {
+ return (
+
+
+ {name}
+
+
+ {desc}
+
+
+ {fields.map(([fieldName, fieldDesc]) => (
+
+
+ {fieldName}
+
+
+ {fieldDesc}
+
+
+ ))}
+
+
+ )
+}
+
+function StatusBadge({ label, color }: { label: string; color: string }) {
+ return (
+
+ {label}
+
+ )
+}
+
+function Arrow() {
+ return (
+ →
+ )
+}
+
+function TransitionRow({
+ from,
+ action,
+ to,
+ who,
+}: {
+ from: string
+ action: string
+ to: string
+ who: string
+}) {
+ return (
+
+ {from}
+ {action}
+ {to}
+
+
+ {who}
+
+
+
+ )
+}
+
+function RouteCard({ path, label }: { path: string; label: string }) {
+ return (
+
+ )
+}
diff --git a/src/docs/SpeakerSystem.stories.tsx b/src/docs/SpeakerSystem.stories.tsx
new file mode 100644
index 00000000..2873a066
--- /dev/null
+++ b/src/docs/SpeakerSystem.stories.tsx
@@ -0,0 +1,572 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Systems/Speakers',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Architecture: Story = {
+ render: () => (
+
+
+
+ Speakers System
+
+
+ Speakers are the identity backbone of the platform. The speaker
+ document doubles as the user account — OAuth sign-in creates or links
+ a speaker profile. The system manages public profiles, admin
+ management, OpenBadges credentials, and travel support.
+
+
+ {/* Data Model */}
+
+
+ Data Model
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Authentication */}
+
+
+ Authentication
+
+
+ Speaker IS the identity model. NextAuth.js 5.0 with JWT strategy.
+ OAuth sign-in triggers{' '}
+
+ getOrCreateSpeaker()
+ {' '}
+ which resolves or creates the speaker document.
+
+
+
+
+ Identity Resolution Flow
+
+
+
+
+
+
+
+
+
+
+
+
+ JWT Session
+
+
+
+ • Providers: GitHub, LinkedIn
+
+
+ • Token stores: speaker._id, name, email, image, isOrganizer,
+ flags
+
+
+ • Session exposes{' '}
+ session.speaker and{' '}
+ session.account
+
+
+ • Legacy is_organizer →{' '}
+ isOrganizer auto-migration
+
+
+
+
+
+ Access Control
+
+
+
+ • isOrganizer: true = admin
+ access
+
+ • Admin routes protected by middleware checking session
+
+ • tRPC procedures use{' '}
+ protectedProcedure and{' '}
+ adminProcedure
+
+
+ • Dev-only impersonation via{' '}
+ ?impersonate=speakerId
+
+
+
+
+
+
+ {/* Flag System */}
+
+
+ Flag System
+
+
+
+
+
+
+
+
+
+ {/* Routes */}
+
+
+ {/* tRPC API */}
+
+
+ tRPC API
+
+
+
+
+ speakerRouter
+
+
+
+ getCurrent — own profile
+
+
+ update — edit own profile
+
+
+ getEmails — OAuth provider emails
+
+
+ updateEmail — change own email
+
+
+ search — admin: search all
+
+
+ admin.create — create speaker
+
+
+ admin.update — edit any
+
+
+ admin.delete — delete speaker
+
+
+ admin.updateEmail — change any email
+
+
+
+
+
+ badgeRouter
+
+
+
+ issue — issue single badge
+
+
+ bulkIssue — batch issue
+
+
+ list — by conference/speaker
+
+
+ getById — single badge
+
+
+ resendEmail — resend notification
+
+
+ verify — public: verify signature
+
+
+ delete — dev-only
+
+
+
+
+
+ travelSupportRouter
+
+
+
+ getMine — own request
+
+
+ create — submit request
+
+
+ updateBankingDetails
+
+
+ submit — submit for review
+
+
+ addExpense / updateExpense
+
+
+ deleteExpense / deleteReceipt
+
+
+ list — admin: all requests
+
+
+ updateStatus — approve/reject/pay
+
+
+ updateExpenseStatus
+
+
+
+
+
+
+ {/* GDPR */}
+
+
+ GDPR Consent
+
+
+
+
+
+
+
+
+ Each consent tracks granted,{' '}
+ grantedAt, and{' '}
+ privacyPolicyVersion. Consent
+ fields are hidden from non-admin Sanity Studio users.
+
+
+
+ {/* Data Flow */}
+
+
+ Data Flow
+
+
+
+ {`OAuth Sign-In
+ → NextAuth callback → getOrCreateSpeaker()
+ → Resolve by provider → email → create new
+ → JWT token with speaker._id, isOrganizer, flags
+
+Public Profile
+ → /speaker/[slug] → GROQ query with conference filter
+ → Cached: cacheLife('hours'), cacheTag('content:speakers')
+ → Talks, gallery, social links, Bluesky feed
+
+Admin Management
+ → /admin/speakers → SpeakersPageClient
+ → tRPC speaker.admin.* → Sanity CRUD
+ → SpeakerTable (filters/search) + modals (create/edit/email)
+
+Badges
+ → /admin/speakers/badge → badgeRouter.issue
+ → Ed25519 JWT signing → SVG baking → email via Resend
+ → /badge/{badgeId} → public verification
+
+Travel Support
+ → Speaker: create → add expenses + receipts → submit
+ → Admin: review → approve/reject → mark paid
+ → Ownership verified on all speaker mutations`}
+
+
+
+
+
+ ),
+}
+
+function SchemaCard({
+ name,
+ desc,
+ fields,
+}: {
+ name: string
+ desc: string
+ fields: [string, string][]
+}) {
+ return (
+
+
+ {name}
+
+
+ {desc}
+
+
+ {fields.map(([fieldName, fieldDesc]) => (
+
+
+ {fieldName}
+
+
+ {fieldDesc}
+
+
+ ))}
+
+
+ )
+}
+
+function StepCard({
+ num,
+ title,
+ desc,
+}: {
+ num: number
+ title: string
+ desc: string
+}) {
+ return (
+
+
+ {num}
+
+
+
+ {title}
+
+
+ {desc}
+
+
+
+ )
+}
+
+function FlagCard({
+ flag,
+ label,
+ desc,
+ color,
+}: {
+ flag: string
+ label: string
+ desc: string
+ color: string
+}) {
+ return (
+
+
+
+ {flag}
+
+
+ {label}
+
+
+
+ {desc}
+
+
+ )
+}
+
+function ConsentCard({ type, desc }: { type: string; desc: string }) {
+ return (
+
+
+ {type}
+
+
+ {desc}
+
+
+ )
+}
+
+function RouteCard({ path, label }: { path: string; label: string }) {
+ return (
+
+ )
+}
diff --git a/src/docs/SponsorComponentIndex.stories.tsx b/src/docs/SponsorComponentIndex.stories.tsx
new file mode 100644
index 00000000..19752b98
--- /dev/null
+++ b/src/docs/SponsorComponentIndex.stories.tsx
@@ -0,0 +1,374 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Systems/Sponsors/Admin/Overview',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const ComponentCard = ({
+ name,
+ path,
+ description,
+ hasStory,
+}: {
+ name: string
+ path: string
+ description: string
+ hasStory?: boolean
+}) => (
+
+
+
+ {name}
+
+ {hasStory && (
+
+ Has Story
+
+ )}
+
+
+ {path}
+
+
+ {description}
+
+
+)
+
+export const Overview: Story = {
+ render: () => (
+
+
+
+ Sponsor Components
+
+
+ Complete list of all sponsor-related components organized by category.
+
+
+ {/* Public Display Components */}
+
+
+ Public Display Components
+
+
+ Components used on public-facing pages to display sponsor
+ information.
+
+
+
+
+
+
+
+
+ {/* Dashboard Components */}
+
+
+ Dashboard Components
+
+
+ Components used on the main sponsor admin dashboard.
+
+
+
+
+
+
+
+
+ {/* CRM Pipeline Components */}
+
+
+ CRM Pipeline Components
+
+
+ Components for the Kanban-style sponsor CRM pipeline.
+
+
+
+
+
+
+
+
+
+
+
+ {/* Form Components */}
+
+
+ Form Components
+
+
+ Specialized form inputs for sponsor data entry.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Contact Management */}
+
+
+ Contact Management
+
+
+ Components for managing sponsor contacts and billing information.
+
+
+
+
+
+
+
+
+ {/* Email & Communication */}
+
+
+ Email & Communication
+
+
+ Components for sponsor email communication and templates.
+
+
+
+
+
+
+
+
+
+ {/* Tier Management */}
+
+
+ Tier Management
+
+
+ Components for managing sponsor tiers and assignments.
+
+
+
+
+
+
+
+
+ {/* Onboarding & Contract */}
+
+
+ Onboarding & Contract
+
+
+ Components for sponsor onboarding and contract management.
+
+
+
+
+
+
+
+
+ {/* Utility Components */}
+
+
+ Utility Components
+
+
+ Helper components and utilities used across the sponsor system.
+
+
+
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/SponsorSystem.stories.tsx b/src/docs/SponsorSystem.stories.tsx
new file mode 100644
index 00000000..2470b83b
--- /dev/null
+++ b/src/docs/SponsorSystem.stories.tsx
@@ -0,0 +1,830 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Systems/Sponsors',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Architecture: Story = {
+ render: () => (
+
+
+
+ Sponsor System
+
+
+ Manages the full lifecycle of conference sponsorships — from initial
+ prospecting through contract generation, digital signatures,
+ invoicing, and self-service onboarding. Built on a two-document model
+ separating the conference-independent sponsor entity from
+ per-conference CRM records.
+
+
+ {/* Data Model */}
+
+
+ Data Model
+
+
+
+
+
+
+
+
+ sponsorForConference
+
+
+ CRM join document linking sponsors to conferences. 29 fields
+ across 7 categories.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Workflows */}
+
+
+ Workflows
+
+
+
+
+ Pipeline
+
+
+
+
+ Closed Lost
+ {' '}
+ can be reached from any stage
+
+
+
+
+
+ Contract Status
+
+
+
+
+
+
+ Signature Status
+
+
+
+
+ Rejected
+
+
+ Expired
+
+
+
+ When signature becomes{' '}
+ Signed , contractStatus
+ is atomically set to Contract Signed .
+
+
+
+
+
+ Invoice Status
+
+
+
+
+ Overdue
+
+
+ Cancelled
+
+
+
+
+
+ {/* Routes */}
+
+
+ Routes
+
+
+
+
+
+ Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* tRPC API */}
+
+
+ tRPC API
+
+
+ 38 procedures across 5 sub-routers. All admin-protected.
+
+
+
+
+ {/* Email System */}
+
+
+ Email System
+
+
+
+
+
+
+
+
+
+ {/* Data Flow */}
+
+
+ Data Flow
+
+
+
+ {`Sponsor onboarding
+ → CRM: create sponsorForConference (auto-assign to current user)
+ → Pipeline: prospect → contacted → negotiating → closed-won
+ → Each stage change logged to sponsorActivity
+
+Contract flow
+ → contractTemplates.findBest → match tier + language
+ → contractTemplates.generatePdf → render with {{{VARIABLE}}} substitution
+ → crm.updateContractStatus → contract-sent
+ → crm.updateSignatureStatus → pending → signed
+ → Signed atomically sets contractStatus → contract-signed + timestamp
+
+Invoicing
+ → crm.updateInvoiceStatus → sent (auto-sets invoiceSentAt)
+ → paid (auto-sets invoicePaidAt) | overdue | cancelled
+
+Self-service onboarding
+ → /sponsor/onboarding/[token] → logo upload, billing, contacts
+ → Sets onboardingComplete + onboardingCompletedAt`}
+
+
+
+
+
+ ),
+}
+
+export const WorkflowDiagram: Story = {
+ render: () => (
+
+
+
+ Sponsor Workflow
+
+
+ {/* Pipeline Flow */}
+
+
+ {/* Contract Workflow */}
+
+
+ Contract Workflow
+
+
+
+
+
+
+
+
+
+ {/* Signature Workflow */}
+
+
+ Signature Workflow
+
+
+
+
+
+
+
+
+
+ Rejected
+
+
+ Signer declined the contract
+
+
+
+
+ Expired
+
+
+ Signature request timed out
+
+
+
+
+
+ {/* Invoice Workflow */}
+
+
+ Invoice Workflow
+
+
+
+
+
+
+
+
+
+ Overdue
+
+
+ Payment past due date
+
+
+
+
+ Cancelled
+
+
+ Invoice voided
+
+
+
+
+
+
+ ),
+}
+
+function SchemaCard({
+ name,
+ desc,
+ fields,
+}: {
+ name: string
+ desc: string
+ fields: [string, string][]
+}) {
+ return (
+
+
+ {name}
+
+
+ {desc}
+
+
+ {fields.map(([fieldName, fieldDesc]) => (
+
+ ))}
+
+
+ )
+}
+
+function FieldGroup({
+ label,
+ fields,
+}: {
+ label: string
+ fields: [string, string][]
+}) {
+ return (
+
+
+ {label}
+
+
+ {fields.map(([fieldName, fieldDesc]) => (
+
+ ))}
+
+
+ )
+}
+
+function Field({ name, desc }: { name: string; desc: string }) {
+ return (
+
+
+ {name}
+
+
+ {desc}
+
+
+ )
+}
+
+function StatusBadge({ label, color }: { label: string; color: string }) {
+ return (
+
+ {label}
+
+ )
+}
+
+function Arrow() {
+ return (
+ →
+ )
+}
+
+function RouteCard({ path, label }: { path: string; label: string }) {
+ return (
+
+ )
+}
+
+function ProcedureCard({
+ name,
+ label,
+ procedures,
+}: {
+ name: string
+ label: string
+ procedures: string[]
+}) {
+ return (
+
+
+ {name}
+
+
+ {label}
+
+
+ {procedures.map((p) => (
+
+ {p}
+
+ ))}
+
+
+ )
+}
+
+function WorkflowStep({
+ num,
+ label,
+ done,
+}: {
+ num: string
+ label: string
+ done?: boolean
+}) {
+ return (
+
+
+
+ {num}
+
+
+
+ {label}
+
+
+ )
+}
+
+function WorkflowRow({
+ num,
+ title,
+ desc,
+ done,
+}: {
+ num: number | string
+ title: string
+ desc: string
+ done?: boolean
+}) {
+ return (
+
+
+ {num}
+
+
+
+ {title}
+
+
+ {desc}
+
+
+
+ )
+}
diff --git a/src/docs/Welcome.stories.tsx b/src/docs/Welcome.stories.tsx
new file mode 100644
index 00000000..8b41d7fa
--- /dev/null
+++ b/src/docs/Welcome.stories.tsx
@@ -0,0 +1,299 @@
+/* eslint-disable @next/next/no-html-link-for-pages */
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ CloudIcon,
+ RocketLaunchIcon,
+ PaintBrushIcon,
+ CodeBracketIcon,
+ Squares2X2Icon,
+ CalendarDaysIcon,
+ ChatBubbleLeftRightIcon,
+ UserGroupIcon,
+ CurrencyDollarIcon,
+ ArrowRightIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Getting Started/Introduction',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const systems = [
+ {
+ name: 'Program',
+ description:
+ 'Schedule views, talk cards, filtering, and multi-format agenda display for conference attendees.',
+ icon: CalendarDaysIcon,
+ color: 'from-brand-cloud-blue to-cyan-500',
+ stories: 9,
+ href: '/?path=/docs/systems-program--docs',
+ },
+ {
+ name: 'Proposals',
+ description:
+ 'Call for Papers submission flow, co-speaker management, admin review pipeline, and featured talks.',
+ icon: ChatBubbleLeftRightIcon,
+ color: 'from-brand-fresh-green to-teal-500',
+ stories: 14,
+ href: '/?path=/docs/systems-proposals--docs',
+ },
+ {
+ name: 'Speakers',
+ description:
+ 'Speaker profiles, avatar components, details forms, admin management, and featured speaker curation.',
+ icon: UserGroupIcon,
+ color: 'from-brand-nordic-purple to-violet-500',
+ stories: 10,
+ href: '/?path=/docs/systems-speakers--docs',
+ },
+ {
+ name: 'Sponsors',
+ description:
+ 'Full CRM pipeline, contract workflows, tiered management, onboarding portal, and email templates.',
+ icon: CurrencyDollarIcon,
+ color: 'from-brand-sunbeam-yellow to-orange-400',
+ stories: 43,
+ href: '/?path=/docs/systems-sponsors--docs',
+ },
+]
+
+const techStack = [
+ { name: 'Next.js', version: '16', url: 'https://nextjs.org' },
+ { name: 'React', version: '19', url: 'https://react.dev' },
+ { name: 'TypeScript', version: '5.9', url: 'https://typescriptlang.org' },
+ { name: 'Tailwind CSS', version: '4', url: 'https://tailwindcss.com' },
+ { name: 'tRPC', version: '11', url: 'https://trpc.io' },
+ { name: 'Sanity', version: '5', url: 'https://sanity.io' },
+ { name: 'Storybook', version: '10', url: 'https://storybook.js.org' },
+ { name: 'Heroicons', version: '2', url: 'https://heroicons.com' },
+]
+
+export const Introduction: Story = {
+ render: () => (
+
+ {/* Hero */}
+
+
+
+
+
+
+ Cloud Native Days Norway
+
+
+
+ Design System &
+
+ Component Library
+
+
+ Interactive documentation for the conference website, admin
+ interfaces, and sponsor portal. Browse live components, review
+ architecture decisions, and build with confidence.
+
+
+
+
+
+ {/* Quick navigation */}
+
+
+ Explore the library
+
+
+ 109 stories across 4 top-level categories.
+
+
+
+
+
+
+ Getting Started
+
+
+ Developer guide with setup instructions, project structure, and
+ coding conventions.
+
+
+ 2 pages
+
+
+
+
+
+
+ Design System
+
+
+ Brand identity, color palette, typography, spacing tokens, and
+ integration examples.
+
+
+ 11 pages
+
+
+
+
+
+
+ Components
+
+
+ Reusable UI building blocks: buttons, modals, form elements,
+ icons, and layout primitives.
+
+
+ 22 pages
+
+
+
+
+
+
+ Systems
+
+
+ Domain-specific features with architecture docs, admin tools, and
+ public-facing components.
+
+
+ 75 pages
+
+
+
+
+
+ {/* Systems overview */}
+
+
+
+ Systems
+
+
+ Each system has an architecture overview, admin interface
+ components, and public-facing UI.
+
+
+
+
+
+
+ {/* Tech stack */}
+
+
+ Tech stack
+
+
+ The tools and frameworks powering this project.
+
+
+
+
+ {/* Footer */}
+
+
+
+ Cloud Native Days Norway • Bergen, Norway
+
+
+ GitHub
+
+
+
+
+ ),
+}
diff --git a/src/components/branding/ButtonShowcase.tsx b/src/docs/components/ButtonShowcase.tsx
similarity index 100%
rename from src/components/branding/ButtonShowcase.tsx
rename to src/docs/components/ButtonShowcase.tsx
diff --git a/src/components/branding/ColorSwatch.tsx b/src/docs/components/ColorSwatch.tsx
similarity index 95%
rename from src/components/branding/ColorSwatch.tsx
rename to src/docs/components/ColorSwatch.tsx
index 370a725e..70f168c4 100644
--- a/src/components/branding/ColorSwatch.tsx
+++ b/src/docs/components/ColorSwatch.tsx
@@ -1,4 +1,4 @@
-import type { ColorDefinition } from '@/lib/branding/data'
+import type { ColorDefinition } from '@/docs/design-system/data'
interface ColorSwatchProps {
color: ColorDefinition
diff --git a/src/components/branding/TypographyShowcase.tsx b/src/docs/components/TypographyShowcase.tsx
similarity index 95%
rename from src/components/branding/TypographyShowcase.tsx
rename to src/docs/components/TypographyShowcase.tsx
index 2433fbd1..75599fd4 100644
--- a/src/components/branding/TypographyShowcase.tsx
+++ b/src/docs/components/TypographyShowcase.tsx
@@ -1,4 +1,4 @@
-import type { TypographyDefinition } from '@/lib/branding/data'
+import type { TypographyDefinition } from '@/docs/design-system/data'
interface TypographyShowcaseProps {
font: TypographyDefinition
diff --git a/src/docs/components/index.ts b/src/docs/components/index.ts
new file mode 100644
index 00000000..7359f151
--- /dev/null
+++ b/src/docs/components/index.ts
@@ -0,0 +1,3 @@
+export { ButtonShowcase } from './ButtonShowcase'
+export { ColorSwatch } from './ColorSwatch'
+export { TypographyShowcase } from './TypographyShowcase'
diff --git a/src/docs/design-system/Icons.stories.tsx b/src/docs/design-system/Icons.stories.tsx
new file mode 100644
index 00000000..849b6dae
--- /dev/null
+++ b/src/docs/design-system/Icons.stories.tsx
@@ -0,0 +1,313 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ CloudIcon,
+ ServerIcon,
+ CubeIcon,
+ CircleStackIcon,
+ GlobeAltIcon,
+ CommandLineIcon,
+ CogIcon,
+ ShieldCheckIcon,
+ ChartBarIcon,
+ BoltIcon,
+ LinkIcon,
+ ArrowPathIcon,
+ CloudArrowUpIcon,
+ RocketLaunchIcon,
+ UserGroupIcon,
+ CalendarIcon,
+ TicketIcon,
+ MicrophoneIcon,
+ VideoCameraIcon,
+ BuildingOfficeIcon,
+} from '@heroicons/react/24/outline'
+import {
+ CloudIcon as CloudSolid,
+ ShieldCheckIcon as ShieldCheckSolid,
+ BoltIcon as BoltSolid,
+} from '@heroicons/react/24/solid'
+
+const meta = {
+ title: 'Design System/Foundation/Icons',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const IconCard = ({
+ name,
+ Icon,
+ usage,
+}: {
+ name: string
+ Icon: React.ComponentType>
+ usage: string
+}) => (
+
+
+
+
+ {name}
+
+
+ {usage}
+
+
+
+)
+
+export const Icons: Story = {
+ render: () => (
+
+
+
+ Icons
+
+
+ Heroicons library with cloud native selections for consistent
+ iconography.
+
+
+ {/* Why Heroicons */}
+
+
+
+ Why Heroicons?
+
+
+ âś“ Created by Tailwind CSS team
+ âś“ Two styles: outline and solid
+ âś“ Full TypeScript support
+ âś“ Tree-shakeable imports
+ âś“ Great cloud/tech icon selection
+ âś“ Consistent 24x24 viewBox
+
+
+
+
+ {/* Platform & Infrastructure */}
+
+
+ Platform & Infrastructure
+
+
+
+
+
+
+
+
+
+
+ {/* Development & Operations */}
+
+
+ Development & Operations
+
+
+
+
+
+
+
+
+
+
+ {/* Connectivity & Flow */}
+
+
+ Connectivity & Flow
+
+
+
+
+
+
+
+
+
+ {/* Conference Specific */}
+
+
+ Conference Specific
+
+
+
+
+
+
+
+
+
+
+
+ {/* Sizes */}
+
+
+ Sizes
+
+
+
+
+
+ h-4 w-4
+
+
+
+
+
+ h-5 w-5
+
+
+
+
+
+ h-6 w-6
+
+
+
+
+
+ h-8 w-8
+
+
+
+
+
+ h-12 w-12
+
+
+
+
+
+ {/* Outline vs Solid */}
+
+
+ Outline vs Solid
+
+
+
+
+ Outline (Default)
+
+
+
+
+
+
+
+ Use for general UI elements, navigation, and content
+
+
+ from '@heroicons/react/24/outline'
+
+
+
+
+
+ Solid (Emphasis)
+
+
+
+
+
+
+
+ Use for status indicators, emphasis, and important highlights
+
+
+ from '@heroicons/react/24/solid'
+
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/design-system/Shadows.stories.tsx b/src/docs/design-system/Shadows.stories.tsx
new file mode 100644
index 00000000..89cd5349
--- /dev/null
+++ b/src/docs/design-system/Shadows.stories.tsx
@@ -0,0 +1,149 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Design System/Foundation/Shadows',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const ShadowCard = ({
+ name,
+ className,
+ code,
+}: {
+ name: string
+ className: string
+ code: string
+}) => (
+
+
+
+ {name}
+
+
+ {code}
+
+
+)
+
+export const Shadows: Story = {
+ render: () => (
+
+
+
+ Shadows
+
+
+ Elevation system for creating visual hierarchy and depth.
+
+
+ {/* Box Shadows */}
+
+
+ Box Shadows
+
+
+
+
+
+
+
+
+
+
+
+ {/* Usage Guidelines */}
+
+
+ Usage Guidelines
+
+
+
+
+ Elevation Levels
+
+
+
+ shadow-sm —
+ Cards at rest, subtle separation
+
+
+ shadow —
+ Default card elevation
+
+
+ shadow-md —
+ Hover states, dropdowns
+
+
+ shadow-lg —
+ Modals, popovers
+
+
+ shadow-xl —
+ Important overlays
+
+
+
+
+
+
+ Interactive Shadows
+
+
+ Use transition utilities for smooth hover effects:
+
+
+ hover:shadow-lg transition-shadow
+
+
+
+
+
+ {/* Interactive Example */}
+
+
+ Interactive Example
+
+
+
+
+ Card Hover
+
+
+ Hover over this card to see the shadow transition
+
+
+
+
+
+ Lift Effect
+
+
+ Combined with translate for lift effect
+
+
+
+
+
+ Colored Shadow
+
+
+ Brand-colored shadow on hover
+
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/design-system/Spacing.stories.tsx b/src/docs/design-system/Spacing.stories.tsx
new file mode 100644
index 00000000..20f68e27
--- /dev/null
+++ b/src/docs/design-system/Spacing.stories.tsx
@@ -0,0 +1,186 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Design System/Foundation/Spacing',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const SpacingBlock = ({ size, pixels }: { size: string; pixels: string }) => (
+
+
{size}
+
+
+ {pixels}
+
+
+)
+
+export const Spacing: Story = {
+ render: () => (
+
+
+
+ Spacing
+
+
+ Consistent spacing scale based on Tailwind CSS defaults for harmonious
+ layouts.
+
+
+ {/* Base Scale */}
+
+
+ Base Scale
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Common Patterns */}
+
+
+ Common Patterns
+
+
+
+
+ Component Padding
+
+
+
+
+ Small buttons
+
+
+ px-3 py-1.5
+
+
+
+
+ Medium buttons
+
+
+ px-4 py-2
+
+
+
+
+ Large buttons
+
+
+ px-6 py-3
+
+
+
+
+ Cards
+
+
+ p-6
+
+
+
+
+
+
+
+ Layout Gaps
+
+
+
+
+ Inline elements
+
+
+ gap-2
+
+
+
+
+ Form fields
+
+
+ gap-4
+
+
+
+
+ Card grids
+
+
+ gap-6
+
+
+
+
+ Page sections
+
+
+ gap-16
+
+
+
+
+
+
+
+ {/* Breakpoints */}
+
+
+ Breakpoints
+
+
+
+ {[
+ { name: 'sm', value: '640px', usage: 'Small tablets' },
+ { name: 'md', value: '768px', usage: 'Tablets' },
+ { name: 'lg', value: '1024px', usage: 'Small laptops' },
+ { name: 'xl', value: '1280px', usage: 'Desktops' },
+ { name: '2xl', value: '1536px', usage: 'Large screens' },
+ ].map((bp) => (
+
+
+
+ {bp.name}:
+
+
+ {bp.value}
+
+
+
+ {bp.usage}
+
+
+ ))}
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/design-system/brand/BrandStory.stories.tsx b/src/docs/design-system/brand/BrandStory.stories.tsx
new file mode 100644
index 00000000..4dcee115
--- /dev/null
+++ b/src/docs/design-system/brand/BrandStory.stories.tsx
@@ -0,0 +1,212 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import {
+ CloudIcon,
+ SparklesIcon,
+ HeartIcon,
+ UsersIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Design System/Brand/Brand Story',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const BrandStory: Story = {
+ render: () => (
+
+ {/* Hero */}
+
+
+
+ Brand Story & Design Principles
+
+
+ Cloud Native Days Norway embodies the spirit of Norway's tech
+ community: innovative yet grounded, collaborative yet independent,
+ modern yet respectful of tradition.
+
+
+
+
+
+ {/* Inspiration */}
+
+
+ Visual Inspiration
+
+
+ Our visual identity draws inspiration from Bergen's dramatic
+ landscapes—the meeting of mountains and sea, the interplay of mist
+ and clarity, the harmony of natural and urban elements.
+
+
+
+
+ Nordic Minimalism
+
+
+ Clean, functional design that lets content shine without
+ unnecessary complexity. Every element serves a purpose.
+
+
+
+
+ Developer-First
+
+
+ Every design choice considers the developer experience and
+ technical audience. We speak their language visually and
+ verbally.
+
+
+
+
+
+ {/* Design Principles */}
+
+
+ Design Principles
+
+
+
+
+
+
+ Developer-First
+
+
+ Every design choice considers the developer experience and
+ technical audience.
+
+
+
+
+
+
+
+
+ Accessible by Design
+
+
+ We prioritize accessibility and inclusive design in all brand
+ applications.
+
+
+
+
+
+
+
+
+ Nordic Minimalism
+
+
+ Clean, functional design that lets content shine without
+ unnecessary complexity.
+
+
+
+
+
+
+
+
+ Community Driven
+
+
+ Our brand reflects the collaborative spirit of the open source
+ community.
+
+
+
+
+
+
+ {/* Voice & Tone */}
+
+
+ Voice & Tone
+
+
+
+
+
+ We Are
+
+
+ âś“ Technical but approachable
+ âś“ Professional but friendly
+ âś“ Confident but humble
+ âś“ Inclusive and welcoming
+
+
+
+
+ We Avoid
+
+
+ âś— Jargon without context
+ âś— Overly formal language
+ âś— Exclusive terminology
+ âś— Marketing speak
+
+
+
+
+ Our Promise
+
+
+ We create an inclusive, educational environment where cloud
+ native enthusiasts can learn, share, and grow together.
+
+
+
+
+
+
+ {/* Accessibility */}
+
+
+ Accessibility Standards
+
+
+
+
+ âś“
+ All components meet WCAG 2.1 AA compliance
+
+
+ âś“
+ Color contrast ratios meet accessibility requirements
+
+
+ âś“
+ Focus states are clearly visible and consistent
+
+
+ âś“
+ Alt text provided for all images and icons
+
+
+ âś“
+ Semantic HTML structure for screen readers
+
+
+ âś“
+ Keyboard navigation fully supported
+
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/design-system/brand/Buttons.stories.tsx b/src/docs/design-system/brand/Buttons.stories.tsx
new file mode 100644
index 00000000..16c43740
--- /dev/null
+++ b/src/docs/design-system/brand/Buttons.stories.tsx
@@ -0,0 +1,33 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { ButtonShowcase } from '@/docs/components/ButtonShowcase'
+
+const meta = {
+ title: 'Design System/Brand/Buttons',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Buttons: Story = {
+ render: () => (
+
+
+
+ Button System
+
+
+ Consistent, accessible button system with clear visual hierarchy and
+ brand-aligned colors.
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/design-system/brand/ColorPalette.stories.tsx b/src/docs/design-system/brand/ColorPalette.stories.tsx
new file mode 100644
index 00000000..73caab8a
--- /dev/null
+++ b/src/docs/design-system/brand/ColorPalette.stories.tsx
@@ -0,0 +1,165 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { colorPalette } from '@/docs/design-system/data'
+import { ColorSwatch } from '@/docs/components/ColorSwatch'
+
+const meta = {
+ title: 'Design System/Brand/Color Palette',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const ColorPalette: Story = {
+ render: () => (
+
+
+
+ Color Palette
+
+
+ Complete color system with usage guidelines and Tailwind class
+ references.
+
+
+ {/* Primary Colors */}
+
+
+ Primary Colors
+
+
+ Core brand colors used for headlines, CTAs, and key UI elements.
+
+
+ {colorPalette.primary.map((color) => (
+
+ ))}
+
+
+
+ {/* Secondary Colors */}
+
+
+ Secondary Colors
+
+
+ Supporting colors for backgrounds, cards, and subtle UI accents.
+
+
+ {colorPalette.secondary.map((color) => (
+
+ ))}
+
+
+
+ {/* Accent Colors */}
+
+
+ Accent Colors
+
+
+ Highlights for emphasis, alerts, and special call-to-actions.
+
+
+ {colorPalette.accent.map((color) => (
+
+ ))}
+
+
+
+ {/* Neutral Colors */}
+
+
+ Neutral Colors
+
+
+ Text, borders, and background colors for body content.
+
+
+ {colorPalette.neutral.map((color) => (
+
+ ))}
+
+
+
+ {/* Quick Reference */}
+
+
+ Quick Reference
+
+
+
+
+
+ Background Colors
+
+
+
+
+
+ bg-brand-cloud-blue
+
+
+
+
+
+ bg-brand-fresh-green
+
+
+
+
+
+ bg-brand-nordic-purple
+
+
+
+
+
+ bg-brand-sunbeam-yellow
+
+
+
+
+
+ bg-brand-sky-mist
+
+
+
+
+
+
+ Gradient Backgrounds
+
+
+
+
+
+ bg-aqua-gradient
+
+
+
+
+
+ bg-brand-gradient
+
+
+
+
+
+ bg-nordic-gradient
+
+
+
+
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/design-system/brand/Patterns.stories.tsx b/src/docs/design-system/brand/Patterns.stories.tsx
new file mode 100644
index 00000000..6bb587a6
--- /dev/null
+++ b/src/docs/design-system/brand/Patterns.stories.tsx
@@ -0,0 +1,338 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+
+const meta = {
+ title: 'Design System/Brand/Cloud Native Patterns',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const CloudNativePatterns: Story = {
+ render: () => (
+
+
+
+ Cloud Native Pattern System
+
+
+ Animated background patterns incorporating authentic cloud native
+ project logos with intelligent focus/diffusion effects.
+
+
+ {/* Pattern Overview */}
+
+
+ Pattern Overview
+
+
+
+
+
+ Dark pattern preview
+
+
+
+ Dark Variant
+
+
+ White icons on dark gradient background. Best for hero sections
+ and dramatic headers.
+
+
+
+
+
+
+ Light pattern preview
+
+
+
+ Light Variant
+
+
+ Colored icons on light background. Perfect for content sections
+ and cards.
+
+
+
+
+
+
+ Brand pattern preview
+
+
+
+ Brand Variant
+
+
+ White icons on brand gradient. Ideal for premium sections and
+ CTAs.
+
+
+
+
+
+ {/* Focus/Diffusion Technology */}
+
+
+ Focus/Diffusion Technology
+
+
+
+
+
+ Small Icons (Sharp Focus)
+
+
+ • Higher opacity
+ • Vibrant colors
+ • No blur effect
+ • Foreground attention
+
+
+
+
+ Medium Icons (Balanced)
+
+
+ • Moderate opacity
+ • Subtle blur
+ • Visual texture
+ • Non-distracting
+
+
+
+
+ Large Icons (Soft Diffusion)
+
+
+ • Lower opacity
+ • Muted colors
+ • Soft blur
+ • Background depth
+
+
+
+
+
+
+ {/* Configuration Options */}
+
+
+ Configuration Presets
+
+
+
+
+
+ Content Background
+
+
+ Subtle pattern for content sections and cards
+
+
+
+ opacity: 0.06 • baseSize: 25 • iconCount: 18
+
+
+
+
+
+
+ Hero Section
+
+
+ Perfect balance for wide hero sections
+
+
+
+ opacity: 0.15 • baseSize: 52 • iconCount: 38
+
+
+
+
+
+
+ Dramatic Background
+
+
+ Dense, dramatic effect for special sections
+
+
+
+ opacity: 0.20 • baseSize: 58 • iconCount: 55
+
+
+
+
+
+ {/* Available Project Icons */}
+
+
+ Available CNCF Project Icons
+
+
+
+
+
+ Container Orchestration
+
+
+ Kubernetes
+ containerd
+ etcd
+
+
+
+
+ Observability & Monitoring
+
+
+ Prometheus
+ Jaeger
+ Falco
+
+
+
+
+ Service Mesh & Networking
+
+
+ Istio
+ Envoy
+ Cilium
+
+
+
+
+ Packaging & GitOps
+
+
+ Helm
+ Argo
+ Crossplane
+
+
+
+
+ Storage & Data
+
+
+ CloudNativePG
+ Harbor
+ Vitess
+
+
+
+
+ Other Projects
+
+
+ gRPC, Backstage
+ KubeVirt, OpenFeature
+ WasmEdge, Shipwright
+
+
+
+
+
+
+ {/* Live Pattern Demo */}
+
+
+ Live Pattern Demo
+
+
+ The CloudNativePattern component renders animated CNCF project
+ icons. View the live pattern on the{' '}
+
+ branding page
+
+ .
+
+
+ {/* Full Width Demo */}
+
+
+
+
+ Cloud Native Days
+
+
+ Hero Section Example
+
+
+ (Pattern renders with animated CNCF icons in production)
+
+
+
+
+
+ {/* Side by Side Comparison */}
+
+
+
+
+
+ Light Variant
+
+
+ Great for cards & content sections
+
+
+
+
+
+
+
+
+
+ Brand Variant
+
+
+ Perfect for CTAs & premium sections
+
+
+
+
+
+
+
+ {/* Usage Example */}
+
+
+ Usage Example
+
+
+
+ {`import { CloudNativePattern } from '@/components/CloudNativePattern'
+
+
+
+
+ {/* Your content here */}
+
+
`}
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/design-system/brand/TypographySystem.stories.tsx b/src/docs/design-system/brand/TypographySystem.stories.tsx
new file mode 100644
index 00000000..3b711373
--- /dev/null
+++ b/src/docs/design-system/brand/TypographySystem.stories.tsx
@@ -0,0 +1,220 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { typography } from '@/docs/design-system/data'
+import { TypographyShowcase } from '@/docs/components/TypographyShowcase'
+
+const meta = {
+ title: 'Design System/Brand/Typography',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Typography: Story = {
+ render: () => (
+
+
+
+ Typography System
+
+
+ Font families carefully selected for developer-focused content with
+ personality and readability.
+
+
+ {/* Primary Fonts */}
+
+
+ Primary Fonts (Headings & Branding)
+
+
+ Display fonts with personality for headlines, hero text, and brand
+ moments.
+
+
+ {typography.primary.map((font) => (
+
+ ))}
+
+
+
+ {/* Secondary Fonts */}
+
+
+ Secondary Fonts (Body & UI)
+
+
+ Highly legible fonts optimized for body text, descriptions, and
+ interface elements.
+
+
+ {typography.secondary.map((font) => (
+
+ ))}
+
+
+
+ {/* Font Pairings */}
+
+
+ Recommended Font Pairings
+
+
+
+
+ Primary
+
+
+ JetBrains Mono
+
+
+ + Inter
+
+
+ “Dev terminal meets clean UI”
+
+
+
+
+
+ Modern
+
+
+ Space Grotesk
+
+
+ + IBM Plex Sans
+
+
+ “Playful headings with structured body”
+
+
+
+
+
+ Accessible
+
+
+ Bricolage Grotesque
+
+
+ + Atkinson Hyperlegible
+
+
+ “Edgy but accessible”
+
+
+
+
+
+ {/* Type Scale */}
+
+
+ Type Scale
+
+
+ {[
+ {
+ label: 'Display',
+ size: 'text-5xl',
+ font: 'font-space-grotesk',
+ },
+ { label: 'H1', size: 'text-4xl', font: 'font-space-grotesk' },
+ { label: 'H2', size: 'text-3xl', font: 'font-space-grotesk' },
+ { label: 'H3', size: 'text-2xl', font: 'font-space-grotesk' },
+ { label: 'H4', size: 'text-xl', font: 'font-space-grotesk' },
+ { label: 'Body Large', size: 'text-lg', font: 'font-inter' },
+ { label: 'Body', size: 'text-base', font: 'font-inter' },
+ { label: 'Small', size: 'text-sm', font: 'font-inter' },
+ { label: 'Caption', size: 'text-xs', font: 'font-inter' },
+ ].map((item, i) => (
+
+
+ {item.label}
+
+
+ {item.size} {item.font}
+
+
+ ))}
+
+
+
+ {/* Usage Guidelines */}
+
+
+ Usage Guidelines
+
+
+
+
+ When to Use Each Font
+
+
+
+
+ JetBrains Mono
+ {' '}
+ — Hero text, code blocks, technical headings
+
+
+
+ Space Grotesk
+ {' '}
+ — Section headers, card titles, navigation
+
+
+
+ Bricolage Grotesque
+ {' '}
+ — Special headings, speaker names
+
+
+
+ Inter
+ {' '}
+ — Body text, descriptions, form labels
+
+
+
+ IBM Plex Sans
+ {' '}
+ — Technical documentation
+
+
+
+ Atkinson
+ {' '}
+ — Accessibility-focused content
+
+
+
+
+
+ Best Practices
+
+
+ âś“ Limit to 2-3 fonts per page for cohesion
+ âś“ Use font weights for hierarchy, not just size
+ âś“ Maintain consistent line heights (1.5-1.75 for body)
+ âś“ Test readability on both light and dark themes
+ âś“ Consider mobile reading experience
+ âś“ Use semantic HTML elements with appropriate fonts
+
+
+
+
+
+
+ ),
+}
diff --git a/src/lib/branding/data.ts b/src/docs/design-system/data.ts
similarity index 100%
rename from src/lib/branding/data.ts
rename to src/docs/design-system/data.ts
diff --git a/src/docs/design-system/examples/AdminPages.stories.tsx b/src/docs/design-system/examples/AdminPages.stories.tsx
new file mode 100644
index 00000000..c75107a3
--- /dev/null
+++ b/src/docs/design-system/examples/AdminPages.stories.tsx
@@ -0,0 +1,630 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import { AdminPageHeader } from '@/components/admin/AdminPageHeader'
+import { ErrorDisplay } from '@/components/admin/ErrorDisplay'
+import { SkeletonTable } from '@/components/admin/LoadingSkeleton'
+import {
+ ChartBarIcon,
+ UserGroupIcon,
+ MegaphoneIcon,
+ PlusIcon,
+ ArrowDownTrayIcon,
+ FunnelIcon,
+ MagnifyingGlassIcon,
+} from '@heroicons/react/24/outline'
+
+const meta = {
+ title: 'Design System/Examples/Admin Pages',
+ parameters: {
+ layout: 'fullscreen',
+ options: { showPanel: false },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+/**
+ * Complete admin list page showing the standard layout pattern:
+ * AdminPageHeader with stats + action buttons, filter bar, and data table.
+ */
+export const ListPage: Story = {
+ render: () => (
+
+
+
}
+ title="Sponsor Management"
+ description="Manage sponsorships for"
+ contextHighlight="Cloud Native Day Bergen 2026"
+ stats={[
+ { value: 24, label: 'Total Sponsors', color: 'blue' },
+ { value: 8, label: 'Pending', color: 'yellow' },
+ { value: 14, label: 'Confirmed', color: 'green' },
+ { value: 2, label: 'Cancelled', color: 'red' },
+ ]}
+ actionItems={[
+ {
+ label: 'Add Sponsor',
+ icon:
,
+ onClick: () => {},
+ },
+ {
+ label: 'Export CSV',
+ icon:
,
+ onClick: () => {},
+ variant: 'secondary',
+ },
+ ]}
+ />
+
+ {/* Filter bar */}
+
+
+
+
+
+
+
+ Filters
+
+
+
+ {/* Data table */}
+
+
+
+
+ {['Company', 'Tier', 'Status', 'Contact', 'Amount'].map(
+ (header) => (
+
+ {header}
+
+ ),
+ )}
+
+
+
+ {[
+ {
+ company: 'Elastic',
+ tier: 'Gold',
+ status: 'Confirmed',
+ contact: 'jane@elastic.co',
+ amount: 'NOK 50,000',
+ tierColor:
+ 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
+ statusColor:
+ 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
+ },
+ {
+ company: 'Grafana Labs',
+ tier: 'Silver',
+ status: 'Pending',
+ contact: 'ops@grafana.com',
+ amount: 'NOK 30,000',
+ tierColor:
+ 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
+ statusColor:
+ 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
+ },
+ {
+ company: 'Isovalent',
+ tier: 'Platinum',
+ status: 'Confirmed',
+ contact: 'events@isovalent.com',
+ amount: 'NOK 80,000',
+ tierColor:
+ 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900/30 dark:text-indigo-300',
+ statusColor:
+ 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
+ },
+ ].map((row) => (
+
+
+ {row.company}
+
+
+
+ {row.tier}
+
+
+
+
+ {row.status}
+
+
+
+ {row.contact}
+
+
+ {row.amount}
+
+
+ ))}
+
+
+
+
+
+ ),
+}
+
+/**
+ * Admin detail page with back navigation, stat cards, and content sections.
+ */
+export const DetailPage: Story = {
+ render: () => (
+
+
+
}
+ title="Kelsey Hightower"
+ description="Speaker profile and talk management"
+ backLink={{
+ href: '/admin/speakers',
+ label: 'Back to speakers',
+ }}
+ stats={[
+ { value: 3, label: 'Talks Submitted', color: 'blue' },
+ { value: 2, label: 'Accepted', color: 'green' },
+ { value: 1, label: 'Pending Review', color: 'yellow' },
+ ]}
+ actionItems={[
+ {
+ label: 'Send Email',
+ icon:
,
+ onClick: () => {},
+ },
+ ]}
+ />
+
+ {/* Content sections */}
+
+
+ {/* Profile card */}
+
+
+ Profile
+
+
+ {[
+ { label: 'Email', value: 'kelsey@example.com' },
+ { label: 'Company', value: 'Google' },
+ { label: 'Role', value: 'Staff Developer Advocate' },
+ { label: 'Location', value: 'Portland, OR' },
+ ].map(({ label, value }) => (
+
+
+ {label}
+
+
+ {value}
+
+
+ ))}
+
+
+
+ {/* Talks table */}
+
+
+
+ Submitted Talks
+
+
+
+ {[
+ {
+ title: 'Kubernetes the Hard Way – Live Edition',
+ status: 'Accepted',
+ statusColor:
+ 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
+ },
+ {
+ title: 'No Code Infrastructure',
+ status: 'Accepted',
+ statusColor:
+ 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
+ },
+ {
+ title: 'The Future of Service Mesh',
+ status: 'Under Review',
+ statusColor:
+ 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
+ },
+ ].map((talk) => (
+
+
+ {talk.title}
+
+
+ {talk.status}
+
+
+ ))}
+
+
+
+
+ {/* Sidebar */}
+
+
+
+ Quick Actions
+
+
+ {[
+ 'Send Welcome Email',
+ 'Generate Speaker Card',
+ 'View Public Profile',
+ ].map((action) => (
+
+ {action}
+
+ ))}
+
+
+
+
+
+ Activity
+
+
+
Talk submitted — 2 days ago
+
Profile updated — 5 days ago
+
Registered — 2 weeks ago
+
+
+
+
+
+
+ ),
+}
+
+/**
+ * Loading state using the skeleton components. Each admin route should
+ * export a loading.tsx that renders an appropriate skeleton.
+ */
+export const LoadingState: Story = {
+ render: () => (
+
+
+ {/* Skeleton header */}
+
+
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+
+
+
+
+ ),
+}
+
+/**
+ * Error state using ErrorDisplay. Server components should catch errors
+ * early and render this instead of crashing.
+ */
+export const ErrorState: Story = {
+ render: () => (
+
+
+
+ ),
+}
+
+/**
+ * Empty state pattern for when a list page has no data yet.
+ */
+export const EmptyState: Story = {
+ render: () => (
+
+
+
}
+ title="Sponsor Management"
+ description="Manage sponsorships for"
+ contextHighlight="Cloud Native Day Bergen 2026"
+ actionItems={[
+ {
+ label: 'Add Sponsor',
+ icon:
,
+ onClick: () => {},
+ },
+ ]}
+ />
+
+
+
+
+ No sponsors yet
+
+
+ Get started by adding your first sponsor. They'll appear here
+ once added.
+
+
+
+ Add Sponsor
+
+
+
+
+ ),
+}
+
+/**
+ * Best practices reference showing the standard admin page patterns and
+ * component usage guidelines.
+ */
+export const BestPractices: Story = {
+ render: () => (
+
+
+
+ Admin Page Patterns
+
+
+ Guidelines for building consistent admin interfaces.
+
+
+ {/* Page Structure */}
+
+
+ Page Structure
+
+
+ Every admin page follows a Server Component → Client Component
+ pattern. The server component handles auth, data fetching, and error
+ boundaries. The client component handles interactivity.
+
+
+ {`// app/(admin)/admin/sponsors/page.tsx (Server Component)
+export default async function AdminSponsors() {
+ const { conference, error } = await getConferenceForCurrentDomain({})
+ if (error) return
+
+ const data = await fetchSponsors(conference._id)
+ return
+}
+
+// loading.tsx (Next.js loading boundary)
+import { AdminTablePageLoading } from '@/components/admin'
+
+export default function Loading() {
+ return
+}`}
+
+
+
+ {/* AdminPageHeader */}
+
+
+ AdminPageHeader
+
+
+ Every admin page must use AdminPageHeader for the title area. It
+ provides consistent spacing, icon placement, stat cards, and
+ responsive action buttons.
+
+
+ {`import { AdminPageHeader } from '@/components/admin'
+import { ChartBarIcon } from '@heroicons/react/24/outline'
+
+ }
+ title="Page Title"
+ description="Brief description of this page"
+ contextHighlight={conference.title} // Optional highlight
+ backLink={{ href: '/admin', label: 'Back' }} // Detail pages
+ stats={[
+ { value: 42, label: 'Total', color: 'blue' },
+ { value: 8, label: 'Pending', color: 'yellow' },
+ ]}
+ actionItems={[ // Preferred over raw actions
+ { label: 'Create', icon: , onClick: fn },
+ { label: 'Export', onClick: fn, variant: 'secondary' },
+ ]}
+/>`}
+
+
+
+ {/* Error Handling */}
+
+
+ Error Handling
+
+
+ Use a three-tier error strategy:
+
+
+ {[
+ {
+ title: '1. Conference loading errors',
+ desc: 'Return
immediately in the server component.',
+ },
+ {
+ title: '2. Data loading errors',
+ desc: 'Return with a contextual back link.',
+ },
+ {
+ title: '3. Runtime errors',
+ desc: 'Use useNotification() for toast feedback on action failures.',
+ },
+ ].map(({ title, desc }) => (
+
+
+ {title}
+
+
+ {desc}
+
+
+ ))}
+
+
+
+ {/* Notifications */}
+
+
+ Notifications
+
+
+ AdminLayout wraps everything in NotificationProvider. Use the hook
+ for toast messages on user actions.
+
+
+ {`import { useNotification } from '@/components/admin'
+
+const { showNotification } = useNotification()
+
+// After a successful action
+showNotification({
+ type: 'success',
+ title: 'Sponsor Updated',
+ message: 'The sponsor details have been saved.',
+})
+
+// After an error
+showNotification({
+ type: 'error',
+ title: 'Failed to Save',
+ message: error.message,
+})`}
+
+
+
+ {/* Data Operations */}
+
+
+ Data Operations
+
+
+ Use tRPC for all admin CRUD operations. It provides type safety,
+ automatic cache invalidation, and optimistic updates via React
+ Query.
+
+
+ {`import { trpc } from '@/lib/trpc/client'
+
+// Query
+const { data, isLoading } = trpc.sponsors.list.useQuery({
+ conferenceId: conference._id,
+})
+
+// Mutation with cache invalidation
+const utils = trpc.useUtils()
+const { mutateAsync } = trpc.sponsors.update.useMutation({
+ onSuccess: () => {
+ utils.sponsors.list.invalidate()
+ showNotification({ type: 'success', title: 'Updated!' })
+ },
+})`}
+
+
+
+ {/* Confirmations */}
+
+
+ Destructive Actions
+
+
+ Always use ConfirmationModal before destructive operations. Use the
+ danger variant for delete actions.
+
+
+ {`import { ConfirmationModal } from '@/components/admin'
+
+ setShowDelete(false)}
+ onConfirm={handleDelete}
+ title="Remove Sponsor"
+ message="This will remove the sponsor and all associated data."
+ confirmButtonText="Remove"
+ variant="danger"
+ isLoading={isPending}
+/>`}
+
+
+
+ {/* Checklist */}
+
+
+ Checklist
+
+
+
+ {[
+ 'Use AdminPageHeader for every page header',
+ 'Add loading.tsx with appropriate skeleton',
+ 'Handle errors with ErrorDisplay in server components',
+ 'Use NotificationProvider toasts for action feedback',
+ 'Use tRPC for data operations',
+ 'Confirm destructive actions with ConfirmationModal',
+ 'Support dark mode with Tailwind dark: variants',
+ 'Use responsive breakpoints (mobile → desktop)',
+ 'Use AdminHeaderActions for page-level buttons',
+ 'Keep server components for auth guards and data fetching',
+ ].map((item) => (
+
+ âś“
+ {item}
+
+ ))}
+
+
+
+
+
+ ),
+}
diff --git a/src/docs/design-system/examples/ConferenceLandingPage.stories.tsx b/src/docs/design-system/examples/ConferenceLandingPage.stories.tsx
new file mode 100644
index 00000000..a846a1c4
--- /dev/null
+++ b/src/docs/design-system/examples/ConferenceLandingPage.stories.tsx
@@ -0,0 +1,882 @@
+import type { Meta, StoryObj } from '@storybook/nextjs-vite'
+import Link from 'next/link'
+import { Button } from '@/components/Button'
+import {
+ SpeakerAvatars,
+ SpeakerAvatarsWithNames,
+} from '@/components/SpeakerAvatars'
+import { ClickableSpeakerNames } from '@/components/ClickableSpeakerNames'
+import {
+ ClockIcon,
+ UserGroupIcon,
+ MicrophoneIcon,
+ WrenchScrewdriverIcon,
+ BuildingOfficeIcon,
+} from '@heroicons/react/24/outline'
+import { Speaker, Flags } from '@/lib/speaker/types'
+
+const meta = {
+ title: 'Design System/Examples/Conference Landing Page',
+ parameters: {
+ layout: 'fullscreen',
+ options: {
+ showPanel: false,
+ },
+ },
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+const mockSpeakers: Speaker[] = [
+ {
+ _id: 'speaker-1',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Kelsey Hightower',
+ email: 'kelsey@example.com',
+ slug: 'kelsey-hightower',
+ title: 'Staff Developer Advocate at Google',
+ bio: 'Kelsey is a strong advocate for open source and the CNCF community.',
+ flags: [],
+ },
+ {
+ _id: 'speaker-2',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Liz Rice',
+ email: 'liz@example.com',
+ slug: 'liz-rice',
+ title: 'Chief Open Source Officer at Isovalent',
+ bio: 'Liz is an expert in container security and eBPF.',
+ flags: [],
+ },
+ {
+ _id: 'speaker-3',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Viktor Farcic',
+ email: 'viktor@example.com',
+ slug: 'viktor-farcic',
+ title: 'Developer Advocate at Upbound',
+ bio: 'Viktor is a DevOps practitioner and author of several books.',
+ flags: [Flags.localSpeaker],
+ },
+ {
+ _id: 'speaker-4',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Ana Medina',
+ email: 'ana@example.com',
+ slug: 'ana-medina',
+ title: 'Principal Chaos Engineer at Gremlin',
+ bio: 'Ana specializes in chaos engineering and reliability.',
+ flags: [Flags.firstTimeSpeaker],
+ },
+ {
+ _id: 'speaker-5',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Tim Hockin',
+ email: 'tim@example.com',
+ slug: 'tim-hockin',
+ title: 'Principal Software Engineer at Google',
+ bio: 'Tim is a co-founder of Kubernetes.',
+ flags: [],
+ },
+ {
+ _id: 'speaker-6',
+ _rev: '1',
+ _createdAt: '2024-01-01T00:00:00Z',
+ _updatedAt: '2024-01-01T00:00:00Z',
+ name: 'Emily Freeman',
+ email: 'emily@example.com',
+ slug: 'emily-freeman',
+ title: 'Head of Community at AWS',
+ bio: 'Emily focuses on developer experience and community building.',
+ flags: [Flags.diverseSpeaker],
+ },
+]
+
+const mockTalks = [
+ {
+ id: '1',
+ title: 'The Future of Kubernetes: What's Coming in 2026',
+ speakers: [mockSpeakers[4]],
+ format: 'Keynote',
+ duration: 45,
+ track: 'Main Stage',
+ time: '09:00',
+ level: 'All levels',
+ description:
+ 'A deep dive into upcoming Kubernetes features and the roadmap ahead.',
+ },
+ {
+ id: '2',
+ title: 'eBPF: The Swiss Army Knife for Cloud Native Observability',
+ speakers: [mockSpeakers[1]],
+ format: 'Talk',
+ duration: 30,
+ track: 'Track A',
+ time: '10:00',
+ level: 'Intermediate',
+ description:
+ 'Learn how eBPF is revolutionizing observability and security.',
+ },
+ {
+ id: '3',
+ title: 'GitOps at Scale: Lessons from the Trenches',
+ speakers: [mockSpeakers[2]],
+ format: 'Talk',
+ duration: 30,
+ track: 'Track B',
+ time: '10:00',
+ level: 'Advanced',
+ description: 'Real-world GitOps patterns for large organizations.',
+ },
+ {
+ id: '4',
+ title: 'Chaos Engineering for Kubernetes',
+ speakers: [mockSpeakers[3]],
+ format: 'Lightning Talk',
+ duration: 15,
+ track: 'Track A',
+ time: '11:00',
+ level: 'Intermediate',
+ description:
+ 'Practical chaos engineering experiments for your Kubernetes clusters.',
+ },
+]
+
+const mockWorkshops = [
+ {
+ id: '1',
+ title: 'Kubernetes Security Fundamentals',
+ speakers: [mockSpeakers[1]],
+ duration: 180,
+ capacity: 30,
+ registered: 24,
+ level: 'Beginner',
+ description:
+ 'Hands-on workshop covering Pod Security Standards, RBAC, and network policies.',
+ },
+ {
+ id: '2',
+ title: 'Building Platform Engineering with Crossplane',
+ speakers: [mockSpeakers[2]],
+ duration: 240,
+ capacity: 25,
+ registered: 25,
+ level: 'Advanced',
+ description:
+ 'Build your own internal developer platform using Crossplane and Kubernetes.',
+ },
+]
+
+const mockSponsors = {
+ platinum: [
+ { name: 'Google Cloud', color: '#4285F4' },
+ { name: 'Microsoft Azure', color: '#0078D4' },
+ ],
+ gold: [
+ { name: 'Datadog', color: '#632CA6' },
+ { name: 'Elastic', color: '#00BFB3' },
+ { name: 'HashiCorp', color: '#000000' },
+ ],
+ silver: [
+ { name: 'Grafana Labs', color: '#F46800' },
+ { name: 'Confluent', color: '#0000FF' },
+ { name: 'Snyk', color: '#4C4A73' },
+ { name: 'CircleCI', color: '#343434' },
+ ],
+}
+
+function SpeakerCard({
+ speaker,
+ featured = false,
+}: {
+ speaker: Speaker
+ featured?: boolean
+}) {
+ return (
+
+
+
+ {speaker.name
+ .split(' ')
+ .map((n) => n[0])
+ .join('')}
+
+
+
+
+ {speaker.name}
+
+ {featured && (
+
+ Keynote
+
+ )}
+
+
+ {speaker.title}
+
+ {speaker.bio && featured && (
+
+ {speaker.bio}
+
+ )}
+
+
+
+ )
+}
+
+function TalkCard({
+ talk,
+}: {
+ talk: {
+ id: string
+ title: string
+ speakers: Speaker[]
+ format: string
+ duration: number
+ track: string
+ time: string
+ level: string
+ description: string
+ }
+}) {
+ const formatColors: Record = {
+ Keynote: 'bg-brand-nordic-purple text-white',
+ Talk: 'bg-brand-cloud-blue text-white',
+ 'Lightning Talk': 'bg-brand-fresh-green text-white',
+ Workshop: 'bg-accent-yellow text-gray-900',
+ }
+
+ return (
+
+
+
+ {talk.format}
+
+
+
+ {talk.time} · {talk.duration}min
+
+
+
+
+ {talk.title}
+
+
+
+ {talk.description}
+
+
+
+
+
+
+
+
+
+
+ {talk.track}
+
+
+
+ )
+}
+
+function WorkshopCard({
+ workshop,
+}: {
+ workshop: {
+ id: string
+ title: string
+ speakers: Speaker[]
+ duration: number
+ capacity: number
+ registered: number
+ level: string
+ description: string
+ }
+}) {
+ const isFull = workshop.registered >= workshop.capacity
+ const spotsLeft = workshop.capacity - workshop.registered
+
+ return (
+
+
+
+ Workshop
+
+
+ {isFull ? 'Sold Out' : `${spotsLeft} spots left`}
+
+
+
+
+ {workshop.title}
+
+
+
+ {workshop.description}
+
+
+
+
+
+ {workshop.duration / 60}h
+
+
+
+ {workshop.registered}/{workshop.capacity}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function SponsorTier({
+ name,
+ sponsors,
+}: {
+ name: string
+ sponsors: { name: string; color: string }[]
+}) {
+ return (
+
+
+
+ {sponsors.map((sponsor) => (
+
+
+ {sponsor.name}
+
+
+ ))}
+
+
+ )
+}
+
+export const CompleteLandingPage: Story = {
+ render: () => (
+
+ {/* Hero Section */}
+
+
+
+
+ Cloud Native Days Norway
+
+
+ June 15-16, 2026 · Oslo Spektrum
+
+
+ Join 1,500+ cloud native enthusiasts for two days of cutting-edge
+ talks, hands-on workshops, and community networking.
+
+
+
+
+
+ 40+ Talks
+
+
+
+ 8 Workshops
+
+
+
+ 60+ Speakers
+
+
+
+ 30+ Sponsors
+
+
+
+
+
+ Get Tickets
+
+
+ Call for Papers
+
+
+
+
+
+ {/* Featured Speakers Section */}
+
+
+
+
+
+ Featured Speakers
+
+
+ Industry leaders sharing their cloud native expertise
+
+
+
+ View all speakers →
+
+
+
+
+
+
+
+
+ {mockSpeakers.slice(0, 3).map((speaker) => (
+
+ ))}
+
+
+
+
+ {/* Schedule Preview Section */}
+
+
+
+
+
+ Program Highlights
+
+
+ Keynotes, talks, and lightning sessions across 3 tracks
+
+
+
+ View full schedule →
+
+
+
+
+ {mockTalks.map((talk) => (
+
+ ))}
+
+
+
+
+ {/* Workshops Section */}
+
+
+
+
+
+ Hands-on Workshops
+
+
+ Deep-dive sessions with limited capacity for personalized
+ learning
+
+
+
+ View all workshops →
+
+
+
+
+ {mockWorkshops.map((workshop) => (
+
+ ))}
+
+
+
+
+ {/* Sponsors Section */}
+
+
+
+
+ Our Sponsors
+
+
+ Thank you to our amazing sponsors making this event possible
+
+
+
+
+
+
+
+
+
+ Become a Sponsor
+
+
+ Join our community of sponsors and connect with cloud native
+ professionals
+
+
View Sponsorship Packages
+
+
+
+
+ {/* Quick Stats Footer */}
+
+
+
+
+
+ 1,500+
+
+
Attendees
+
+
+
+
+
+
+
+
+ ),
+}
+
+export const SpeakersAndTalksSection: Story = {
+ name: 'Speakers & Talks Combined',
+ render: () => (
+
+
+
+ Speakers & Talks Integration
+
+
+ Demonstrating how speaker components and talk cards work together.
+
+
+ {/* Speaker with Their Talks */}
+
+
+ Speaker Profile with Talks
+
+
+
+
+
+ LR
+
+