From 91de7323831386fd154968b86c458dd150cb47da Mon Sep 17 00:00:00 2001 From: SharifIbrahimDev Date: Sat, 30 May 2026 17:52:13 +0100 Subject: [PATCH] feat: batch frontend widgets (#998, #999, #1000, #1001) --- .../cntr/AmenitiesList/AmenitiesList.test.tsx | 43 +++++++ frontend/cntr/AmenitiesList/AmenitiesList.tsx | 47 ++++++++ .../BookingStatusBadge.test.tsx | 57 +++++++++ .../BookingStatusBadge/BookingStatusBadge.tsx | 52 +++++++++ .../CheckInHistory/CheckInHistory.test.tsx | 80 +++++++++++++ .../cntr/CheckInHistory/CheckInHistory.tsx | 89 ++++++++++++++ .../OnboardingChecklist.test.tsx | 75 ++++++++++++ .../OnboardingChecklist.tsx | 110 ++++++++++++++++++ frontend/package-lock.json | 30 ----- 9 files changed, 553 insertions(+), 30 deletions(-) create mode 100644 frontend/cntr/AmenitiesList/AmenitiesList.test.tsx create mode 100644 frontend/cntr/AmenitiesList/AmenitiesList.tsx create mode 100644 frontend/cntr/BookingStatusBadge/BookingStatusBadge.test.tsx create mode 100644 frontend/cntr/BookingStatusBadge/BookingStatusBadge.tsx create mode 100644 frontend/cntr/CheckInHistory/CheckInHistory.test.tsx create mode 100644 frontend/cntr/CheckInHistory/CheckInHistory.tsx create mode 100644 frontend/cntr/OnboardingChecklist/OnboardingChecklist.test.tsx create mode 100644 frontend/cntr/OnboardingChecklist/OnboardingChecklist.tsx diff --git a/frontend/cntr/AmenitiesList/AmenitiesList.test.tsx b/frontend/cntr/AmenitiesList/AmenitiesList.test.tsx new file mode 100644 index 0000000..45b8b55 --- /dev/null +++ b/frontend/cntr/AmenitiesList/AmenitiesList.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { AmenitiesList } from './AmenitiesList'; + +describe('AmenitiesList', () => { + it('renders a list of amenities with capitalized labels', () => { + render(); + + expect(screen.getByText('Wifi')).toBeInTheDocument(); + expect(screen.getByText('Parking')).toBeInTheDocument(); + expect(screen.getByText('Air conditioning')).toBeInTheDocument(); + }); + + it('renders an unknown amenity and falls back to the Tag icon', () => { + const { container } = render(); + + expect(screen.getByText('Unknown amenity')).toBeInTheDocument(); + // Verify it renders the fallback Tag icon (lucide icons add class 'lucide-tag') + expect(container.querySelector('.lucide-tag')).toBeInTheDocument(); + }); + + it('handles empty or missing arrays gracefully', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('handles varied casing and whitespace in inputs', () => { + render(); + + expect(screen.getByText('Wifi')).toBeInTheDocument(); + expect(screen.getByText('Parking')).toBeInTheDocument(); + expect(screen.getByText('Coffee')).toBeInTheDocument(); + }); + + it('renders multiple known icons', () => { + const { container } = render(); + + expect(container.querySelector('.lucide-wifi')).toBeInTheDocument(); + expect(container.querySelector('.lucide-car')).toBeInTheDocument(); + expect(container.querySelector('.lucide-dumbbell')).toBeInTheDocument(); + }); +}); diff --git a/frontend/cntr/AmenitiesList/AmenitiesList.tsx b/frontend/cntr/AmenitiesList/AmenitiesList.tsx new file mode 100644 index 0000000..713abcc --- /dev/null +++ b/frontend/cntr/AmenitiesList/AmenitiesList.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { Wifi, Car, Printer, Coffee, Monitor, Lock, Dumbbell, Wind, Tag } from 'lucide-react'; +import { cn } from '../../lib/utils'; + +export interface AmenitiesListProps extends React.HTMLAttributes { + amenities: string[]; +} + +const iconMap: Record = { + wifi: Wifi, + parking: Car, + printing: Printer, + coffee: Coffee, + monitor: Monitor, + security: Lock, + gym: Dumbbell, + 'air conditioning': Wind, + ac: Wind, +}; + +function capitalizeFirstLetter(string: string) { + if (!string) return ''; + return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); +} + +export function AmenitiesList({ amenities, className, ...props }: AmenitiesListProps) { + if (!amenities || amenities.length === 0) return null; + + return ( +
+ {amenities.map((amenity, index) => { + const normalizedKey = amenity.trim().toLowerCase(); + const IconComponent = iconMap[normalizedKey] || Tag; + + return ( +
+ + {capitalizeFirstLetter(amenity.trim())} +
+ ); + })} +
+ ); +} diff --git a/frontend/cntr/BookingStatusBadge/BookingStatusBadge.test.tsx b/frontend/cntr/BookingStatusBadge/BookingStatusBadge.test.tsx new file mode 100644 index 0000000..cc8fbf2 --- /dev/null +++ b/frontend/cntr/BookingStatusBadge/BookingStatusBadge.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { BookingStatusBadge, BookingStatus } from './BookingStatusBadge'; + +describe('BookingStatusBadge', () => { + it('renders PENDING status correctly', () => { + render(); + const badge = screen.getByText('Pending'); + expect(badge).toBeInTheDocument(); + expect(badge.className).toContain('bg-yellow-100'); + expect(badge.className).toContain('text-yellow-800'); + }); + + it('renders CONFIRMED status correctly', () => { + render(); + const badge = screen.getByText('Confirmed'); + expect(badge).toBeInTheDocument(); + expect(badge.className).toContain('bg-green-100'); + expect(badge.className).toContain('text-green-800'); + }); + + it('renders CANCELLED status correctly', () => { + render(); + const badge = screen.getByText('Cancelled'); + expect(badge).toBeInTheDocument(); + expect(badge.className).toContain('bg-red-100'); + expect(badge.className).toContain('text-red-800'); + }); + + it('renders COMPLETED status correctly', () => { + render(); + const badge = screen.getByText('Completed'); + expect(badge).toBeInTheDocument(); + expect(badge.className).toContain('bg-blue-100'); + expect(badge.className).toContain('text-blue-800'); + }); + + it('renders NO_SHOW status correctly', () => { + render(); + const badge = screen.getByText('No Show'); + expect(badge).toBeInTheDocument(); + expect(badge.className).toContain('bg-zinc-100'); + expect(badge.className).toContain('text-zinc-800'); + }); + + it('allows passing custom classNames', () => { + render(); + const badge = screen.getByText('Confirmed'); + expect(badge.className).toContain('custom-test-class'); + }); + + it('returns null for an invalid status', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/frontend/cntr/BookingStatusBadge/BookingStatusBadge.tsx b/frontend/cntr/BookingStatusBadge/BookingStatusBadge.tsx new file mode 100644 index 0000000..c7e70f6 --- /dev/null +++ b/frontend/cntr/BookingStatusBadge/BookingStatusBadge.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { cn } from '../../lib/utils'; // Assuming standard shadcn/tailwind setup + +export type BookingStatus = 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED' | 'NO_SHOW'; + +export interface BookingStatusBadgeProps extends React.HTMLAttributes { + status: BookingStatus; +} + +const statusConfig: Record = { + PENDING: { + label: 'Pending', + className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-500', + }, + CONFIRMED: { + label: 'Confirmed', + className: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-500', + }, + CANCELLED: { + label: 'Cancelled', + className: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-500', + }, + COMPLETED: { + label: 'Completed', + className: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-500', + }, + NO_SHOW: { + label: 'No Show', + className: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-400', + }, +}; + +export function BookingStatusBadge({ status, className, ...props }: BookingStatusBadgeProps) { + const config = statusConfig[status]; + + if (!config) { + return null; // or fallback + } + + return ( + + {config.label} + + ); +} diff --git a/frontend/cntr/CheckInHistory/CheckInHistory.test.tsx b/frontend/cntr/CheckInHistory/CheckInHistory.test.tsx new file mode 100644 index 0000000..fcf9f0e --- /dev/null +++ b/frontend/cntr/CheckInHistory/CheckInHistory.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import { CheckInHistory, CheckInLog } from './CheckInHistory'; + +describe('CheckInHistory', () => { + const mockLogs: CheckInLog[] = [ + { + workspaceName: 'Hub A', + checkInTime: '2026-05-30T10:00:00Z', + checkOutTime: '2026-05-30T12:15:00Z', + durationMinutes: 135, + status: 'COMPLETED', + }, + { + workspaceName: 'Hub B', + checkInTime: '2026-05-31T09:00:00Z', + checkOutTime: null, + durationMinutes: 45, + status: 'CONFIRMED', + } + ]; + + it('renders a table with all check-in logs', () => { + render(); + + // Check columns + expect(screen.getByText('Workspace')).toBeInTheDocument(); + expect(screen.getByText('Check-In')).toBeInTheDocument(); + + // Check data + expect(screen.getByText('Hub A')).toBeInTheDocument(); + expect(screen.getByText('Hub B')).toBeInTheDocument(); + }); + + it('formats duration correctly as Xh Ym', () => { + render(); + + // 135 minutes = 2h 15m + expect(screen.getByText('2h 15m')).toBeInTheDocument(); + + // 45 minutes = 45m + expect(screen.getByText('45m')).toBeInTheDocument(); + }); + + it('renders "Active" in green when checkOutTime is null', () => { + render(); + + const activeCell = screen.getByText('Active'); + expect(activeCell).toBeInTheDocument(); + expect(activeCell.className).toContain('text-green-600'); + }); + + it('renders the BookingStatusBadge for the status column', () => { + render(); + + // Statuses passed to BookingStatusBadge + expect(screen.getByText('Completed')).toBeInTheDocument(); + expect(screen.getByText('Confirmed')).toBeInTheDocument(); + }); + + it('renders an empty state message when logs are empty', () => { + render(); + + expect(screen.getByText('No check-in history available.')).toBeInTheDocument(); + expect(screen.queryByText('Workspace')).not.toBeInTheDocument(); + }); + + it('formats zero minutes properly', () => { + const zeroLog: CheckInLog[] = [{ + workspaceName: 'Hub Zero', + checkInTime: '2026-05-30T10:00:00Z', + checkOutTime: '2026-05-30T10:00:00Z', + durationMinutes: 0, + status: 'COMPLETED', + }]; + render(); + expect(screen.getByText('0m')).toBeInTheDocument(); + }); +}); diff --git a/frontend/cntr/CheckInHistory/CheckInHistory.tsx b/frontend/cntr/CheckInHistory/CheckInHistory.tsx new file mode 100644 index 0000000..caab2dc --- /dev/null +++ b/frontend/cntr/CheckInHistory/CheckInHistory.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { cn } from '../../lib/utils'; +import { BookingStatusBadge, BookingStatus } from '../BookingStatusBadge/BookingStatusBadge'; + +export interface CheckInLog { + workspaceName: string; + checkInTime: string; + checkOutTime: string | null; + durationMinutes: number; + status: string; +} + +export interface CheckInHistoryProps extends React.HTMLAttributes { + logs: CheckInLog[]; +} + +function formatDuration(minutes: number): string { + if (minutes < 0) return '0m'; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h > 0) return `${h}h ${m}m`; + return `${m}m`; +} + +function formatDateString(dateStr: string) { + try { + const d = new Date(dateStr); + return d.toLocaleString(undefined, { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + } catch { + return dateStr; + } +} + +export function CheckInHistory({ logs, className, ...props }: CheckInHistoryProps) { + if (!logs || logs.length === 0) { + return ( +
+

No check-in history available.

+
+ ); + } + + return ( +
+ + + + + + + + + + + + {logs.map((log, i) => ( + + + + + + + + ))} + +
WorkspaceCheck-InCheck-OutDurationStatus
+ {log.workspaceName} + + {formatDateString(log.checkInTime)} + + {log.checkOutTime === null ? ( + Active + ) : ( + formatDateString(log.checkOutTime) + )} + + {formatDuration(log.durationMinutes)} + + +
+
+ ); +} diff --git a/frontend/cntr/OnboardingChecklist/OnboardingChecklist.test.tsx b/frontend/cntr/OnboardingChecklist/OnboardingChecklist.test.tsx new file mode 100644 index 0000000..97d43e6 --- /dev/null +++ b/frontend/cntr/OnboardingChecklist/OnboardingChecklist.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { OnboardingChecklist, OnboardingStep } from './OnboardingChecklist'; + +describe('OnboardingChecklist', () => { + const defaultSteps: OnboardingStep[] = [ + { key: 'step-1', label: 'Create profile', description: 'Add your avatar and bio', isComplete: true }, + { key: 'step-2', label: 'Connect wallet', description: 'Link your Web3 wallet', isComplete: false }, + { key: 'step-3', label: 'Join community', description: 'Say hi in the forum', isComplete: false }, + ]; + + it('renders the correct number of steps and progress text', () => { + render(); + + expect(screen.getByText('Getting Started')).toBeInTheDocument(); + expect(screen.getByText('1 of 3 completed')).toBeInTheDocument(); + + expect(screen.getByText('Create profile')).toBeInTheDocument(); + expect(screen.getByText('Connect wallet')).toBeInTheDocument(); + expect(screen.getByText('Join community')).toBeInTheDocument(); + }); + + it('calculates the progress bar width correctly', () => { + const { container } = render(); + const progressBar = container.querySelector('[role="progressbar"]'); + // 1 out of 3 is 33% + expect(progressBar).toHaveStyle('width: 33%'); + }); + + it('applies strikethrough styling to completed steps', () => { + render(); + + const completedLabel = screen.getByText('Create profile'); + expect(completedLabel.className).toContain('line-through'); + + const incompleteLabel = screen.getByText('Connect wallet'); + expect(incompleteLabel.className).not.toContain('line-through'); + }); + + it('calls onStepClick when an incomplete step is clicked', () => { + const onStepClick = vi.fn(); + render(); + + const incompleteStep = screen.getByText('Connect wallet'); + fireEvent.click(incompleteStep); + + expect(onStepClick).toHaveBeenCalledTimes(1); + expect(onStepClick).toHaveBeenCalledWith('step-2'); + }); + + it('does not call onStepClick when a completed step is clicked', () => { + const onStepClick = vi.fn(); + render(); + + const completedStep = screen.getByText('Create profile'); + fireEvent.click(completedStep); + + expect(onStepClick).not.toHaveBeenCalled(); + }); + + it('renders the completion state when all steps are complete', () => { + const completedSteps = defaultSteps.map(step => ({ ...step, isComplete: true })); + render(); + + expect(screen.getByText('Onboarding complete! 🎉')).toBeInTheDocument(); + expect(screen.queryByText('Getting Started')).not.toBeInTheDocument(); + expect(screen.queryByText('Create profile')).not.toBeInTheDocument(); + }); + + it('renders nothing if steps array is empty', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/frontend/cntr/OnboardingChecklist/OnboardingChecklist.tsx b/frontend/cntr/OnboardingChecklist/OnboardingChecklist.tsx new file mode 100644 index 0000000..308a1a1 --- /dev/null +++ b/frontend/cntr/OnboardingChecklist/OnboardingChecklist.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { CheckCircle, Circle } from 'lucide-react'; +import { cn } from '../../lib/utils'; // Assuming standard shadcn/tailwind setup + +export interface OnboardingStep { + key: string; + label: string; + isComplete: boolean; + description: string; +} + +export interface OnboardingChecklistProps { + steps: OnboardingStep[]; + onStepClick?: (key: string) => void; + className?: string; +} + +export function OnboardingChecklist({ steps, onStepClick, className }: OnboardingChecklistProps) { + if (!steps || steps.length === 0) return null; + + const totalCount = steps.length; + const completedCount = steps.filter((step) => step.isComplete).length; + const progressPercentage = Math.round((completedCount / totalCount) * 100); + + if (completedCount === totalCount) { + return ( +
+

+ Onboarding complete! 🎉 +

+

+ You're all set to get started. +

+
+ ); + } + + return ( +
+
+
+

Getting Started

+ + {completedCount} of {totalCount} completed + +
+
+
+
+
+ +
+ {steps.map((step) => { + const isComplete = step.isComplete; + return ( +
{ + if (!isComplete && onStepClick) { + onStepClick(step.key); + } + }} + role={!isComplete && onStepClick ? "button" : "listitem"} + tabIndex={!isComplete && onStepClick ? 0 : undefined} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (!isComplete && onStepClick) { + onStepClick(step.key); + } + } + }} + > +
+ {isComplete ? ( + + ) : ( + + )} +
+
+

+ {step.label} +

+

+ {step.description} +

+
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 29bfa74..94d2eaa 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1531,9 +1531,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1551,9 +1548,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1571,9 +1565,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1591,9 +1582,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1611,9 +1599,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1631,9 +1616,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8280,9 +8262,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8304,9 +8283,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8328,9 +8304,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8352,9 +8325,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [