Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
target: CEUS/src/components/EventCard.tsx
total_score: 32
p0_count: 0
p1_count: 0
timestamp: 2026-05-29T05-48-45Z
slug: ceus-src-components-eventcard-tsx
---
# Design Critique: `CEUS/src/components/EventCard.tsx` (post-polish)

## Design Health Score

| # | Heuristic | Score | Key Issue |
|---|-----------|-------|-----------|
| 1 | Visibility of System Status | 3 | Smooth hover/transition feedback; no loading skeleton |
| 2 | Match System / Real World | 3 | `<time>` semantic, "View on Facebook" honest; categories self-explanatory |
| 3 | User Control and Freedom | 3 | Focus-visible rings on both variants; `motion-safe` guards on scales |
| 4 | Consistency and Standards | 4 | Brand Navy primary, Hairline border, Ink Body description — DESIGN.md aligned |
| 5 | Error Prevention | 3 | Defensive link handling; graceful degrade when facebookEventLink missing |
| 6 | Recognition Rather Than Recall | 4 | Photo + category chip + date + title is rich recognition |
| 7 | Flexibility and Efficiency | 3 | Keyboard works, focus visible; no bookmark/save affordance |
| 8 | Aesthetic and Minimalist Design | 3 | Hover overlay simplified to description-only; date visible without hover |
| 9 | Error Recovery | 3 | "Details coming soon" graceful state for missing CTA |
| 10 | Help and Documentation | 3 | n/a at component scale |
| **Total** | | **32/40** | **Good — address weak areas, solid foundation** |

## Anti-Patterns Verdict

No AI tells. Hover treatment restrained (capped at 1.02/1.10 scales, eased out, single-property transitions). Detector reported 0 findings.

## Priority Issues

### [P2] Long titles handled inconsistently between variants
Home variant: `truncate` (one line, hard cut). Default: `line-clamp-2`. Different physical truth for same data. Fix: bring home variant to `line-clamp-2 min-h-[3rem]` so carousel stays balanced. Command: `/impeccable layout`.

### [P2] Card clickability is asymmetric between variants
Home: whole card is link. Default: only bottom CTA. Same content type, two different interaction models. Touch users tap card image in default variant and nothing happens. Recommendation: make whole default-variant card clickable. Command: `/impeccable shape event-card-clickability`.

### [P3] Date is absolute, not relative
"Happening Soon" section promises relative-temporal framing; cards show only absolute "9 May 2026". Fix: hybrid "Friday 9 May · in 4 days" using date-fns `formatDistance`. Command: `/impeccable clarify`.

### [P3] No loading skeleton aligned to `src/components/skeletons/`
A skeletons dir exists; no EventCardSkeleton. Page renders flat during data fetch. Fix: add EventCardSkeleton.tsx mirroring card structure. Command: `/impeccable harden`.

### [P3] Hover gradient overlay on default variant adds little value
`bg-gradient-to-t from-black/40` fades in on hover with no copy overlay — vestige from a pattern with text. Fix: remove or replace with subtle Brand Navy tint. Command: `/impeccable distill`.

## Persona Red Flags

**Riley:** Long titles clip with no signal in home variant. Records with missing description show empty space; no "No description" hint.

**Sam:** Focus rings ✓, semantics ✓, motion-safe ✓. Minor: CTA aria-label duplicates link's accessible name.

**Casey:** Home variant whole-card tappable (good). Default variant only CTA tappable — tap on card image does nothing.

## Minor Observations

- `aria-label` on CTA duplicates link name; could simplify to "View on Facebook".
- `<article>` landmark may clutter screen-reader landmark list in a 12+ card grid.
- Category badge could use `aria-label="{category} event"` for context.
- Card has both border AND shadow-lg at rest — belt-and-suspenders, but matches DESIGN.md spec.
- `FALLBACK_IMAGE_URLS.event` not verified visually.

## Questions to Consider

- Should the whole default-variant card be clickable, matching home and the Eventbrite/Lu.ma/Meetup convention?
- Is the home overlay-on-hover earning its place when it only shows description?
- What's the degrade story when other fields (description, image, category) are missing — the polish handled link, the rest deserves the same care?
336 changes: 236 additions & 100 deletions CEUS/src/app/events/EventsClient.tsx

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion CEUS/src/app/events/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { Metadata } from 'next';

export const metadata: Metadata = {
title: 'Events',
description: 'Join us for exciting events, workshops, and social gatherings organized by CEUS.',
description:
'Every event CEUS runs at UNSW: industry nights, social lawn days, study sessions, and the annual Engineering Ball. Browse upcoming and past events.',
};

// Revalidate every hour
Expand Down
180 changes: 117 additions & 63 deletions CEUS/src/components/EventCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,113 +3,167 @@ import React from 'react';
import { Event } from '../types';
import { FaCalendarAlt, FaExternalLinkAlt } from 'react-icons/fa';
import { FALLBACK_IMAGE_URLS } from '../lib/storagePublicUrls';
import { formatEventDate, cn } from '../lib/utils';
import { cn, formatRelativeEventDate } from '../lib/utils';
import OptimizedImage from './OptimizedImage';

interface EventCardProps {
event: Event;
variant?: 'default' | 'minimal' | 'home';
variant?: 'default' | 'home';
}

const CATEGORY_COLORS = {
'Flagship': 'bg-purple-100 text-purple-800',
'Careers': 'bg-blue-100 text-blue-800',
'Social': 'bg-green-100 text-green-800',
'Academic': 'bg-orange-100 text-orange-800',
'Welfare': 'bg-pink-100 text-pink-800',
'Recruitment': 'bg-indigo-100 text-indigo-800',
'Collaboration': 'bg-teal-100 text-teal-800',
'Other': 'bg-gray-100 text-gray-800'
const CATEGORY_COLORS: Record<Event['category'], string> = {
Flagship: 'bg-purple-100 text-purple-800',
Careers: 'bg-blue-100 text-blue-800',
Social: 'bg-green-100 text-green-800',
Academic: 'bg-orange-100 text-orange-800',
Welfare: 'bg-pink-100 text-pink-800',
Recruitment: 'bg-indigo-100 text-indigo-800',
Collaboration: 'bg-teal-100 text-teal-800',
Other: 'bg-gray-100 text-gray-800',
};

const EventCard: React.FC<EventCardProps> = ({ event, variant = 'default' }) => {
const formattedDate = formatEventDate(event.date);
const relativeDate = formatRelativeEventDate(event.date);
const hasLink = Boolean(event.facebookEventLink);
const hasDescription = Boolean(event.description?.trim());

if (variant === 'home') {
const Wrapper: React.ElementType = hasLink ? 'a' : 'div';
const wrapperProps = hasLink
? {
href: event.facebookEventLink,
target: '_blank',
rel: 'noopener noreferrer',
'aria-label': `${event.title}, ${relativeDate}. Opens on Facebook.`,
}
: {};

return (
<div className="group px-4 block focus:outline-none rounded-md cursor-pointer">
<a
href={event.facebookEventLink || '#'}
target="_blank"
rel="noopener noreferrer"
<div className="group px-4">
<Wrapper
{...wrapperProps}
className={cn(
'block rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
hasLink && 'cursor-pointer'
)}
>
<div className="relative mx-auto h-[260px] w-full overflow-hidden rounded-lg shadow-md group-hover:shadow-xl transition-shadow duration-300">
<div className="relative mx-auto h-[260px] w-full overflow-hidden rounded-lg shadow-md transition-shadow duration-300 group-hover:shadow-xl">
<OptimizedImage
src={event.imageUrl || FALLBACK_IMAGE_URLS.event}
alt={event.title}
fill
className="group-hover:scale-110"
className="transition-transform duration-500 ease-out motion-safe:group-hover:scale-105"
fallbackSrc={FALLBACK_IMAGE_URLS.event}
/>
<div className="absolute inset-0 bg-black/70 flex flex-col justify-center items-center text-white p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<h3 className="text-xl font-bold text-center mb-2">{event.title}</h3>
<p className="text-sm text-center mb-2 opacity-90 line-clamp-3">{event.description}</p>
<p className="text-xs text-center opacity-75 font-medium">
{formattedDate}
</p>
</div>
{hasDescription && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center bg-black/70 p-5 text-white opacity-0 transition-opacity duration-300 group-hover:opacity-100">
<p className="line-clamp-4 text-center text-sm leading-relaxed opacity-95">
{event.description}
</p>
</div>
)}
</div>
<h3 className="text-2xl font-semibold text-center mt-5 group-hover:text-blue-600 transition-all duration-300 group-hover:opacity-100 truncate">
<h3 className="mt-5 line-clamp-2 min-h-[3.25rem] text-center text-xl font-semibold leading-tight text-gray-900 transition-colors duration-300 group-hover:text-blue-600">
{event.title}
</h3>
</a>
<time
dateTime={event.date}
className="mt-1 block text-center text-sm font-medium text-gray-600"
>
{relativeDate}
</time>
</Wrapper>
</div>
);
}

return (
<div className="group relative bg-white rounded-xl shadow-lg overflow-hidden transform transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl border border-gray-100 flex flex-col h-full">
{/* Image Container */}
<div className="relative w-full h-56 overflow-hidden">
const cardClasses =
'group relative flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg transition-[transform,box-shadow] duration-300 ease-out hover:shadow-2xl motion-safe:hover:scale-[1.02] focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2';

const cardBody = (
<>
<div className="relative h-56 w-full overflow-hidden">
<OptimizedImage
src={event.imageUrl || FALLBACK_IMAGE_URLS.event}
alt={event.title}
fill
className="group-hover:scale-110"
className="transition-transform duration-500 ease-out motion-safe:group-hover:scale-110"
fallbackSrc={FALLBACK_IMAGE_URLS.event}
/>

{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>

{/* Category badge */}
<div className="absolute top-4 left-4">
<span className={cn(
"inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold shadow-sm",
CATEGORY_COLORS[event.category] || CATEGORY_COLORS['Other']
)}>

<div className="absolute left-4 top-4">
<span
aria-label={`${event.category} event`}
className={cn(
'inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold shadow-sm',
CATEGORY_COLORS[event.category] || CATEGORY_COLORS.Other
)}
>
{event.category}
</span>
</div>

{hasLink && (
<span
aria-hidden="true"
className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-full bg-white/95 text-gray-700 opacity-0 shadow-sm transition-opacity duration-300 group-hover:opacity-100"
>
<FaExternalLinkAlt className="h-3 w-3" />
</span>
)}
</div>

{/* Content */}
<div className="p-6 flex flex-col flex-grow">
<div className="flex items-center text-sm text-blue-600 mb-3">
<FaCalendarAlt className="w-4 h-4 mr-2" />
<span className="font-semibold">{formattedDate}</span>
</div>
<div className="flex flex-grow flex-col p-6">
<time
dateTime={event.date}
className="mb-3 flex items-center text-sm text-gray-700"
>
<FaCalendarAlt aria-hidden="true" className="mr-2 h-4 w-4 text-blue-600" />
<span className="font-medium">{relativeDate}</span>
</time>

<h3 className="text-xl font-bold text-gray-900 mb-3 line-clamp-2 group-hover:text-blue-600 transition-colors duration-300">
<h3 className="mb-3 line-clamp-2 text-xl font-bold leading-snug text-gray-900 transition-colors duration-300 group-hover:text-blue-600">
{event.title}
</h3>

<p className="text-gray-600 text-sm leading-relaxed line-clamp-3 mb-6 flex-grow">
{event.description}
</p>
{hasDescription && (
<p className="mb-6 line-clamp-3 flex-grow text-sm leading-relaxed text-gray-700">
{event.description}
</p>
)}

<a
href={event.facebookEventLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center w-full px-5 py-3 bg-blue-600 text-white text-sm font-bold rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-all duration-300"
>
<span>View Event</span>
<FaExternalLinkAlt className="w-3 h-3 ml-2" />
</a>
{hasLink ? (
<span
aria-hidden="true"
className="mt-auto inline-flex w-full items-center justify-center rounded-lg bg-[#1B397E] px-6 py-3 text-sm font-semibold text-white transition-colors duration-200 ease-out group-hover:bg-blue-700"
>
View on Facebook
<FaExternalLinkAlt className="ml-2 h-3 w-3" aria-hidden="true" />
</span>
) : (
<span className="mt-auto inline-flex w-full items-center justify-center rounded-lg border border-gray-200 bg-gray-50 px-6 py-3 text-sm font-semibold text-gray-500">
Details coming soon
</span>
)}
</div>
</div>
</>
);

if (hasLink) {
return (
<a
href={event.facebookEventLink}
target="_blank"
rel="noopener noreferrer"
aria-label={`${event.title}, ${relativeDate}. Opens on Facebook.`}
className={cn(cardClasses, 'cursor-pointer')}
>
{cardBody}
</a>
);
}

return <article className={cardClasses}>{cardBody}</article>;
};

export default React.memo(EventCard);
15 changes: 9 additions & 6 deletions CEUS/src/components/EventFilterButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ interface EventFilterButtonProps {
const EventFilterButton: React.FC<EventFilterButtonProps> = ({ label, isActive, onClick }) => {
return (
<button
type="button"
onClick={onClick}
aria-pressed={isActive}
className={`
px-5 py-2.5 rounded-lg text-sm font-semibold transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-400
transform hover:scale-105 active:scale-95
px-5 py-2.5 rounded-lg text-sm font-semibold
transition-colors duration-200 ease-out
focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500
motion-safe:transition-transform motion-safe:active:scale-95
${
isActive
? 'bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg shadow-blue-500/25'
: 'bg-white text-gray-700 border border-gray-200 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800 hover:shadow-md'
? 'bg-[#1B397E] text-white hover:bg-blue-700'
: 'bg-white text-[#1B397E] border border-gray-200 hover:bg-[#1B397E]/5 hover:border-gray-300'
}
`}
>
Expand All @@ -27,4 +30,4 @@ const EventFilterButton: React.FC<EventFilterButtonProps> = ({ label, isActive,
);
};

export default EventFilterButton;
export default EventFilterButton;
32 changes: 22 additions & 10 deletions CEUS/src/components/skeletons/Skeletons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,33 @@ export const Skeleton: React.FC<SkeletonProps> = ({ className }) => (
);

export const EventSkeleton: React.FC = () => (
<div className="bg-white rounded-xl shadow-lg overflow-hidden border border-gray-100">
<Skeleton className="h-56 w-full" />
<div className="p-6">
<Skeleton className="h-4 w-1/3 mb-3" />
<Skeleton className="h-6 w-3/4 mb-3" />
<div className="space-y-2 mb-4">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
<div className="relative h-56 w-full">
<Skeleton className="h-full w-full rounded-none" />
<Skeleton className="absolute left-4 top-4 h-6 w-20 rounded-full bg-gray-300" />
</div>
<div className="flex flex-grow flex-col p-6">
<Skeleton className="mb-3 h-4 w-2/5" />
<Skeleton className="mb-2 h-5 w-3/4" />
<Skeleton className="mb-4 h-5 w-1/2" />
<div className="mb-6 flex-grow space-y-2">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-2/3" />
</div>
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-11 w-full rounded-lg" />
</div>
</div>
);

export const EventHomeSkeleton: React.FC = () => (
<div className="px-4">
<Skeleton className="h-[260px] w-full rounded-lg" />
<Skeleton className="mx-auto mt-5 h-6 w-3/4" />
<Skeleton className="mx-auto mt-2 h-4 w-1/2" />
</div>
);

export const TeamSkeleton: React.FC = () => (
<div className="bg-white rounded-xl shadow-md p-6 flex flex-col items-center">
<Skeleton className="w-32 h-32 rounded-full mb-4" />
Expand Down
5 changes: 5 additions & 0 deletions CEUS/src/lib/links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Shared external links used by multiple pages.
// Hoisted here so a URL change is a one-line edit rather than a multi-file hunt.

export const CALENDAR_SUBSCRIBE_URL =
'https://calendar.google.com/calendar/u/0?cid=ZWIwYjViOTgxYjJmMGE5NDM0NzczNjMzODU1MGRkZGFiMTYwMmQ1NDE2MTI5MjQ5ZmQzNzczZjQzNjQxYjlhN0Bncm91cC5jYWxlbmRhci5nb29nbGUuY29t';
Loading