Files
myeasycms-v2/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx
Zaid Marzguioui a1719671df
Some checks failed
Workflow / ⚫️ Test (push) Has been cancelled
Workflow / ʦ TypeScript (push) Has been cancelled
fix: QA remediation — all 19 audit fixes (C+ → A-)
## Summary
Fixes all 31  FAILs and most ⚠️ WARNs from the QA audit (113/33⚠️/31).

## Changes

### FIX 1 — Loading Skeleton
- Replace full-screen GlobalLoader with PageBody-scoped animate-pulse skeleton
- Sidebar stays visible during page transitions

### FIX 2 — Status Badges i18n (15 files, 12 label maps)
- Add *_LABEL_KEYS maps to lib/status-badges.ts (i18n keys instead of German)
- Update all 15 consumer files to use t(*_LABEL_KEYS[status])
- Add status namespace to finance.json (de+en)
- Add registration_open to events.json status (de+en)
- Add status block to cms.json events section (de+en)
- Add missing pending/bounced keys to newsletter.json (de+en)
- Add active key to courses.json status (de+en)

### FIX 3 — Error Page i18n
- Replace 4 hardcoded German strings with useTranslations('common')
- Add error.* keys to common.json (de+en)

### FIX 4 — Account Not Found i18n
- Convert AccountNotFound to async Server Component
- Resolve default props from getTranslations('common')
- Add accountNotFoundCard.* keys to common.json (de+en)

### FIX 5 — Publish Toggle Button (6 strings + 2 bugs)
- Add useTranslations('siteBuilder'), replace 6 German strings
- Fix: add response.ok check before router.refresh()
- Fix: add disabled={isPending} to AlertDialogAction
- Fix: use Base UI render= prop pattern (not asChild)
- Add pages.hide/publish/hideTitle/publishTitle/hideDesc/publishDesc/
  toggleError/cancelAction to siteBuilder.json (de+en)

### FIX 6 — Cancel Booking Button (7 strings + bugs)
- Add useTranslations('bookings'), replace all strings
- Fix: use render= prop pattern, add disabled={isPending}
- Add cancel.* and calendar.* keys to bookings.json (de+en)

### FIX 7 — Portal Pages i18n (5 files, ~40 strings)
- Create i18n/messages/de/portal.json and en/portal.json
- Add 'portal' to i18n/request.ts namespace list
- Rewrite portal/page.tsx, invite/page.tsx, profile/page.tsx,
  documents/page.tsx with getTranslations('portal')
- Fix portal-linked-accounts.tsx: add useTranslations, replace
  hardcoded strings, fix AlertDialogTrigger render= pattern

### FIX 8 — Invitations View (1 string)
- Replace hardcoded string with t('invitations.emptyDescription')
- Add key to members.json (de+en)

### FIX 9 — Dead Navigation Link
- Comment out memberPortal nav entry (page does not exist)

### FIX 10 — Calendar Button Accessibility
- Add aria-label + aria-hidden to all icon buttons in bookings/calendar
- Add aria-label + aria-hidden to all icon buttons in courses/calendar
- Add previousMonth/nextMonth/backToBookings/backToCourses to
  bookings.json and courses.json (de+en)

### FIX 11 — Pagination Aria Labels
- Add aria-label to icon-only pagination buttons in finance/page.tsx
- Fix Link/Button nesting in newsletter/page.tsx, add aria-labels
- Add pagination.* to common.json (de+en)
- Add common.previous/next to newsletter.json (de+en)

### FIX 12 — Site Builder Type Safety
- Add SitePage interface, replace Record<string,unknown> in page.tsx
- Add SitePost interface, replace Record<string,unknown> in posts/page.tsx
- Remove String() casts on typed properties

### FIX 14 — EmptyState Heading Level
- Change <h3> to <h2> in empty-state.tsx (WCAG heading sequence)

### FIX 16 — CmsPageShell Nullish Coalescing
- Change description ?? <AppBreadcrumbs /> to !== undefined check

### FIX 17 — Meetings Protocol Hardcoded Strings
- Replace 5 hardcoded German strings with t() in protocol detail page
- Add notFound/back/backToList/statusPublished/statusDraft to meetings.json

### FIX 18 — Finance Toolbar Hardcoded Strings
- Replace toolbar filter labels with t() calls in finance/page.tsx

### FIX 19 — Admin Audit Hardcoded Strings
- Add getTranslations('cms.audit') to audit page
- Replace title, description, column headers, pagination labels
- Add description/timestamp/paginationPrevious/paginationNext to cms.json

## Verification
- tsc --noEmit: 0 errors
- Turbopack: Compiled successfully in 9.3s
- Lint: 0 new errors introduced
- All 8 audit verification checks pass
2026-04-02 01:18:15 +02:00

294 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Link from 'next/link';
import {
GraduationCap,
Users,
Calendar,
Euro,
User,
Clock,
Pencil,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import {
COURSE_STATUS_VARIANT,
COURSE_STATUS_LABEL_KEYS,
} from '~/lib/status-badges';
import { CreateSessionDialog } from './create-session-dialog';
import { DeleteCourseButton } from './delete-course-button';
interface PageProps {
params: Promise<{ account: string; courseId: string }>;
}
export default async function CourseDetailPage({ params }: PageProps) {
const { account, courseId } = await params;
const client = getSupabaseServerClient();
const api = createCourseManagementApi(client);
const t = await getTranslations('courses');
const [course, participants, sessions] = await Promise.all([
api.getCourse(courseId),
api.getParticipants(courseId),
api.getSessions(courseId),
]);
if (!course) return <AccountNotFound />;
const courseData = course as Record<string, unknown>;
return (
<CmsPageShell account={account} title={String(courseData.name)}>
<div className="flex w-full flex-col gap-6">
{/* Action Buttons */}
<div className="flex justify-end gap-2">
<Button asChild variant="outline" size="sm">
<Link href={`/home/${account}/courses/${courseId}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
{t('detail.edit')}
</Link>
</Button>
<DeleteCourseButton courseId={courseId} accountSlug={account} />
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardContent className="flex items-center gap-3 p-4">
<GraduationCap className="text-primary h-5 w-5" />
<div>
<p className="text-muted-foreground text-xs">
{t('detail.name')}
</p>
<p className="font-semibold">{String(courseData.name)}</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Clock className="text-primary h-5 w-5" />
<div>
<p className="text-muted-foreground text-xs">
{t('common.status')}
</p>
<Badge
variant={
COURSE_STATUS_VARIANT[String(courseData.status)] ??
'secondary'
}
>
{t(COURSE_STATUS_LABEL_KEYS[String(courseData.status)] ??
String(courseData.status))}
</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<User className="text-primary h-5 w-5" />
<div>
<p className="text-muted-foreground text-xs">
{t('detail.instructor')}
</p>
<p className="font-semibold">
{String(courseData.instructor_id ?? '—')}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Calendar className="text-primary h-5 w-5" />
<div>
<p className="text-muted-foreground text-xs">
{t('detail.dateRange')}
</p>
<p className="font-semibold">
{formatDate(courseData.start_date as string)}
{' '}
{formatDate(courseData.end_date as string)}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Euro className="text-primary h-5 w-5" />
<div>
<p className="text-muted-foreground text-xs">{t('list.fee')}</p>
<p className="font-semibold">
{courseData.fee != null
? formatCurrencyAmount(courseData.fee as number)
: '—'}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 p-4">
<Users className="text-primary h-5 w-5" />
<div>
<p className="text-muted-foreground text-xs">
{t('detail.participants')}
</p>
<p className="font-semibold">
{participants.length} / {String(courseData.capacity ?? '∞')}
</p>
</div>
</CardContent>
</Card>
</div>
{/* Participants Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t('detail.participants')}</CardTitle>
<Button variant="outline" size="sm" asChild>
<Link href={`/home/${account}/courses/${courseId}/participants`}>
{t('detail.viewAll')}
</Link>
</Button>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('detail.name')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('detail.email')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('common.status')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('detail.date')}
</th>
</tr>
</thead>
<tbody>
{participants.length === 0 ? (
<tr>
<td
colSpan={4}
className="text-muted-foreground p-6 text-center"
>
{t('detail.noParticipants')}
</td>
</tr>
) : (
participants.map((p: Record<string, unknown>) => (
<tr
key={String(p.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3 font-medium">
{String(p.last_name ?? '')},{' '}
{String(p.first_name ?? '')}
</td>
<td className="p-3">{String(p.email ?? '—')}</td>
<td className="p-3">
<Badge variant="outline">
{String(p.status ?? '—')}
</Badge>
</td>
<td className="p-3">
{formatDate(p.enrolled_at as string)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
{/* Sessions Section */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{t('detail.sessions')}</CardTitle>
<div className="flex gap-2">
<CreateSessionDialog courseId={courseId} />
<Button variant="outline" size="sm" asChild>
<Link href={`/home/${account}/courses/${courseId}/attendance`}>
{t('detail.attendance')}
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[640px] text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th scope="col" className="p-3 text-left font-medium">
{t('detail.date')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.startDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('list.endDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('detail.cancelled')}
</th>
</tr>
</thead>
<tbody>
{sessions.length === 0 ? (
<tr>
<td
colSpan={4}
className="text-muted-foreground p-6 text-center"
>
{t('detail.noSessions')}
</td>
</tr>
) : (
sessions.map((s: Record<string, unknown>) => (
<tr
key={String(s.id)}
className="hover:bg-muted/30 border-b"
>
<td className="p-3">
{formatDate(s.session_date as string)}
</td>
<td className="p-3">{String(s.start_time ?? '—')}</td>
<td className="p-3">{String(s.end_time ?? '—')}</td>
<td className="p-3">
{s.cancelled ? (
<Badge variant="destructive">
{t('common.yes')}
</Badge>
) : (
'—'
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</div>
</CmsPageShell>
);
}