2026-02-25 19:00:43 +00:00
|
|
|
import React, { useState, useEffect } from 'react';
|
2026-01-07 09:31:02 +00:00
|
|
|
import { Card } from '../ui/card';
|
|
|
|
|
import { Button } from '../ui/button';
|
2026-02-25 19:00:43 +00:00
|
|
|
import { Save, AlertTriangle, Server, Activity, Loader2 } from 'lucide-react';
|
2026-01-26 13:12:17 +00:00
|
|
|
import { useToast } from '../../components/feedback/ToastProvider';
|
2026-02-25 19:00:43 +00:00
|
|
|
import { adminService } from '../../services/adminService';
|
|
|
|
|
import { logger } from '@/utils/logger';
|
|
|
|
|
|
|
|
|
|
interface FeatureFlag {
|
|
|
|
|
name: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
description?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Announcement {
|
|
|
|
|
id: string;
|
|
|
|
|
title: string;
|
|
|
|
|
content: string;
|
|
|
|
|
type: string;
|
|
|
|
|
is_active: boolean;
|
|
|
|
|
}
|
2026-01-07 09:31:02 +00:00
|
|
|
|
|
|
|
|
export const AdminSettingsView: React.FC = () => {
|
|
|
|
|
const { addToast } = useToast();
|
|
|
|
|
const [maintenance, setMaintenance] = useState(false);
|
2026-02-25 19:00:43 +00:00
|
|
|
const [maintenanceLoading, setMaintenanceLoading] = useState(true);
|
|
|
|
|
const [uploadLimit, setUploadLimit] = useState(500);
|
|
|
|
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
|
|
|
|
const [announcementsLoading, setAnnouncementsLoading] = useState(true);
|
|
|
|
|
const [featureFlags, setFeatureFlags] = useState<FeatureFlag[]>([]);
|
|
|
|
|
const [flagsLoading, setFlagsLoading] = useState(true);
|
|
|
|
|
const [newAnnouncementTitle, setNewAnnouncementTitle] = useState('');
|
|
|
|
|
const [newAnnouncementContent, setNewAnnouncementContent] = useState('');
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const load = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const [mm, flags, ann] = await Promise.all([
|
|
|
|
|
adminService.getMaintenanceMode(),
|
|
|
|
|
adminService.getFeatureFlags(),
|
|
|
|
|
adminService.getAnnouncements(),
|
|
|
|
|
]);
|
|
|
|
|
setMaintenance(mm);
|
|
|
|
|
setFeatureFlags(flags);
|
|
|
|
|
setAnnouncements(ann);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.error('Admin settings load failed', { error: e });
|
|
|
|
|
addToast('Failed to load settings', 'error');
|
|
|
|
|
} finally {
|
|
|
|
|
setMaintenanceLoading(false);
|
|
|
|
|
setFlagsLoading(false);
|
|
|
|
|
setAnnouncementsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
load();
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleMaintenanceToggle = async () => {
|
|
|
|
|
const next = !maintenance;
|
|
|
|
|
try {
|
|
|
|
|
await adminService.setMaintenanceMode(next);
|
|
|
|
|
setMaintenance(next);
|
|
|
|
|
addToast(next ? 'Maintenance mode enabled' : 'Maintenance mode disabled', 'success');
|
|
|
|
|
} catch {
|
|
|
|
|
addToast('Failed to update maintenance mode', 'error');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleFlagToggle = async (name: string, enabled: boolean) => {
|
|
|
|
|
try {
|
|
|
|
|
await adminService.toggleFeatureFlag(name, enabled);
|
|
|
|
|
setFeatureFlags((prev) => prev.map((f) => (f.name === name ? { ...f, enabled } : f)));
|
|
|
|
|
addToast(`Feature ${name} ${enabled ? 'enabled' : 'disabled'}`, 'success');
|
|
|
|
|
} catch {
|
|
|
|
|
addToast('Failed to toggle feature flag', 'error');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCreateAnnouncement = async () => {
|
|
|
|
|
if (!newAnnouncementTitle.trim() || !newAnnouncementContent.trim()) {
|
|
|
|
|
addToast('Title and content required', 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await adminService.createAnnouncement({
|
|
|
|
|
title: newAnnouncementTitle.trim(),
|
|
|
|
|
content: newAnnouncementContent.trim(),
|
|
|
|
|
});
|
|
|
|
|
const ann = await adminService.getAnnouncements();
|
|
|
|
|
setAnnouncements(ann);
|
|
|
|
|
setNewAnnouncementTitle('');
|
|
|
|
|
setNewAnnouncementContent('');
|
|
|
|
|
addToast('Announcement created', 'success');
|
|
|
|
|
} catch {
|
|
|
|
|
addToast('Failed to create announcement', 'error');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDeleteAnnouncement = async (id: string) => {
|
|
|
|
|
try {
|
|
|
|
|
await adminService.deleteAnnouncement(id);
|
|
|
|
|
setAnnouncements((prev) => prev.filter((a) => a.id !== id));
|
|
|
|
|
addToast('Announcement deleted', 'success');
|
|
|
|
|
} catch {
|
|
|
|
|
addToast('Failed to delete announcement', 'error');
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-01-07 09:31:02 +00:00
|
|
|
|
|
|
|
|
const handleSave = () => {
|
2026-01-13 18:47:57 +00:00
|
|
|
addToast('System settings updated', 'success');
|
2026-01-07 09:31:02 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-8 animate-fadeIn max-w-4xl pb-20">
|
feat(ui): education, gamification, developer, admin views polish
Education:
- CourseCard: lessons count badge, progress bar, backdrop-blur on badges
- EducationView: framer-motion stagger on grid
- Filters: interactive color-coded pills (Beginner/Intermediate/Advanced)
- MyCoursesView: stagger animation, semantic token migration
Gamification:
- LeaderboardView: gold/silver/bronze podium styling with glow + accents
- AchievementCard: shine sweep animation on hover, lift effect
- AchievementsView: stagger animation with filter re-animation
- XPBar: semantic token fix
Developer dashboard:
- API key copy-to-clipboard with icon toggle
- Status indicator badges with animated pulse dot
Commerce/Admin:
- WishlistView: stagger animation, hover lift
- PurchasesView: stagger on list items
- Admin views: consistent headers, semantic tokens (text-white → text-foreground)
18 files modified, all text-white → text-foreground migrations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:48:45 +00:00
|
|
|
<div className="flex justify-between items-center border-b border-border/50 pb-6">
|
|
|
|
|
<div>
|
2026-02-12 00:49:07 +00:00
|
|
|
<h2 className="text-2xl font-heading font-bold text-foreground tracking-tight">
|
feat(ui): education, gamification, developer, admin views polish
Education:
- CourseCard: lessons count badge, progress bar, backdrop-blur on badges
- EducationView: framer-motion stagger on grid
- Filters: interactive color-coded pills (Beginner/Intermediate/Advanced)
- MyCoursesView: stagger animation, semantic token migration
Gamification:
- LeaderboardView: gold/silver/bronze podium styling with glow + accents
- AchievementCard: shine sweep animation on hover, lift effect
- AchievementsView: stagger animation with filter re-animation
- XPBar: semantic token fix
Developer dashboard:
- API key copy-to-clipboard with icon toggle
- Status indicator badges with animated pulse dot
Commerce/Admin:
- WishlistView: stagger animation, hover lift
- PurchasesView: stagger on list items
- Admin views: consistent headers, semantic tokens (text-white → text-foreground)
18 files modified, all text-white → text-foreground migrations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-09 23:48:45 +00:00
|
|
|
SYSTEM SETTINGS
|
|
|
|
|
</h2>
|
|
|
|
|
<p className="text-muted-foreground font-mono text-sm">
|
|
|
|
|
Global configuration and feature management.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
2026-01-13 18:47:57 +00:00
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
icon={<Save className="w-4 h-4" />}
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
>
|
|
|
|
|
SAVE CHANGES
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
{/* General Config */}
|
|
|
|
|
<Card variant="default">
|
2026-02-07 18:37:41 +00:00
|
|
|
<h3 className="font-bold text-foreground mb-6 flex items-center gap-2">
|
|
|
|
|
<Server className="w-5 h-5 text-muted-foreground" /> General Configuration
|
2026-01-13 18:47:57 +00:00
|
|
|
</h3>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
|
|
|
<div>
|
2026-02-07 18:37:41 +00:00
|
|
|
<label className="block text-sm font-bold text-muted-foreground mb-2">
|
|
|
|
|
Upload Limit (MB)
|
|
|
|
|
</label>
|
2026-01-13 18:47:57 +00:00
|
|
|
<input
|
|
|
|
|
type="number"
|
2026-02-07 18:37:41 +00:00
|
|
|
className="w-full bg-card border border-border rounded p-2 text-foreground outline-none focus:border-primary"
|
2026-01-13 18:47:57 +00:00
|
|
|
value={uploadLimit}
|
|
|
|
|
onChange={(e) => setUploadLimit(Number(e.target.value))}
|
|
|
|
|
/>
|
2026-02-07 18:37:41 +00:00
|
|
|
<p className="text-xs text-muted-foreground mt-1">
|
2026-01-13 18:47:57 +00:00
|
|
|
Maximum file size for standard users.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
2026-02-07 18:37:41 +00:00
|
|
|
<label className="block text-sm font-bold text-muted-foreground mb-2">
|
|
|
|
|
Default Storage Region
|
|
|
|
|
</label>
|
|
|
|
|
<select className="w-full bg-card border border-border rounded p-2 text-foreground outline-none focus:border-primary">
|
2026-01-13 18:47:57 +00:00
|
|
|
<option>us-east-1 (N. Virginia)</option>
|
|
|
|
|
<option>eu-west-1 (Ireland)</option>
|
|
|
|
|
<option>ap-northeast-1 (Tokyo)</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
{/* Feature Flags */}
|
|
|
|
|
<Card variant="default">
|
2026-02-07 18:37:41 +00:00
|
|
|
<h3 className="font-bold text-foreground mb-6 flex items-center gap-2">
|
|
|
|
|
<Activity className="w-5 h-5 text-primary" /> Feature Flags
|
2026-01-13 18:47:57 +00:00
|
|
|
</h3>
|
2026-02-25 19:00:43 +00:00
|
|
|
{flagsLoading ? (
|
|
|
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
|
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading...
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
{featureFlags.map((flag) => (
|
|
|
|
|
<div
|
|
|
|
|
key={flag.name}
|
fix: stabilize builds, tests, and lint across all stacks
Complete stabilization pass bringing all 3 stacks to green:
Frontend (apps/web/):
- Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks
- Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified)
- Rename 306 story imports from @storybook/react to @storybook/react-vite
- Fix conditional hook call in useMediaQuery.ts useIsTablet
- Move useQuery to top of LoginPage.tsx component
- Remove useless try/catch in GearFormModal.tsx
- Fix stale closure in ResetPasswordPage.tsx handleChange
- Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio)
no-ops since global StorybookDecorator already provides these — prevents
nested Router / duplicate provider crashes in vitest-browser
- Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile)
- Update i18n initialization in test setup (await init before changeLanguage)
- Update ~30 test assertions from English to French to match i18n translations
- Update test assertions to match SUMI V3 design changes (shadow vs border)
- Fix remaining story type errors (PlayerError, PlaylistBatchActions,
TrackFilters, VirtualizedChatMessages)
Backend (veza-backend-api/):
- Fix response_test.go RespondWithAppError signature (2 args, not 3)
- Fix TestErrorContractAuthEndpoints expected error codes
(ErrCodeUnauthorized vs ErrCodeInvalidCredentials)
- Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup
- Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold
(needs 5 unique users, not 1)
- Replace NOW() PostgreSQL function with time.Now() parameter in marketplace
service for SQLite test compatibility
- Add missing AutoMigrate entries in marketplace_test.go
(ProductImage, ProductPreview, ProductLicense, ProductReview)
Results:
- Frontend TypeCheck: 617 errors -> 0 errors
- Frontend ESLint: 349 errors -> 0 errors
- Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing)
- Backend go vet: 1 error -> 0 errors
- Backend tests: 5 failing -> all 13 packages passing
- Rust: 150/150 tests passing (unchanged)
- Storybook audit: 0 errors across 1244 stories
Triage report: docs/TRIAGE_REPORT.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:07 +00:00
|
|
|
className="flex items-center justify-between p-4 bg-muted/50 rounded shadow-[0_0_8px_rgba(26,26,30,0.05)]"
|
2026-02-25 19:00:43 +00:00
|
|
|
>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="font-bold text-foreground">{flag.name}</span>
|
|
|
|
|
{flag.description && (
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-1">{flag.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleFlagToggle(flag.name, !flag.enabled)}
|
|
|
|
|
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${flag.enabled ? 'bg-success' : 'bg-muted'}`}
|
|
|
|
|
aria-label={`Toggle ${flag.name}`}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`absolute top-1 w-4 h-4 bg-background rounded-full transition-all shadow-md ${flag.enabled ? 'left-7' : 'left-1'}`}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
2026-01-13 18:47:57 +00:00
|
|
|
</div>
|
2026-02-25 19:00:43 +00:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-13 18:47:57 +00:00
|
|
|
</Card>
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
{/* Maintenance */}
|
2026-02-07 18:37:41 +00:00
|
|
|
<Card variant="default" className="border-destructive/30">
|
|
|
|
|
<h3 className="font-bold text-foreground mb-6 flex items-center gap-2">
|
|
|
|
|
<AlertTriangle className="w-5 h-5 text-destructive" /> Emergency &
|
2026-01-13 18:47:57 +00:00
|
|
|
Maintenance
|
|
|
|
|
</h3>
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
2026-02-07 18:37:41 +00:00
|
|
|
<div className="text-foreground font-bold">Maintenance Mode</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
2026-01-13 18:47:57 +00:00
|
|
|
Disable access for non-admin users
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-25 19:00:43 +00:00
|
|
|
{maintenanceLoading ? (
|
|
|
|
|
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
|
|
|
) : (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleMaintenanceToggle}
|
|
|
|
|
className={`w-12 h-6 rounded-full relative cursor-pointer transition-colors ${maintenance ? 'bg-destructive' : 'bg-muted'}`}
|
|
|
|
|
aria-label="Toggle maintenance mode"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={`absolute top-1 w-4 h-4 bg-background rounded-full transition-all ${maintenance ? 'left-7' : 'left-1'}`}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
2026-01-13 18:47:57 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-02-07 18:37:41 +00:00
|
|
|
<label className="block text-sm font-bold text-muted-foreground mb-2">
|
2026-02-25 19:00:43 +00:00
|
|
|
New Global Announcement
|
2026-01-13 18:47:57 +00:00
|
|
|
</label>
|
2026-02-25 19:00:43 +00:00
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="w-full bg-card border border-border rounded p-2 text-foreground outline-none focus:border-primary mb-2"
|
|
|
|
|
placeholder="Title..."
|
|
|
|
|
value={newAnnouncementTitle}
|
|
|
|
|
onChange={(e) => setNewAnnouncementTitle(e.target.value)}
|
|
|
|
|
/>
|
2026-01-13 18:47:57 +00:00
|
|
|
<textarea
|
2026-02-25 19:00:43 +00:00
|
|
|
className="w-full bg-card border border-border rounded p-4 text-foreground outline-none focus:border-primary h-24 resize-none mb-2"
|
|
|
|
|
placeholder="Content..."
|
|
|
|
|
value={newAnnouncementContent}
|
|
|
|
|
onChange={(e) => setNewAnnouncementContent(e.target.value)}
|
2026-01-13 18:47:57 +00:00
|
|
|
/>
|
2026-02-25 19:00:43 +00:00
|
|
|
<Button variant="secondary" size="sm" onClick={handleCreateAnnouncement}>
|
|
|
|
|
Create Announcement
|
|
|
|
|
</Button>
|
2026-01-13 18:47:57 +00:00
|
|
|
</div>
|
2026-02-25 19:00:43 +00:00
|
|
|
|
|
|
|
|
{announcementsLoading ? (
|
|
|
|
|
<div className="flex items-center gap-2 text-muted-foreground">
|
|
|
|
|
<Loader2 className="w-4 h-4 animate-spin" /> Loading announcements...
|
|
|
|
|
</div>
|
|
|
|
|
) : announcements.length > 0 ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<label className="block text-sm font-bold text-muted-foreground">
|
|
|
|
|
Active Announcements
|
|
|
|
|
</label>
|
|
|
|
|
{announcements.map((a) => (
|
|
|
|
|
<div
|
|
|
|
|
key={a.id}
|
fix: stabilize builds, tests, and lint across all stacks
Complete stabilization pass bringing all 3 stacks to green:
Frontend (apps/web/):
- Fix TypeScript nullability in useSeason.ts, useTimeOfDay.ts hooks
- Disable no-undef in ESLint config (TypeScript handles it; JSX misidentified)
- Rename 306 story imports from @storybook/react to @storybook/react-vite
- Fix conditional hook call in useMediaQuery.ts useIsTablet
- Move useQuery to top of LoginPage.tsx component
- Remove useless try/catch in GearFormModal.tsx
- Fix stale closure in ResetPasswordPage.tsx handleChange
- Make Storybook decorators (withRouter, withQueryClient, withToast, withAudio)
no-ops since global StorybookDecorator already provides these — prevents
nested Router / duplicate provider crashes in vitest-browser
- Fix nested MemoryRouter in 3 page stories (TrackDetail, PlaylistDetail, UserProfile)
- Update i18n initialization in test setup (await init before changeLanguage)
- Update ~30 test assertions from English to French to match i18n translations
- Update test assertions to match SUMI V3 design changes (shadow vs border)
- Fix remaining story type errors (PlayerError, PlaylistBatchActions,
TrackFilters, VirtualizedChatMessages)
Backend (veza-backend-api/):
- Fix response_test.go RespondWithAppError signature (2 args, not 3)
- Fix TestErrorContractAuthEndpoints expected error codes
(ErrCodeUnauthorized vs ErrCodeInvalidCredentials)
- Fix TestTrackHandler_GetTrackLikes_Success missing auth middleware setup
- Fix TestPlaybackAnalyticsService_GetTrackStats k-anonymity threshold
(needs 5 unique users, not 1)
- Replace NOW() PostgreSQL function with time.Now() parameter in marketplace
service for SQLite test compatibility
- Add missing AutoMigrate entries in marketplace_test.go
(ProductImage, ProductPreview, ProductLicense, ProductReview)
Results:
- Frontend TypeCheck: 617 errors -> 0 errors
- Frontend ESLint: 349 errors -> 0 errors
- Frontend Vitest: 196 failing tests -> 1 skipped (3396/3397 passing)
- Backend go vet: 1 error -> 0 errors
- Backend tests: 5 failing -> all 13 packages passing
- Rust: 150/150 tests passing (unchanged)
- Storybook audit: 0 errors across 1244 stories
Triage report: docs/TRIAGE_REPORT.md
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 14:48:07 +00:00
|
|
|
className="flex items-center justify-between p-3 bg-muted/50 rounded shadow-[0_0_8px_rgba(26,26,30,0.05)]"
|
2026-02-25 19:00:43 +00:00
|
|
|
>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="font-bold text-foreground">{a.title}</span>
|
|
|
|
|
<p className="text-sm text-muted-foreground truncate">{a.content}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="text-destructive hover:text-destructive"
|
|
|
|
|
onClick={() => handleDeleteAnnouncement(a.id)}
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-01-13 18:47:57 +00:00
|
|
|
</div>
|
|
|
|
|
</Card>
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|