Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
141 changes: 141 additions & 0 deletions e2e/orglist.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,39 @@ import { getOrgsByType, OrgType } from '@acm-uiuc/js-shared';
const sigs = getOrgsByType(OrgType.SIG);
const committees = getOrgsByType(OrgType.COMMITTEE);

const mockOrgs = [
{
id: 'S01',
name: 'TestSIG',
type: 'sig',
description: 'A test SIG',
email: 'test@example.com',
website: 'https://example.com',
leads: [
{
username: 'loba1',
name: 'Nikolai Lobachevsky',
title: 'Lead',
nonVotingMember: false,
},
{
username: 'euler2',
name: 'Leonard Euler',
title: 'Lead',
nonVotingMember: false,
},
{ username: 'devktest3', title: 'Treasurer', nonVotingMember: true },
],
},
{
id: 'S02',
name: 'NoLeadsSIG',
type: 'sig',
description: 'A SIG with no leads',
leads: [],
},
];

test.describe('Org list pre-compilation', () => {
test('SSG cards visible while API is pending', async ({ page }) => {
// Hold the API request open so it never resolves
Expand Down Expand Up @@ -150,3 +183,111 @@ test.describe('Org list pre-compilation', () => {
}
});
});

test.describe('Organization card flip', () => {
test('clicking a card flips it to show leads', async ({ page }) => {
await page.goto('/');

const card = page.locator('.flip-card').first();

// Back face should be hidden before flip
await expect(card.locator('.flip-card-back')).toBeHidden();

// Click to flip
await card.click();
await page.waitForTimeout(700);

// Back face should now be visible with lead data
const backFace = card.locator('.flip-card-back');
await expect(backFace).toBeVisible();
await expect(backFace.getByText('Leadership')).toBeVisible();
});

test('clicking the back flips the card back to front', async ({ page }) => {
await page.goto('/');

const card = page.locator('.flip-card').first();

// Flip to back
await card.click();
await page.waitForTimeout(700);
await expect(card.locator('.flip-card-back')).toBeVisible();

// Flip back to front
await card.click();
await page.waitForTimeout(700);

await expect(card.locator('.flip-card-front h3')).toBeVisible();
await expect(card.locator('.flip-card-back')).toBeHidden();
});

test('clicking social links does not flip the card', async ({ page }) => {
await page.goto('/');

const card = page.locator('.flip-card').first();
// Click a link inside the card (stopPropagation should prevent flip)
const link = card.locator('.flip-card-front a').first();
await expect(link).toBeVisible();
await link.click();

// Card should NOT have flipped
await expect(card.locator('.flip-card-back')).toBeHidden();
});

test('flip card shows tap to flip back hint', async ({ page }) => {
await page.goto('/');

const card = page.locator('.flip-card').first();
await card.click();
await page.waitForTimeout(700);

await expect(
card.locator('.flip-card-back').getByText('Tap to flip back')
).toBeVisible();
});

test('leads without a name fall back to username', async ({ page }) => {
await page.route('**/api/v1/organizations', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockOrgs),
})
);
await page.goto('/');
await expect(
page.locator('[data-testid="org-grid"]').getByText('TestSIG')
).toBeVisible();

const card = page.locator('.flip-card').first();
await card.click();
await page.waitForTimeout(700);

const backFace = card.locator('.flip-card-back');
// devktest3 has no name, should show username
await expect(backFace.getByText('devktest3')).toBeVisible();
await expect(backFace.getByText('Treasurer')).toBeVisible();
});

test('card with no leads shows fallback message', async ({ page }) => {
await page.route('**/api/v1/organizations', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockOrgs),
})
);
await page.goto('/');
await expect(
page.locator('[data-testid="org-grid"]').getByText('TestSIG')
).toBeVisible();

const card = page.locator('.flip-card').nth(1);
await card.click();
await page.waitForTimeout(700);

await expect(
card.locator('.flip-card-back').getByText('No leads listed')
).toBeVisible();
});
});
5 changes: 5 additions & 0 deletions src/components/OrgTypeTabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ const OrgTypeTabBar = ({ initialCounts }: Props) => {
<p className="mt-3 text-sm text-gray-500 max-w-2xl text-center">
{activeTab.description}
</p>
<p className="mt-1 text-xs text-gray-400">
<span className="sm:hidden">Tap</span>
<span className="hidden sm:inline">Click</span>
{' on a card to view leadership'}
</p>
</div>
);
};
Expand Down
170 changes: 131 additions & 39 deletions src/components/OrganizationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import {
SiInstagram,
SiSlack,
} from '@icons-pack/react-simple-icons';
import { Globe, Link, Mail } from 'lucide-react';
import { Globe, Link, Mail, RotateCcw } from 'lucide-react';
import type { JSX } from 'preact/jsx-runtime';
import { useState } from 'preact/hooks';

import type { Organization } from '../stores/organization';
import { toTitleCase } from '../util';
Expand All @@ -25,6 +26,9 @@ const OrganizationCard = ({
imageData?: ImageData;
index?: number;
}) => {
const [isFlipped, setIsFlipped] = useState(false);
const [showBack, setShowBack] = useState(false);

// Use optimized image if available, fallback to public folder
const logoUrl = imageData?.src || `/images/logos/${organization.id}.png`;
const commonIconProps = { size: 16 };
Expand All @@ -46,53 +50,141 @@ const OrganizationCard = ({
const allLinks = [
{ type: 'WEBSITE', url: organization.website },
...(organization.links || []),
...(organization.email
? [{ type: 'EMAIL', url: `mailto:${organization.email}` }]
: []),
].filter((x) => Boolean(x) && x.type && x.url);

const leads = organization.leads || [];

return (
<div
className={`group relative flex h-full flex-col rounded-xl border border-gray-200 border-t-2 border-t-transparent bg-white p-6 text-center shadow-md transition-all duration-200 hover:border-navy-300 hover:shadow-xl hover:-translate-y-0.5 ${topBorderColors[organization.type] || ''} animate-fade-up`}
className={`flip-card animate-fade-up ${topBorderColors[organization.type] || ''}`}
style={{ animationDelay: `${index * 50}ms` }}
onClick={() => {
const next = !isFlipped;
if (next) {
setShowBack(true);
} else if (
window.matchMedia('(prefers-reduced-motion: reduce)').matches
) {
setShowBack(false);
}
setIsFlipped(next);
}}
>
<div className="mb-4">
<img
src={logoUrl}
alt={`${organization.name} logo`}
width={imageData?.width || 96}
height={imageData?.height || 96}
loading="lazy"
className="h-24 w-24 rounded-md object-contain mx-auto transition-transform duration-200 group-hover:scale-110"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</div>
<div
className="flip-card-inner"
style={{ transform: isFlipped ? 'rotateY(180deg)' : 'none' }}
onTransitionEnd={(e) => {
if (
e.target === e.currentTarget &&
(e as TransitionEvent).propertyName === 'transform' &&
!isFlipped
) {
setShowBack(false);
}
}}
>
{/* Front face */}
<div
className={`flip-card-front group relative flex h-full flex-col rounded-xl border border-gray-200 border-t-2 border-t-transparent bg-white p-6 text-center shadow-md transition-colors duration-200 hover:border-navy-300 hover:shadow-xl ${topBorderColors[organization.type] || ''}`}
aria-hidden={isFlipped ? true : undefined}
>
<div className="mb-4">
<img
src={logoUrl}
alt={`${organization.name} logo`}
width={imageData?.width || 96}
height={imageData?.height || 96}
loading="lazy"
className="h-24 w-24 rounded-md object-contain mx-auto transition-transform duration-200 group-hover:scale-110"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</div>

<div className="flex-1 flex flex-col">
<h3 className="text-xl font-bold text-navy-900 mb-2 group-hover:text-navy-700">
{organization.name}
</h3>
<p className="text-md text-gray-600 line-clamp-8 lg:line-clamp-5 flex-1">
{organization.description}
</p>
</div>
<div className="flex-1 flex flex-col">
<h3 className="text-xl font-bold text-navy-900 mb-2 group-hover:text-navy-700">
{organization.name}
</h3>
<p className="text-md text-gray-600 line-clamp-8 lg:line-clamp-5 flex-1">
{organization.description}
</p>
</div>

{allLinks.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex items-center justify-center gap-2">
{allLinks.map((link) => (
<a
key={`${organization.id}-${link.type}`}
href={link.url}
target="_blank"
rel="noopener noreferrer"
title={toTitleCase(link.type)}
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-navy-700"
onClick={(e) => e.stopPropagation()}
>
{linkIconPaths[link.type] || linkIconPaths.OTHER}
</a>
))}
</div>
</div>
)}
<span className="absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-gray-400 animate-flip-hint">
<span className="text-[10px] font-medium sm:hidden">Tap</span>
<span className="text-[10px] font-medium hidden sm:inline">
Click
</span>
<RotateCcw size={12} />
</span>
</div>

{allLinks.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex items-center justify-center gap-2">
{allLinks.map((link) => (
<a
key={`${organization.id}-${link.type}`}
href={link.url}
target="_blank"
rel="noopener noreferrer"
title={toTitleCase(link.type)}
className="rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-navy-700"
>
{linkIconPaths[link.type] || linkIconPaths.OTHER}
</a>
))}
{/* Back face */}
<div
className="flip-card-back relative rounded-xl border border-gray-200 border-t-2 border-t-transparent bg-white p-6 text-center shadow-md"
style={showBack ? { visibility: 'visible' } : undefined}
aria-hidden={isFlipped ? undefined : true}
>
<h3 className="text-lg font-semibold text-gray-500 uppercase tracking-wide mb-4">
Leadership
</h3>

<div className="flex-1 overflow-y-auto mb-6">
{leads.length > 0 ? (
<ul className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-left">
{leads.map((lead) => (
<li
key={lead.username}
className="rounded-lg bg-gray-50 px-3 py-2"
>
<p className="text-sm font-medium text-navy-900">
{lead.name || lead.username}
</p>
{lead.title && (
<p className="text-xs text-gray-500">{lead.title}</p>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-gray-400 italic mt-4">
No leads listed
</p>
)}
</div>

<span className="absolute bottom-3 right-3 flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-gray-400">
<span className="text-[10px] font-medium sm:hidden">Tap</span>
<span className="text-[10px] font-medium hidden sm:inline">
Click
</span>
<RotateCcw size={12} />
</span>
</div>
)}
</div>
</div>
);
};
Expand Down
Loading
Loading