Frontend Architecture

Last updated: June 5, 2026 Reading time: 31 minutes File size: 60.62 KB

Component Organization

web-frontend/src/
β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ shared/                    # Reusable components across roles
β”‚   β”‚   β”œβ”€β”€ Layout/
β”‚   β”‚   β”œβ”€β”€ Navigation/
β”‚   β”‚   β”œβ”€β”€ Forms/
β”‚   β”‚   └── Company/
β”‚   β”œβ”€β”€ organizer/                 # Organizer-specific components
β”‚   β”‚   β”œβ”€β”€ EventManagement/
β”‚   β”‚   β”œβ”€β”€ SpeakerCoordination/
β”‚   β”‚   β”œβ”€β”€ UserManagement/         # User Management (Organizer-only)
β”‚   β”‚   β”‚   β”œβ”€β”€ UserList.tsx        # User list table with filters
β”‚   β”‚   β”‚   β”œβ”€β”€ UserEditModal.tsx   # User detail edit modal
β”‚   β”‚   β”‚   β”œβ”€β”€ UserCreateModal.tsx # Create new user modal
β”‚   β”‚   β”‚   β”œβ”€β”€ UserFilters.tsx     # Search and filter controls
β”‚   β”‚   β”‚   └── RoleManager.tsx     # Role assignment component
β”‚   β”‚   └── Analytics/
β”‚   β”œβ”€β”€ speaker/                   # Speaker-specific components
β”‚   β”‚   β”œβ”€β”€ Dashboard/
β”‚   β”‚   β”œβ”€β”€ Submissions/
β”‚   β”‚   └── Profile/
β”‚   β”œβ”€β”€ partner/                   # Partner-specific components
β”‚   β”‚   β”œβ”€β”€ Analytics/
β”‚   β”‚   └── StrategicInput/
β”‚   └── attendee/                  # Attendee-specific components
β”‚       β”œβ”€β”€ Discovery/
β”‚       β”œβ”€β”€ Events/
β”‚       └── Content/
β”œβ”€β”€ hooks/                         # Custom React hooks
β”œβ”€β”€ services/                      # API client services
β”œβ”€β”€ stores/                        # Zustand state stores
β”œβ”€β”€ types/                         # TypeScript type definitions
└── utils/                         # Utility functions

Type Safety & API Contract

OpenAPI Type Generation

Library: openapi-typescript v7.10.1

Rationale: Ensures frontend types stay synchronized with backend OpenAPI specifications, preventing type drift and runtime errors. Generated types serve as the single source of truth derived from API contracts.

Workflow:

  1. OpenAPI Spec (single source of truth):

    • Located at: /docs/api/companies-api.openapi.yml
    • Maintained by backend team
    • Versioned in Git
  2. Type Generation:

    bash
    npm run generate:api-types
    
    • Generates: src/types/generated/company-api.types.ts
    • Types are committed to repository
  3. Git Hook Integration (Husky pre-commit):

    • Detects when OpenAPI spec changes
    • Auto-regenerates TypeScript types
    • Auto-stages generated files
    • Ensures types are always in sync
  4. CI/CD Validation (GitHub Actions):

    • Verifies committed types match OpenAPI spec
    • Fails build if types are out of date
    • Provides clear instructions for fixing drift

Usage in Frontend:

typescript
// src/types/company.types.ts
import type { components } from './generated/company-api.types';

// Derive application types from OpenAPI-generated types
export type Company = components['schemas']['CompanyResponse'];
export type CompanyStatistics = components['schemas']['CompanyStatistics'];
export type CreateCompanyRequest = components['schemas']['CreateCompanyRequest'];
export type UpdateCompanyRequest = components['schemas']['UpdateCompanyRequest'];
export type PaginatedCompanyResponse = components['schemas']['PaginatedCompanyResponse'];

// Frontend-specific types (not in OpenAPI)
export interface CompanyFilters {
  isVerified?: boolean;
  industry?: string;
}

Benefits:

  • βœ… Compile-time type safety between frontend and backend
  • βœ… Automatic type updates when API changes
  • βœ… Prevents field name mismatches and data model drift
  • βœ… IDE autocomplete for all API response fields
  • βœ… CI validation prevents merging unsynchronized types

HTTP Client Configuration

API Base URL Pattern

CRITICAL: The runtime config sets apiBaseURL to include the /api/v1 prefix (e.g., http://localhost:8080/api/v1). All API service paths must exclude the /api/v1 prefix to avoid double concatenation errors.

Why This Matters:

  • Runtime config (from backend /api/v1/config): apiBaseUrl: "http://localhost:8080/api/v1"
  • If frontend code uses /api/v1/users, result is: http://localhost:8080/api/v1/api/v1/users ❌
  • This causes 404/500 errors that are difficult to debug

Correct Pattern (Reference Implementation: src/services/api/companyApi.ts):

typescript
// src/services/api/userManagementApi.ts

// Define API path constants WITHOUT /api/v1 prefix
// Note: apiClient baseURL is set from runtime config to 'http://localhost:8080/api/v1'
// so we only need '/users' (the /api/v1 prefix is already in the baseURL)
const USER_API_PATH = '/users';

export const listUsers = async (filters: UserFilters, pagination: UserPagination) => {
  const response = await apiClient.get(USER_API_PATH, { params });
  // βœ… Resolves to: http://localhost:8080/api/v1/users
  return response.data;
};

export const searchUsers = async (query: string) => {
  const response = await apiClient.get(`${USER_API_PATH}/search`, { params });
  // βœ… Resolves to: http://localhost:8080/api/v1/users/search
  return response.data;
};

export const getUserById = async (id: string) => {
  const response = await apiClient.get(`${USER_API_PATH}/${id}`, { params });
  // βœ… Resolves to: http://localhost:8080/api/v1/users/{id}
  return response.data;
};

Incorrect Pattern (DO NOT DO THIS):

typescript
// ❌ WRONG - Includes /api/v1 prefix, causing double concatenation
const response = await apiClient.get('/api/v1/users');
// Result: http://localhost:8080/api/v1/api/v1/users ❌ 500 ERROR

// ❌ WRONG - Direct path string without constant
const response = await apiClient.post('/api/v1/users', data);
// Result: http://localhost:8080/api/v1/api/v1/users ❌ 500 ERROR

Standard Path Constants:

typescript
// Use these standard constants across all API services:
const USER_API_PATH = '/users';
const COMPANY_API_PATH = '/companies';
const EVENT_API_PATH = '/events';
const SPEAKER_API_PATH = '/speakers';
const PARTNER_API_PATH = '/partners';
const ATTENDEE_API_PATH = '/attendees';

Testing Your API Paths:

When writing tests with MockAdapter, use the same path constants (WITHOUT /api/v1):

typescript
// βœ… CORRECT - Test mocks match implementation
import MockAdapter from 'axios-mock-adapter';
import apiClient from './apiClient';

const mock = new MockAdapter(apiClient);
mock.onGet('/users').reply(200, mockResponse);  // βœ… Matches USER_API_PATH
mock.onPost('/users').reply(201, mockResponse);  // βœ… Matches USER_API_PATH

State Management Architecture

State Structure

typescript
interface AppState {
  // Authentication state
  auth: {
    user: User | null;
    currentRole: UserRole;
    availableRoles: UserRole[];
    isAuthenticated: boolean;
  };

  // UI state
  ui: {
    sidebarOpen: boolean;
    notifications: Notification[];
    loading: Record<string, boolean>;
  };

  // Domain-specific state
  events: {
    currentEvent: Event | null;
    eventList: Event[];
    filters: EventFilters;
  };

  speakers: {
    speakerList: Speaker[];
    invitations: SpeakerInvitation[];
  };

  partners: {
    topicVotes: TopicVote[];
    meetings: PartnerMeeting[];
  };

  attendees: {
    registrations: EventRegistration[];
    bookmarks: ContentBookmark[];
  };

  companies: {
    companyList: Company[];
    selectedCompany: Company | null;
    filters: CompanyFilters;
    searchQuery: string;
  };
}

Detailed Component Specifications

Shared Component Architecture

Layout Components:

typescript
// BaseLayout Component
interface BaseLayoutProps {
  children: React.ReactNode;
  currentRole: UserRole;
  user: User;
  notifications: Notification[];
}

const BaseLayout: React.FC<BaseLayoutProps> = ({
  children,
  currentRole,
  user,
  notifications
}) => {
  return (
    <Box sx={{ display: 'flex', minHeight: '100vh' }}>
      <AppHeader
        user={user}
        currentRole={currentRole}
        notifications={notifications}
      />
      <Sidebar currentRole={currentRole} />
      <MainContent>{children}</MainContent>
    </Box>
  );
};

// Navigation Component Specifications
interface NavigationItem {
  id: string;
  label: string;
  icon: React.ComponentType;
  path: string;
  roles: UserRole[];
  children?: NavigationItem[];
}

const navigationConfig: Record<UserRole, NavigationItem[]> = {
  [UserRole.ORGANIZER]: [
    {
      id: 'dashboard',
      label: 'Dashboard',
      icon: DashboardIcon,
      path: '/organizer/dashboard',
      roles: [UserRole.ORGANIZER]
    },
    {
      id: 'events',
      label: 'Event Management',
      icon: EventIcon,
      path: '/organizer/events',
      roles: [UserRole.ORGANIZER],
      children: [
        { id: 'create', label: 'Create Event', icon: AddIcon, path: '/organizer/events/create', roles: [UserRole.ORGANIZER] },
        { id: 'timeline', label: 'Event Timeline', icon: TimelineIcon, path: '/organizer/events/timeline', roles: [UserRole.ORGANIZER] }
      ]
    }
  ],
  // Additional role configurations...
};

Form Components:

typescript
// Reusable Form Components with Validation
interface FormFieldProps<T> {
  name: keyof T;
  label: string;
  required?: boolean;
  validation?: (value: any) => string | undefined;
  disabled?: boolean;
}

// AutoComplete Company Selector (AC2 - Search & Autocomplete)
interface CompanySelectorProps {
  value: Company | null;
  onChange: (company: Company | null) => void;
  allowCreate?: boolean;
}

const CompanySelector: React.FC<CompanySelectorProps> = ({
  value,
  onChange,
  allowCreate = false,
}) => {
  const { data: companies, isLoading } = useCompanySearch(searchQuery, 10);

  return (
    <Autocomplete
      options={companies || []}
      value={value}
      onChange={(_, newValue) => onChange(newValue)}
      getOptionLabel={(option) => option.displayName || option.name}
      renderInput={(params) => (
        <TextField {...params} label="Company" />
      )}
      loading={isLoading}
      freeSolo={allowCreate}
      onInputChange={async (_, inputValue) => {
        // Search triggered with 3+ characters (AC2 requirement)
        if (inputValue.length >= 3) {
          setSearchQuery(inputValue);
        }
      }}
      renderOption={(props, option) => (
        <Box component="li" {...props}>
          <Box>
            <Typography variant="body1">
              {option.displayName || option.name}
              {option.isVerified && ' βœ…'}
            </Typography>
            {option.industry && (
              <Typography variant="caption" color="text.secondary">
                {option.industry}
              </Typography>
            )}
          </Box>
        </Box>
      )}
    />
  );
};

Data Visualization Components

Library: Recharts 2.10+

Rationale: React-native charting library that integrates seamlessly with Material-UI and TypeScript, providing declarative chart components with minimal configuration.

Usage:

  • Topic usage heat maps
  • Historical usage timelines
  • Partner engagement analytics
  • Event attendance trends

Key Components:

typescript
import {
  HeatMap,
  BarChart,
  LineChart,
  Tooltip,
  ResponsiveContainer
} from 'recharts';

// Topic Usage Heat Map Component
interface TopicHeatMapProps {
  topics: Topic[];
  usageHistory: TopicUsageRecord[];
  timeRange: '12months' | '24months';
}

const TopicUsageHeatMap: React.FC<TopicHeatMapProps> = ({
  topics,
  usageHistory,
  timeRange
}) => {
  const heatMapData = useMemo(() =>
    calculateHeatMapData(topics, usageHistory, timeRange),
    [topics, usageHistory, timeRange]
  );

  return (
    <ResponsiveContainer width="100%" height={400}>
      <HeatMap
        data={heatMapData}
        margin={{ top: 20, right: 30, left: 20, bottom: 5 }}
      >
        <Tooltip content={<TopicTooltip />} />
      </HeatMap>
    </ResponsiveContainer>
  );
};

Performance Requirements:

  • Heat map rendering: <500ms for 100+ topics
  • Interactive updates: <100ms response time
  • Lazy loading for historical data beyond 24 months

Role-Specific Component Specifications

Enhanced Organizer Workflow Components:

typescript
// Enhanced Event Workflow Dashboard
interface EventWorkflowDashboardProps {
  event: Event;
  workflowState: EventWorkflowState;
  nextSteps: WorkflowStep[];
  onStateTransition: (targetState: EventWorkflowState) => Promise<void>;
}

const EventWorkflowDashboard: React.FC<EventWorkflowDashboardProps> = ({
  event,
  workflowState,
  nextSteps,
  onStateTransition
}) => {
  return (
    <Container maxWidth="lg">
      <Typography variant="h4" gutterBottom>
        Event Workflow: {event.title}
      </Typography>

      <Grid container spacing={3}>
        <Grid item xs={12} md={4}>
          <WorkflowProgressCard
            currentState={workflowState}
            completedSteps={getCompletedSteps(workflowState)}
            totalSteps={16}
          />
        </Grid>

        <Grid item xs={12} md={8}>
          <WorkflowStepDetails
            currentStep={getCurrentStep(workflowState)}
            nextSteps={nextSteps}
            onTransition={onStateTransition}
          />
        </Grid>

        <Grid item xs={12}>
          <WorkflowVisualization
            steps={getAllWorkflowSteps()}
            currentState={workflowState}
            eventType={event.eventType}
          />
        </Grid>
      </Grid>
    </Container>
  );
};

// Slot Assignment Management Interface
interface SlotAssignmentManagerProps {
  event: Event;
  slots: EventSlot[];
  speakers: SessionSpeaker[];
  preferences: SpeakerSlotPreferences[];
  onSlotAssign: (slotId: string, speakerId: string) => Promise<void>;
  onAutoAssign: () => Promise<void>;
}

const SlotAssignmentManager: React.FC<SlotAssignmentManagerProps> = ({
  event,
  slots,
  speakers,
  preferences,
  onSlotAssign,
  onAutoAssign
}) => {
  const [draggedSpeaker, setDraggedSpeaker] = useState<string | null>(null);

  return (
    <Container maxWidth="xl">
      <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
        <Typography variant="h5">Slot Assignment</Typography>
        <Button
          variant="contained"
          onClick={onAutoAssign}
          startIcon={<AutoAwesomeIcon />}
        >
          Auto-Assign Speakers
        </Button>
      </Box>

      <Grid container spacing={3}>
        <Grid item xs={12} md={8}>
          <Paper sx={{ p: 2 }}>
            <Typography variant="h6" gutterBottom>
              Event Slots ({event.eventType})
            </Typography>
            <SlotGrid
              slots={slots}
              eventType={event.eventType}
              onSlotDrop={(slotId, speakerId) => onSlotAssign(slotId, speakerId)}
              draggedSpeaker={draggedSpeaker}
            />
          </Paper>
        </Grid>

        <Grid item xs={12} md={4}>
          <Paper sx={{ p: 2 }}>
            <Typography variant="h6" gutterBottom>
              Unassigned Speakers
            </Typography>
            <UnassignedSpeakersList
              speakers={speakers.filter(s => !s.slotAssignment)}
              preferences={preferences}
              onDragStart={setDraggedSpeaker}
              onDragEnd={() => setDraggedSpeaker(null)}
            />
          </Paper>
        </Grid>
      </Grid>
    </Container>
  );
};

// Quality Review Dashboard
interface QualityReviewDashboardProps {
  pendingReviews: ContentQualityReview[];
  completedReviews: ContentQualityReview[];
  moderatorId: string;
  onReviewUpdate: (reviewId: string, status: QualityReviewStatus, feedback?: string) => Promise<void>;
}

const QualityReviewDashboard: React.FC<QualityReviewDashboardProps> = ({
  pendingReviews,
  completedReviews,
  moderatorId,
  onReviewUpdate
}) => {
  const [selectedReview, setSelectedReview] = useState<ContentQualityReview | null>(null);

  return (
    <Container maxWidth="lg">
      <Typography variant="h4" gutterBottom>
        Content Quality Review
      </Typography>

      <Tabs value={0}>
        <Tab label={`Pending Reviews (${pendingReviews.length})`} />
        <Tab label={`Completed Reviews (${completedReviews.length})`} />
      </Tabs>

      <TabPanel value={0}>
        <Grid container spacing={2}>
          {pendingReviews.map((review) => (
            <Grid item xs={12} md={6} key={review.id}>
              <ReviewCard
                review={review}
                onSelect={() => setSelectedReview(review)}
                onQuickApprove={() => onReviewUpdate(review.id, QualityReviewStatus.APPROVED)}
                onRequireChanges={() => setSelectedReview(review)}
              />
            </Grid>
          ))}
        </Grid>
      </TabPanel>

      {selectedReview && (
        <ReviewDetailModal
          review={selectedReview}
          open={!!selectedReview}
          onClose={() => setSelectedReview(null)}
          onSubmitReview={onReviewUpdate}
        />
      )}
    </Container>
  );
};

// Overflow Management & Voting Interface
interface OverflowManagementProps {
  event: Event;
  overflowSpeakers: OverflowSpeaker[];
  votes: SpeakerSelectionVote[];
  currentOrganizerId: string;
  onVote: (speakerId: string, vote: VoteType, reason?: string) => Promise<void>;
}

const OverflowManagement: React.FC<OverflowManagementProps> = ({
  event,
  overflowSpeakers,
  votes,
  currentOrganizerId,
  onVote
}) => {
  const [votingReason, setVotingReason] = useState<Record<string, string>>({});

  return (
    <Container maxWidth="lg">
      <Alert severity="warning" sx={{ mb: 3 }}>
        <AlertTitle>Speaker Overflow Detected</AlertTitle>
        We have {overflowSpeakers.length} speakers for {event.slotConfiguration.maxSlots} available slots.
        Please vote to select the final speakers.
      </Alert>

      <Grid container spacing={2}>
        {overflowSpeakers.map((speaker) => {
          const userVote = votes.find(v =>
            v.speakerId === speaker.speakerId && v.organizerId === currentOrganizerId
          );

          return (
            <Grid item xs={12} md={6} key={speaker.speakerId}>
              <SpeakerVotingCard
                speaker={speaker}
                currentVote={userVote}
                totalVotes={votes.filter(v => v.speakerId === speaker.speakerId).length}
                onVote={(vote, reason) => onVote(speaker.speakerId, vote, reason)}
                disabled={!!userVote}
              />
            </Grid>
          );
        })}
      </Grid>

      <VotingProgressSummary
        totalOrganizers={getTotalOrganizers()}
        votesReceived={getVotesReceived()}
        votingDeadline={event.overflowManagement?.votingDeadline}
      />
    </Container>
  );
};

// Real-time Notification & Alert System
interface WorkflowNotificationProps {
  notifications: WorkflowNotification[];
  onMarkAsRead: (notificationId: string) => void;
  onEscalate: (notificationId: string) => void;
}

const WorkflowNotificationCenter: React.FC<WorkflowNotificationProps> = ({
  notifications,
  onMarkAsRead,
  onEscalate
}) => {
  const unreadCount = notifications.filter(n => !n.isRead).length;

  return (
    <Badge badgeContent={unreadCount} color="error">
      <IconButton>
        <NotificationsIcon />
      </IconButton>
      <Menu>
        {notifications.map((notification) => (
          <NotificationItem
            key={notification.id}
            notification={notification}
            onMarkAsRead={() => onMarkAsRead(notification.id)}
            onEscalate={() => onEscalate(notification.id)}
          />
        ))}
      </Menu>
    </Badge>
  );
};

Enhanced Speaker Interface Components:

typescript
// Speaker Preferences Collection Interface
interface SpeakerPreferencesFormProps {
  eventId: string;
  existingPreferences?: SpeakerSlotPreferences;
  eventSlots: EventSlot[];
  onSubmit: (preferences: SpeakerSlotPreferences) => Promise<void>;
}

const SpeakerPreferencesForm: React.FC<SpeakerPreferencesFormProps> = ({
  eventId,
  existingPreferences,
  eventSlots,
  onSubmit
}) => {
  const [preferences, setPreferences] = useState<Partial<SpeakerSlotPreferences>>(
    existingPreferences || {}
  );

  return (
    <Container maxWidth="md">
      <Typography variant="h5" gutterBottom>
        Slot Preferences & Requirements
      </Typography>

      <FormSection title="Time Slot Preferences">
        <Grid container spacing={2}>
          {eventSlots.map((slot) => (
            <Grid item xs={12} md={6} key={slot.id}>
              <SlotPreferenceCard
                slot={slot}
                preference={preferences.preferredTimeSlots?.find(p =>
                  p.startTime === slot.startTime
                )?.preference || PreferenceLevel.ACCEPTABLE}
                onChange={(preference) => updateSlotPreference(slot, preference)}
              />
            </Grid>
          ))}
        </Grid>
      </FormSection>

      <FormSection title="Technical Requirements">
        <FormControlLabel
          control={
            <Checkbox
              checked={preferences.ownLaptopRequired || false}
              onChange={(e) => setPreferences({
                ...preferences,
                ownLaptopRequired: e.target.checked
              })}
            />
          }
          label="I must use my own laptop for presentation"
        />

        <TextField
          fullWidth
          multiline
          rows={3}
          label="Special Equipment or Setup Requirements"
          value={preferences.specialEquipmentNeeds || ''}
          onChange={(e) => setPreferences({
            ...preferences,
            specialEquipmentNeeds: e.target.value
          })}
          sx={{ mt: 2 }}
        />
      </FormSection>

      <Button
        variant="contained"
        onClick={() => onSubmit(preferences as SpeakerSlotPreferences)}
        fullWidth
        sx={{ mt: 3 }}
      >
        Save Preferences
      </Button>
    </Container>
  );
};

// Speaker Workflow Status Dashboard
interface SpeakerWorkflowDashboardProps {
  speaker: Speaker;
  sessions: SessionSpeaker[];
  qualityReviews: ContentQualityReview[];
  onContentSubmit: (sessionId: string, content: SubmitContentRequest) => Promise<void>;
}

const SpeakerWorkflowDashboard: React.FC<SpeakerWorkflowDashboardProps> = ({
  speaker,
  sessions,
  qualityReviews,
  onContentSubmit
}) => {
  return (
    <Container maxWidth="lg">
      <Typography variant="h4" gutterBottom>
        My Speaking Engagements
      </Typography>

      <Grid container spacing={3}>
        {sessions.map((session) => {
          const review = qualityReviews.find(r => r.sessionId === session.sessionId);

          return (
            <Grid item xs={12} md={6} key={session.sessionId}>
              <SpeakerSessionCard
                session={session}
                qualityReview={review}
                onContentSubmit={(content) => onContentSubmit(session.sessionId, content)}
              />
            </Grid>
          );
        })}
      </Grid>

      <WorkflowProgressIndicator
        sessions={sessions}
        currentState={getCurrentWorkflowState(sessions)}
      />
    </Container>
  );
};

Organizer Components:

typescript
// Event Management Dashboard
interface EventDashboardProps {
  currentEvent: Event | null;
  upcomingEvents: Event[];
  onEventSelect: (event: Event) => void;
}

const EventDashboard: React.FC<EventDashboardProps> = ({
  currentEvent,
  upcomingEvents,
  onEventSelect
}) => {
  return (
    <Grid container spacing={3}>
      <Grid item xs={12} md={8}>
        <EventTimelineCard event={currentEvent} />
      </Grid>
      <Grid item xs={12} md={4}>
        <WorkflowStatusCard event={currentEvent} />
        <SpeakerStatusCard event={currentEvent} />
      </Grid>
    </Grid>
  );
};

// Speaker Coordination Workflow
interface SpeakerWorkflowProps {
  event: Event;
  speakers: Speaker[];
  invitations: SpeakerInvitation[];
  onStateChange: (speakerId: string, newState: SpeakerState) => void;
}

const SpeakerWorkflow: React.FC<SpeakerWorkflowProps> = ({
  event,
  speakers,
  invitations,
  onStateChange
}) => {
  const workflowSteps = [
    'OPEN',
    'CONTACTED',
    'READY',
    'DECLINED_ACCEPTED',
    'FINAL_AGENDA'
  ];

  return (
    <Card>
      <CardHeader title="Speaker Pipeline" />
      <CardContent>
        {workflowSteps.map((step) => (
          <WorkflowStep
            key={step}
            step={step}
            speakers={speakers.filter(s => s.status === step)}
            onSpeakerAction={onStateChange}
          />
        ))}
      </CardContent>
    </Card>
  );
};

// Role Management Components
interface RoleManagementPanelProps {
  eventId?: string; // Optional: scope to specific event
}

const RoleManagementPanel: React.FC<RoleManagementPanelProps> = ({ eventId }) => {
  const [selectedUser, setSelectedUser] = useState<User | null>(null);
  const { data: pendingRequests } = usePendingApprovals();

  const { mutate: promoteUser } = usePromoteUser();
  const { mutate: demoteUser } = useDemoteUser();
  const { mutate: approveChange } = useApproveRoleChange();

  return (
    <Stack spacing={3}>
      <Typography variant="h5">Role Management</Typography>

      <UserSearchBar
        onUserSelected={setSelectedUser}
        placeholder="Search users by name or email..."
      />

      {selectedUser && (
        <RoleActionPanel
          user={selectedUser}
          onPromote={(role, reason) => promoteUser({
            userId: selectedUser.id,
            role,
            reason
          })}
          onDemote={(role, reason) => demoteUser({
            userId: selectedUser.id,
            role,
            reason
          })}
        />
      )}

      <PendingApprovalsQueue
        requests={pendingRequests || []}
        onApprove={(requestId, approved, comments) =>
          approveChange({
            userId: selectedUser?.id,
            changeId: requestId,
            approved,
            comments
          })
        }
      />
    </Stack>
  );
};

// API Hooks for Role Management
// Story 1.16.2: Uses username in URL paths, not UUID
export const usePromoteUser = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ username, role, reason }: PromoteUserRequest) => {
      return apiClient.post(`/users/${username}/roles`, { role, reason });
    },
    onSuccess: () => {
      queryClient.invalidateQueries(['users']);
      showNotification('User role promoted successfully', 'success');
    },
    onError: (error: ApiError) => {
      showNotification(error.message || 'Failed to promote user', 'error');
    },
  });
};

export const useDemoteUser = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ username, role, reason }: DemoteUserRequest) => {
      return apiClient.delete(`/users/${username}/roles/${role}`, {
        data: { reason }
      });
    },
    onSuccess: (data) => {
      if (data.status === 202) {
        showNotification('Demotion request created - awaiting approval', 'info');
      } else {
        showNotification('User role demoted successfully', 'success');
      }
      queryClient.invalidateQueries(['users']);
    },
    onError: (error: ApiError) => {
      showNotification(error.message || 'Failed to demote user', 'error');
    },
  });
};

export const useApproveRoleChange = () => {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async ({ username, changeId, approved, comments }: ApprovalRequest) => {
      return apiClient.post(
        `/api/v1/users/${username}/role-changes/${changeId}/approve`,
        { approved, comments }
      );
    },
    onSuccess: () => {
      queryClient.invalidateQueries(['role-change-requests']);
      showNotification('Approval processed successfully', 'success');
    },
    onError: (error: ApiError) => {
      showNotification(error.message || 'Failed to process approval', 'error');
    },
  });
};

export const usePendingApprovals = () => {
  const { user } = useAuth();

  return useQuery({
    queryKey: ['role-change-requests', 'pending', user?.id],  // id is username (Story 1.16.2)
    queryFn: async () => {
      const response = await apiClient.get(
        `/api/v1/users/${user.id}/role-changes?status=PENDING`  // user.id is username (Story 1.16.2)
      );
      return response.data;
    },
    enabled: !!user?.id,
  });
};

Speaker Components:

typescript
// Speaker Dashboard Component
interface SpeakerDashboardProps {
  speaker: Speaker;
  invitations: SpeakerInvitation[];
  submissions: SpeakerSubmission[];
}

const SpeakerDashboard: React.FC<SpeakerDashboardProps> = ({
  speaker,
  invitations,
  submissions
}) => {
  return (
    <Container maxWidth="lg">
      <Typography variant="h4" gutterBottom>
        Welcome, {speaker.firstName}
      </Typography>

      <Grid container spacing={3}>
        <Grid item xs={12} md={6}>
          <PendingInvitationsCard invitations={invitations} />
        </Grid>
        <Grid item xs={12} md={6}>
          <UpcomingSessionsCard speaker={speaker} />
        </Grid>
        <Grid item xs={12}>
          <SubmissionHistoryCard submissions={submissions} />
        </Grid>
      </Grid>
    </Container>
  );
};

// Material Submission Form
interface MaterialSubmissionProps {
  sessionId: string;
  existingSubmission?: SpeakerSubmission;
  onSubmit: (submission: SpeakerSubmissionData) => Promise<void>;
}

const MaterialSubmissionForm: React.FC<MaterialSubmissionProps> = ({
  sessionId,
  existingSubmission,
  onSubmit
}) => {
  const [formData, setFormData] = useState<SpeakerSubmissionData>({
    title: existingSubmission?.title || '',
    abstract: existingSubmission?.abstract || '',
    biography: existingSubmission?.biography || '',
    materials: existingSubmission?.materials || []
  });

  return (
    <Card>
      <CardHeader title="Submit Session Materials" />
      <CardContent>
        <Stack spacing={3}>
          <TextField
            label="Session Title"
            value={formData.title}
            onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
            fullWidth
            required
          />

          <TextField
            label="Abstract"
            value={formData.abstract}
            onChange={(e) => setFormData(prev => ({ ...prev, abstract: e.target.value }))}
            multiline
            rows={4}
            fullWidth
            required
          />

          <FileUploadZone
            acceptedTypes={['.pdf', '.pptx', '.jpg', '.png']}
            maxSize={10 * 1024 * 1024} // 10MB
            onFilesAdd={(files) => setFormData(prev => ({
              ...prev,
              materials: [...prev.materials, ...files]
            }))}
          />
        </Stack>
      </CardContent>
    </Card>
  );
};

Partner Components:

typescript
// Topic Voting Interface
interface TopicVotingProps {
  availableTopics: Topic[];
  existingVotes: TopicVote[];
  votingPower: number;
  onVoteSubmit: (votes: TopicVoteData[]) => Promise<void>;
}

const TopicVotingInterface: React.FC<TopicVotingProps> = ({
  availableTopics,
  existingVotes,
  votingPower,
  onVoteSubmit
}) => {
  const [votes, setVotes] = useState<Record<string, number>>({});
  const totalVotesUsed = Object.values(votes).reduce((sum, vote) => sum + vote, 0);

  return (
    <Card>
      <CardHeader
        title="Strategic Topic Voting"
        subheader={`${votingPower - totalVotesUsed} votes remaining`}
      />
      <CardContent>
        <Stack spacing={2}>
          {availableTopics.map((topic) => (
            <TopicVoteCard
              key={topic.id}
              topic={topic}
              currentVote={votes[topic.id] || 0}
              onVoteChange={(newVote) => setVotes(prev => ({ ...prev, [topic.id]: newVote }))}
              disabled={totalVotesUsed >= votingPower && !votes[topic.id]}
            />
          ))}
        </Stack>
      </CardContent>
    </Card>
  );
};

Attendee Components:

typescript
// Content Discovery Engine
interface ContentDiscoveryProps {
  searchQuery: string;
  filters: ContentFilters;
  results: SearchResult[];
  onSearch: (query: string, filters: ContentFilters) => void;
  onBookmark: (contentId: string) => void;
}

const ContentDiscovery: React.FC<ContentDiscoveryProps> = ({
  searchQuery,
  filters,
  results,
  onSearch,
  onBookmark
}) => {
  return (
    <Container maxWidth="lg">
      <Box sx={{ mb: 3 }}>
        <SearchBar
          value={searchQuery}
          onSearch={(query) => onSearch(query, filters)}
          placeholder="Search 20+ years of BATbern content..."
        />
        <FilterPanel
          filters={filters}
          onChange={(newFilters) => onSearch(searchQuery, newFilters)}
        />
      </Box>

      <Grid container spacing={2}>
        {results.map((result) => (
          <Grid item xs={12} md={6} lg={4} key={result.id}>
            <ContentCard
              content={result}
              onBookmark={() => onBookmark(result.id)}
              onView={() => window.open(result.url, '_blank')}
            />
          </Grid>
        ))}
      </Grid>
    </Container>
  );
};

// Event Registration Component
interface EventRegistrationProps {
  event: Event;
  existingRegistration?: EventRegistration;
  onRegister: (registration: RegistrationData) => Promise<void>;
}

const EventRegistration: React.FC<EventRegistrationProps> = ({
  event,
  existingRegistration,
  onRegister
}) => {
  const [registrationData, setRegistrationData] = useState<RegistrationData>({
    attendeeType: existingRegistration?.attendeeType || 'GENERAL',
    dietaryRequirements: existingRegistration?.dietaryRequirements || '',
    networkingOptIn: existingRegistration?.networkingOptIn || true
  });

  return (
    <Card>
      <CardHeader title={`Register for ${event.title}`} />
      <CardContent>
        <Stack spacing={3}>
          <EventDetailsCard event={event} />

          <FormControl fullWidth>
            <InputLabel>Attendee Type</InputLabel>
            <Select
              value={registrationData.attendeeType}
              onChange={(e) => setRegistrationData(prev => ({
                ...prev,
                attendeeType: e.target.value as AttendeeType
              }))}
            >
              <MenuItem value="GENERAL">General Attendee</MenuItem>
              <MenuItem value="STUDENT">Student</MenuItem>
              <MenuItem value="SPEAKER">Speaker</MenuItem>
            </Select>
          </FormControl>

          <TextField
            label="Dietary Requirements (Optional)"
            value={registrationData.dietaryRequirements}
            onChange={(e) => setRegistrationData(prev => ({
              ...prev,
              dietaryRequirements: e.target.value
            }))}
            multiline
            rows={2}
            fullWidth
          />

          <FormControlLabel
            control={
              <Checkbox
                checked={registrationData.networkingOptIn}
                onChange={(e) => setRegistrationData(prev => ({
                  ...prev,
                  networkingOptIn: e.target.checked
                }))}
              />
            }
            label="I'd like to participate in networking activities"
          />
        </Stack>
      </CardContent>
    </Card>
  );
};

Component Development Standards

TypeScript Interface Patterns:

typescript
// Props interfaces should be explicitly defined
interface ComponentNameProps {
  // Required props first
  requiredProp: string;
  onAction: (data: ActionData) => void;

  // Optional props second with defaults
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
  children?: React.ReactNode;
}

// Use generic constraints for flexible components
interface DataTableProps<T extends { id: string }> {
  data: T[];
  columns: TableColumn<T>[];
  onRowClick?: (row: T) => void;
}

Error Boundary Implementation:

typescript
class ComponentErrorBoundary extends React.Component<
  { children: React.ReactNode; fallback: React.ComponentType<{ error: Error }> },
  { hasError: boolean; error: Error | null }
> {
  constructor(props: any) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Component Error:', error, errorInfo);
    // Log to monitoring service
    Sentry.captureException(error, { extra: errorInfo });
  }

  render() {
    if (this.state.hasError && this.state.error) {
      const FallbackComponent = this.props.fallback;
      return <FallbackComponent error={this.state.error} />;
    }

    return this.props.children;
  }
}

Routing Architecture

/                              # Public home page
/current-event                 # Current BATbern event landing
/auth/login                    # Authentication

# Organizer routes
/organizer/dashboard          # Organizer main dashboard
/organizer/events             # Event management
/organizer/speakers           # Speaker database
/organizer/analytics          # Cross-domain analytics

# Speaker routes
/speaker/dashboard            # Speaker main dashboard
/speaker/invitations          # Speaking invitations
/speaker/sessions             # My sessions
/speaker-portal/profile       # β†’ redirects to /profile (Story 12.11 β€” profile is role-neutral)

# Partner routes
/partner/dashboard            # Partner main dashboard (voting, meetings, profile)
/partner/voting               # Topic voting interface

# Attendee routes
/attendee/dashboard           # Attendee main dashboard
/attendee/events              # Event discovery
/attendee/search              # Content search
/attendee/content             # My bookmarked content

# Shared routes (role-adaptive)
/companies                    # Company management
/profile                      # User profile (role-neutral, Story 12.11: tabs Profile | Consent & Newsletter;
                              # also the onboarding-gate target /profile?onboarding=1)

Route Guards (ProtectedRoute)

All authenticated routes render through ProtectedRoute (src/components/auth/ProtectedRoute/ProtectedRoute.tsx), which enforces two checks:

  1. Role-based access β€” roles come from the hydrated auth user (AuthContext, including the Pattern 3b DB fallback for empty JWT roles β€” see 06b-user-lifecycle-sync.md); unauthorized roles are redirected away before the component tree renders.
  2. ToS/Privacy consent gate (Story 12.11, β€œPattern C” in 06b) β€” if the hydrated user’s termsAcceptedAt is null (consent confirmed absent), every protected path except /profile and /logout redirects to /profile?onboarding=1, where the Consent tab is preselected; a successful accept calls refreshUser() and the gate lifts. undefined (hydration failed) fails open β€” a transient /users/me failure never locks users out.

Internationalization (i18n) Architecture

Supported Languages & Locales

Primary Language: German (de-CH) - Swiss German locale Secondary Language: English (en-US) Default/Fallback: German (de-CH)

Rationale: BATbern serves a primarily Swiss German-speaking audience. German is the default language with English as an optional alternative for international attendees and speakers.

Library Stack

Library Version Purpose
i18next ^23.7.0 Core i18n framework with namespace support, pluralization, interpolation
react-i18next ^14.0.0 React bindings with hooks (useTranslation) and Suspense support
i18next-browser-languagedetector ^7.2.0 Automatic language detection from browser, localStorage, user profile
date-fns ^3.0.0 Date/time formatting with locale support (de, enUS)

Bundle Impact: ~14KB gzipped (i18next ecosystem)

Rationale: react-i18next provides the most flexible and feature-rich solution with excellent TypeScript support, namespace-based code splitting, and seamless integration with React 18.2+ and our existing tech stack.

Translation File Structure

web-frontend/
β”œβ”€β”€ public/
β”‚   └── locales/
β”‚       β”œβ”€β”€ de/
β”‚       β”‚   β”œβ”€β”€ common.json          # Shared UI (navigation, buttons, labels)
β”‚       β”‚   β”œβ”€β”€ auth.json            # Authentication & account flows
β”‚       β”‚   β”œβ”€β”€ validation.json      # Form validation messages
β”‚       β”‚   β”œβ”€β”€ organizer.json       # Organizer-specific UI
β”‚       β”‚   β”œβ”€β”€ speaker.json         # Speaker-specific UI
β”‚       β”‚   β”œβ”€β”€ partner.json         # Partner-specific UI
β”‚       β”‚   └── attendee.json        # Attendee-specific UI
β”‚       └── en/
β”‚           └── [same structure]
β”œβ”€β”€ src/
β”‚   └── i18n/
β”‚       β”œβ”€β”€ config.ts                # i18next initialization & configuration
β”‚       β”œβ”€β”€ types.ts                 # Auto-generated TypeScript types
β”‚       └── utils.ts                 # Formatting helpers (dates, currency)

Namespace Strategy:

  • common: Loaded immediately on app start (navigation, shared UI chrome)
  • auth: Loaded on authentication routes
  • validation: Loaded with forms (shared error messages)
  • Role-specific namespaces: Lazy-loaded when user switches to that role context

Translation Key Patterns

Nested JSON Structure (type-safe with TypeScript):

json
{
  "auth": {
    "login": {
      "title": "Anmelden",
      "emailLabel": "E-Mail-Adresse",
      "passwordLabel": "Passwort",
      "submitButton": "Anmelden",
      "forgotPassword": "Passwort vergessen?"
    },
    "validation": {
      "emailRequired": "E-Mail ist erforderlich",
      "invalidEmail": "UngΓΌltige E-Mail-Adresse"
    }
  },
  "common": {
    "navigation": {
      "dashboard": "Übersicht",
      "events": "Veranstaltungen",
      "profile": "Profil"
    }
  }
}

Usage in Components:

typescript
import { useTranslation } from 'react-i18next';

const LoginForm: React.FC = () => {
  const { t } = useTranslation('auth');

  return (
    <form>
      <h1>{t('login.title')}</h1>
      <TextField label={t('login.emailLabel')} />
      <TextField label={t('login.passwordLabel')} type="password" />
      <Button>{t('login.submitButton')}</Button>
    </form>
  );
};

TypeScript Type Safety

Compile-time Type Checking for Translation Keys:

typescript
// src/i18n/types.ts (auto-generated from de/common.json)
import 'react-i18next';
import type common from '../../public/locales/de/common.json';
import type auth from '../../public/locales/de/auth.json';
import type validation from '../../public/locales/de/validation.json';
import type organizer from '../../public/locales/de/organizer.json';
import type speaker from '../../public/locales/de/speaker.json';
import type partner from '../../public/locales/de/partner.json';
import type attendee from '../../public/locales/de/attendee.json';

declare module 'react-i18next' {
  interface CustomTypeOptions {
    defaultNS: 'common';
    resources: {
      common: typeof common;
      auth: typeof auth;
      validation: typeof validation;
      organizer: typeof organizer;
      speaker: typeof speaker;
      partner: typeof partner;
      attendee: typeof attendee;
    };
  }
}

Result:

  • IDE autocomplete for all translation keys
  • Compile-time errors for missing or mistyped keys
  • Type inference for interpolation variables

i18next Configuration

typescript
// src/i18n/config.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

// Import all translation files (bundled at build time)
import commonDe from '../../public/locales/de/common.json';
import authDe from '../../public/locales/de/auth.json';
import validationDe from '../../public/locales/de/validation.json';
import organizerDe from '../../public/locales/de/organizer.json';
import speakerDe from '../../public/locales/de/speaker.json';
import partnerDe from '../../public/locales/de/partner.json';
import attendeeDe from '../../public/locales/de/attendee.json';

import commonEn from '../../public/locales/en/common.json';
import authEn from '../../public/locales/en/auth.json';
import validationEn from '../../public/locales/en/validation.json';
import organizerEn from '../../public/locales/en/organizer.json';
import speakerEn from '../../public/locales/en/speaker.json';
import partnerEn from '../../public/locales/en/partner.json';
import attendeeEn from '../../public/locales/en/attendee.json';

i18n
  .use(LanguageDetector)
  .use(initReactI18next)
  .init({
    resources: {
      de: {
        common: commonDe,
        auth: authDe,
        validation: validationDe,
        organizer: organizerDe,
        speaker: speakerDe,
        partner: partnerDe,
        attendee: attendeeDe,
      },
      en: {
        common: commonEn,
        auth: authEn,
        validation: validationEn,
        organizer: organizerEn,
        speaker: speakerEn,
        partner: partnerEn,
        attendee: attendeeEn,
      },
    },

    fallbackLng: 'de',
    defaultNS: 'common',
    ns: ['common', 'auth', 'validation', 'organizer', 'speaker', 'partner', 'attendee'],

    detection: {
      order: ['localStorage', 'navigator', 'htmlTag'],
      caches: ['localStorage'],
      lookupLocalStorage: 'batbern-language',
    },

    interpolation: {
      escapeValue: false, // React already escapes by default
    },

    react: {
      useSuspense: true, // Use React Suspense for async loading
    },
  });

export default i18n;

Language Detection & Selection

Detection Priority Order:

  1. User Profile (authenticated users with saved language preference in database)
  2. LocalStorage (batbern-language key for returning users)
  3. Browser Language (navigator.language)
  4. Default Fallback: German (de)

Language Switcher Component:

typescript
// src/components/shared/LanguageSwitcher.tsx
import { useTranslation } from 'react-i18next';
import { MenuItem, Select } from '@mui/material';
import { useAuthStore } from '@/stores/authStore';

const LanguageSwitcher: React.FC = () => {
  const { i18n } = useTranslation();
  const { user, updateUserLanguage } = useAuthStore();

  const handleLanguageChange = async (newLang: 'de' | 'en') => {
    await i18n.changeLanguage(newLang);
    document.documentElement.lang = newLang;

    // Update user profile if authenticated
    if (user) {
      await updateUserLanguage(newLang);
    }
  };

  return (
    <Select
      value={i18n.language}
      onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en')}
      size="small"
    >
      <MenuItem value="de">πŸ‡¨πŸ‡­ Deutsch</MenuItem>
      <MenuItem value="en">πŸ‡¬πŸ‡§ English</MenuItem>
    </Select>
  );
};

Placement: Header navigation (top-right, next to user profile menu)

State Management Integration

typescript
// src/stores/uiStore.ts (Zustand)
interface UIState {
  locale: 'de' | 'en';
  setLocale: (locale: 'de' | 'en') => void;
  // ... other UI state
}

export const useUIStore = create<UIState>((set) => ({
  locale: 'de',
  setLocale: (locale) => {
    set({ locale });
    i18n.changeLanguage(locale); // Sync with i18next
    document.documentElement.lang = locale; // Update HTML lang attribute
  },
}));

Persistence:

  • LocalStorage: batbern-language key (automatic via i18next-browser-languagedetector)
  • Database: User profile preferredLanguage field (synchronized on language change for authenticated users)

Date & Time Formatting

Using date-fns with Locale Support:

typescript
// src/i18n/utils.ts
import { format, parseISO } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { useTranslation } from 'react-i18next';

const localeMap = {
  de: de,
  en: enUS,
};

export const useFormatDate = () => {
  const { i18n } = useTranslation();

  return {
    formatDate: (date: Date | string, formatStr: string = 'PPP') => {
      const dateObj = typeof date === 'string' ? parseISO(date) : date;
      return format(dateObj, formatStr, { locale: localeMap[i18n.language] });
    },

    formatDateTime: (date: Date | string) => {
      const dateObj = typeof date === 'string' ? parseISO(date) : date;
      return format(dateObj, 'PPP p', { locale: localeMap[i18n.language] });
    },
  };
};

// Usage in components:
const EventCard: React.FC<{ event: Event }> = ({ event }) => {
  const { formatDateTime } = useFormatDate();

  return <div>{formatDateTime(event.startTime)}</div>;
  // German: "15. MΓ€rz 2024, 14:30"
  // English: "March 15, 2024, 2:30 PM"
};

Number & Currency Formatting

Using Native Intl API (no additional library needed):

typescript
// src/i18n/utils.ts
export const useFormatNumber = () => {
  const { i18n } = useTranslation();

  const localeMap = {
    de: 'de-CH',
    en: 'en-US',
  };

  return {
    formatCurrency: (amount: number) => {
      return new Intl.NumberFormat(localeMap[i18n.language], {
        style: 'currency',
        currency: 'CHF',
      }).format(amount);
    },

    formatNumber: (value: number, decimals: number = 2) => {
      return new Intl.NumberFormat(localeMap[i18n.language], {
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals,
      }).format(value);
    },
  };
};

// Usage:
const { formatCurrency } = useFormatNumber();
formatCurrency(1500.5);
// German (de-CH): "CHF 1'500.50"
// English (en-US): "CHF 1,500.50"

Pluralization

Built-in i18next Pluralization:

json
{
  "speakers": {
    "count_zero": "Keine Referenten",
    "count_one": "{{count}} Referent",
    "count_other": "{{count}} Referenten"
  },
  "events": {
    "registered_one": "Sie sind fΓΌr {{count}} Veranstaltung registriert",
    "registered_other": "Sie sind fΓΌr {{count}} Veranstaltungen registriert"
  }
}
typescript
const { t } = useTranslation('common');

t('speakers.count', { count: 0 });  // "Keine Referenten"
t('speakers.count', { count: 1 });  // "1 Referent"
t('speakers.count', { count: 5 });  // "5 Referenten"

Content Translation Strategy

What Gets Translated:

Content Type Translation Approach
UI Chrome (navigation, buttons, labels, help text) βœ… Fully translated (de/en)
Form Validation Messages βœ… Fully translated (de/en)
System Notifications βœ… Fully translated based on user’s UI language
Email Templates βœ… Fully translated (separate AWS SES templates for de/en)
Event Titles & Descriptions ❌ Single language (organizer’s choice, typically German)
Speaker Abstracts ❌ Original language preserved with language metadata field
Company Names ❌ Never translated
User-Generated Content ❌ Preserved in original language

Rationale:

  • Swiss audience is primarily German-speaking
  • Event content is typically German with occasional English speakers
  • Preserving original language maintains authenticity and avoids poor machine translation
  • Language metadata allows future filtering/search by content language

User-Generated Content Handling:

  • Database schema includes optional language field (ISO 639-1: β€˜de’, β€˜en’)
  • UI displays language badge for multilingual events
  • No automatic translation - content shown in original language

URL Structure & SEO

Query Parameter Approach:

https://batbern.ch/current-event?lang=de
https://batbern.ch/current-event?lang=en

HTML Lang Attribute: Dynamically updated

html
<html lang="de"> <!-- or lang="en" -->

Meta Tags (for public pages):

typescript
// Update on language change
<meta name="description" content={t('meta.description')} />
<link rel="alternate" hreflang="de" href="?lang=de" />
<link rel="alternate" hreflang="en" href="?lang=en" />

CI/CD Translation Validation

Pre-commit Hook: Translation Key Parity Check

bash
#!/bin/bash
# .husky/pre-commit

# Validate that all translation keys exist in both de and en
node scripts/validate-translations.js

if [ $? -ne 0 ]; then
  echo "❌ Translation validation failed. Ensure all keys exist in both de and en."
  exit 1
fi

Validation Script:

javascript
// scripts/validate-translations.js
const fs = require('fs');
const path = require('path');

const namespaces = ['common', 'auth', 'validation', 'organizer', 'speaker', 'partner', 'attendee'];
const languages = ['de', 'en'];

function flattenKeys(obj, prefix = '') {
  return Object.keys(obj).reduce((acc, key) => {
    const fullKey = prefix ? `${prefix}.${key}` : key;
    if (typeof obj[key] === 'object' && obj[key] !== null) {
      return acc.concat(flattenKeys(obj[key], fullKey));
    }
    return acc.concat(fullKey);
  }, []);
}

let hasErrors = false;

namespaces.forEach(ns => {
  const deFile = path.join(__dirname, `../public/locales/de/${ns}.json`);
  const enFile = path.join(__dirname, `../public/locales/en/${ns}.json`);

  const deKeys = flattenKeys(JSON.parse(fs.readFileSync(deFile, 'utf-8')));
  const enKeys = flattenKeys(JSON.parse(fs.readFileSync(enFile, 'utf-8')));

  const missingInEn = deKeys.filter(k => !enKeys.includes(k));
  const missingInDe = enKeys.filter(k => !deKeys.includes(k));

  if (missingInEn.length > 0) {
    console.error(`❌ [${ns}] Missing in English:`, missingInEn);
    hasErrors = true;
  }

  if (missingInDe.length > 0) {
    console.error(`❌ [${ns}] Missing in German:`, missingInDe);
    hasErrors = true;
  }
});

if (hasErrors) {
  process.exit(1);
} else {
  console.log('βœ… All translation keys are in sync between de and en.');
  process.exit(0);
}

GitHub Actions CI Check:

yaml
# .github/workflows/ci.yml
- name: Validate Translations
  run: npm run validate:translations

Testing Strategy

Translation Key Tests:

typescript
// __tests__/i18n/translations.test.ts
import { describe, it, expect } from 'vitest';
import commonDe from '@/locales/de/common.json';
import commonEn from '@/locales/en/common.json';

describe('Translation Completeness', () => {
  it('should have matching keys in de and en', () => {
    const deKeys = flattenKeys(commonDe);
    const enKeys = flattenKeys(commonEn);

    expect(deKeys).toEqual(enKeys);
  });
});

Component Translation Tests:

typescript
// __tests__/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import i18n from '@/i18n/config';
import LoginForm from '@/components/auth/LoginForm';

describe('LoginForm i18n', () => {
  it('renders in German by default', async () => {
    await i18n.changeLanguage('de');
    render(
      <I18nextProvider i18n={i18n}>
        <LoginForm />
      </I18nextProvider>
    );

    expect(screen.getByText('Anmelden')).toBeInTheDocument();
  });

  it('renders in English when language is changed', async () => {
    await i18n.changeLanguage('en');
    render(
      <I18nextProvider i18n={i18n}>
        <LoginForm />
      </I18nextProvider>
    );

    expect(screen.getByText('Sign In')).toBeInTheDocument();
  });
});

E2E Language Switching Tests (Playwright):

typescript
// e2e/i18n.spec.ts
import { test, expect } from '@playwright/test';

test('should switch language from German to English', async ({ page }) => {
  await page.goto('/');

  // Verify default German
  await expect(page.locator('h1')).toHaveText('Willkommen bei BATbern');

  // Switch to English
  await page.selectOption('[data-testid="language-switcher"]', 'en');

  // Verify English
  await expect(page.locator('h1')).toHaveText('Welcome to BATbern');

  // Verify persistence (reload page)
  await page.reload();
  await expect(page.locator('h1')).toHaveText('Welcome to BATbern');
});

Accessibility Considerations

Screen Reader Support:

typescript
// Update HTML lang attribute and announce change
const handleLanguageChange = async (newLang: 'de' | 'en') => {
  await i18n.changeLanguage(newLang);
  document.documentElement.lang = newLang;

  // Announce language change to screen readers
  const announcement = document.createElement('div');
  announcement.setAttribute('role', 'status');
  announcement.setAttribute('aria-live', 'polite');
  announcement.textContent = newLang === 'de'
    ? 'Sprache wurde auf Deutsch geΓ€ndert'
    : 'Language changed to English';
  document.body.appendChild(announcement);
  setTimeout(() => announcement.remove(), 1000);
};

Keyboard Navigation:

  • Language switcher fully keyboard accessible
  • Focus management maintained during language switch

Performance Optimization

Bundle Size Management:

  • All translations bundled at build time (~50KB total for de + en)
  • Vite tree-shaking removes unused namespaces
  • Common namespace loaded immediately (~5KB)
  • Role-specific namespaces lazy-loaded on route navigation

Caching:

  • Language preference cached in localStorage
  • Translations cached by browser (static assets with cache headers)

Initial Load Performance:

Initial bundle: common.json (de) + auth.json (de) = ~8KB
On role switch: Load role namespace = ~5KB additional
On language switch: Load en namespaces = ~25KB additional (lazy)

Bootstrap & Prerendering (SSG)

Plan & decision record: docs/plans/public-homepage-prerender.md.

Decoupled bootstrap (src/main.tsx)

The app renders its shell immediately β€” it does not block on the runtime-config round-trip and shows no full-screen bootstrap spinner:

  • main.tsx synchronously sets the API client base URL via getDefaultApiBaseUrl() (src/config/runtime-config.ts) = getApiUrl() + '/api/v1', which is byte-identical to the backend ConfigController.getApiBaseUrl() value in every environment. Public data (e.g. the homepage current event) therefore loads in parallel with GET /api/v1/config instead of after it.
  • main.tsx then mounts <ConfigProvider><App/></ConfigProvider> with createRoot. ConfigProvider (src/contexts/ConfigContext.tsx) loads runtime config in an effect and, on resolve, calls updateApiClientConfig + setAmplifyRuntimeConfig. It accepts an optional config prop for tests. A config-load failure is non-fatal β€” public pages keep working; it does not blank the app.
  • useConfig() still throws if read with no config. The only public-path consumer, useTurnstile, uses useOptionalConfig() (null β‡’ β€œturnstile not ready / disabled”). The newsletter widget (the hook’s host) is additionally lazy-loaded below the fold.
  • Genuinely slow regions use the BATbern logo spinner (BATbernLoader), e.g. the homepage event-data block β€” never a generic bootstrap spinner.

Build-time prerender (SSG) of public routes

The public, MUI-free routes are prerendered to static HTML so real content paints before any JS executes (the FCP/LCP lever). Tooling = a build-time Playwright crawl (web-frontend/scripts/prerender.mjs), chosen over vite-react-ssg (beta, peers cap at Vite 7 / React Router 6 β€” incompatible with our Vite 8 / RR 7 stack) and RR7 framework-mode prerender (would require migrating off the <BrowserRouter> SPA). The crawl is version-immune: it operates on the already-built dist.

  • Render model = paint-and-replace (not hydration): the static HTML paints first, then createRoot().render() mounts fresh and replaces it. No hydrateRoot β‡’ no hydration-mismatch risk across the 10 locales. The prerendered shell is the German default locale; other locales are corrected client-side on mount.
  • Routes prerendered: /privacy, /support, /about (pure static) and / (the homepage loading shell β€” nav + animated logo spinner + footer; the live event hydrates in client-side, preserving β€œbuild once”).
  • Output convention: the homepage is written to dist/home/index.html (NOT dist/index.html) so the build’s neutral dist/index.html stays the SPA fallback for all non-prerendered routes (admin/archive/events) β€” no shell flash on those. Static routes are written to dist/<route>/index.html.
  • Build scripts (web-frontend/package.json): the default build is unchanged (tsc && vite build); build:prerender = build + the crawl. The crawl degrades gracefully to a CSR-only build if Chromium is unavailable (prerender is a pure enhancement, never a hard build dependency). Deploy paths (deploy-staging.yml, scripts/deploy/publish-beta-frontend.sh) run build:prerender; the local/CI verification build (make build β†’ npm run build, build.yml) stays the fast plain build.
  • CloudFront routing (infrastructure/lib/stacks/frontend-stack.ts, RouterFunction): /β†’/home/index.html, /privacy|/about|/supportβ†’ their folder index.html, everything else β†’ neutral /index.html. Safe even if a prerendered object is missing: the distribution’s 404 β†’ /index.html error response falls back to the CSR shell.
  • Guardrails: the crawl fails the build on an empty #root snapshot or a missing per-route content marker, and on any uncaught page error.