Frontend Architecture
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:
-
OpenAPI Spec (single source of truth):
- Located at:
/docs/api/companies-api.openapi.yml - Maintained by backend team
- Versioned in Git
- Located at:
-
Type Generation:
bashnpm run generate:api-types- Generates:
src/types/generated/company-api.types.ts - Types are committed to repository
- Generates:
-
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
-
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:
// 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):
// 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):
// β 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:
// 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):
// β
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
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:
// 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:
// 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:
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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
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:
- Role-based access β roles come from the hydrated auth user (
AuthContext, including the Pattern 3b DB fallback for empty JWT roles β see06b-user-lifecycle-sync.md); unauthorized roles are redirected away before the component tree renders. - ToS/Privacy consent gate (Story 12.11, βPattern Cβ in 06b) β if the hydrated
userβs
termsAcceptedAtisnull(consent confirmed absent), every protected path except/profileand/logoutredirects to/profile?onboarding=1, where the Consent tab is preselected; a successful accept callsrefreshUser()and the gate lifts.undefined(hydration failed) fails open β a transient/users/mefailure 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):
{
"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:
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:
// 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
// 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:
- User Profile (authenticated users with saved language preference in database)
- LocalStorage (
batbern-languagekey for returning users) - Browser Language (
navigator.language) - Default Fallback: German (
de)
Language Switcher Component:
// 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
// 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-languagekey (automatic via i18next-browser-languagedetector) - Database: User profile
preferredLanguagefield (synchronized on language change for authenticated users)
Date & Time Formatting
Using date-fns with Locale Support:
// 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):
// 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:
{
"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"
}
}
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
languagefield (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 lang="de"> <!-- or lang="en" -->
Meta Tags (for public pages):
// 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
#!/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:
// 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:
# .github/workflows/ci.yml
- name: Validate Translations
run: npm run validate:translations
Testing Strategy
Translation Key Tests:
// __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:
// __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):
// 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:
// 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.tsxsynchronously sets the API client base URL viagetDefaultApiBaseUrl()(src/config/runtime-config.ts) =getApiUrl() + '/api/v1', which is byte-identical to the backendConfigController.getApiBaseUrl()value in every environment. Public data (e.g. the homepage current event) therefore loads in parallel withGET /api/v1/configinstead of after it.main.tsxthen mounts<ConfigProvider><App/></ConfigProvider>withcreateRoot.ConfigProvider(src/contexts/ConfigContext.tsx) loads runtime config in an effect and, on resolve, callsupdateApiClientConfig+setAmplifyRuntimeConfig. It accepts an optionalconfigprop 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, usesuseOptionalConfig()(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. NohydrateRootβ 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(NOTdist/index.html) so the buildβs neutraldist/index.htmlstays the SPA fallback for all non-prerendered routes (admin/archive/events) β no shell flash on those. Static routes are written todist/<route>/index.html. - Build scripts (
web-frontend/package.json): the defaultbuildis 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) runbuild:prerender; the local/CI verification build (make buildβnpm run build,build.yml) stays the fast plainbuild. - CloudFront routing (
infrastructure/lib/stacks/frontend-stack.ts,RouterFunction):/β/home/index.html,/privacy|/about|/supportβ their folderindex.html, everything else β neutral/index.html. Safe even if a prerendered object is missing: the distributionβs404 β /index.htmlerror response falls back to the CSR shell. - Guardrails: the crawl fails the build on an empty
#rootsnapshot or a missing per-route content marker, and on any uncaught page error.