Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
131 changes: 131 additions & 0 deletions e2e/orgcard-flip.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { test, expect } from '@playwright/test';

const mockOrgs = [
{
id: 'TEST01',
name: 'TestSIG',
type: 'sig',
description: 'A test SIG',
email: 'test@example.com',
website: 'https://example.com',
leads: [
{
username: 'alice1',
name: 'Alice Smith',
title: 'Chair',
nonVotingMember: false,
},
{
username: 'bob2',
name: 'Bob Jones',
title: 'Vice Chair',
nonVotingMember: false,
},
{ username: 'charlie3', title: 'Treasurer', nonVotingMember: true },
],
},
{
id: 'TEST02',
name: 'NoLeadsSIG',
type: 'sig',
description: 'A SIG with no leads',
leads: [],
},
];

test.describe('Organization card flip', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/v1/organizations', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockOrgs),
})
);
await page.goto('/');
// Wait for mock data to replace SSG data (same pattern as orglist.spec.ts)
await expect(
page.locator('[data-testid="org-grid"]').getByText('TestSIG')
).toBeVisible();
expect(
await page.locator('[data-testid="org-grid"] h3:visible').count()
).toBe(2);
});

test('clicking a card flips it to show leads', async ({ page }) => {
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();
await expect(backFace.getByText('Alice Smith')).toBeVisible();
await expect(backFace.getByText('Chair', { exact: true })).toBeVisible();
await expect(backFace.getByText('Bob Jones')).toBeVisible();
});

test('clicking the back flips the card back to front', async ({ page }) => {
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('leads without a name fall back to username', async ({ page }) => {
const card = page.locator('.flip-card').first();
await card.click();
await page.waitForTimeout(700);

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

test('card with no leads shows fallback message', async ({ page }) => {
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();
});

test('clicking social links does not flip the card', async ({ page }) => {
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 }) => {
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();
});
});
155 changes: 117 additions & 38 deletions src/components/OrganizationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@icons-pack/react-simple-icons';
import { Globe, Link, Mail } 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,128 @@ 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>
)}
</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 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">
{leads.length > 0 ? (
<ul className="space-y-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>

<p className="mt-4 text-xs text-gray-400">Tap to flip back</p>
</div>
)}
</div>
</div>
);
};
Expand Down
36 changes: 36 additions & 0 deletions src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,38 @@ body {
animation: drop-bounce 0.6s ease-out 0.2s both;
}

/* Flip card animation */
.flip-card {
perspective: 1000px;
cursor: pointer;
}

.flip-card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
}

.flip-card-front,
.flip-card-back {
backface-visibility: hidden;
}

.flip-card-front {
position: relative;
}

.flip-card-back {
position: absolute;
inset: 0;
transform: rotateY(180deg);
display: flex;
flex-direction: column;
visibility: hidden;
}

@media (prefers-reduced-motion: reduce) {
.animate-blink,
.animate-gradient,
Expand All @@ -189,6 +221,10 @@ body {
animation: none;
opacity: 1;
}

.flip-card-inner {
transition: none;
}
}

@keyframes fadeIn {
Expand Down
6 changes: 3 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@


"@acm-uiuc/core-client@^4.1.11":
version "4.1.11"
resolved "https://registry.npmjs.org/@acm-uiuc/core-client/-/core-client-4.1.11.tgz"
integrity sha512-F0VgGQv6186QZ5N4t/IQaWzrapSVPoUTT8yAwIuashQlWTxhkk195Q5HNTq3qbWYd118qxt2yswU9dywNEGqUg==
version "4.3.3"
resolved "https://registry.yarnpkg.com/@acm-uiuc/core-client/-/core-client-4.3.3.tgz#450ce63c1f05916181a8502fb6159bca5a53be8c"
integrity sha512-pfOZdxMei1YzhUFheQKL6OBlrDlFwVbFc3ULIHvttma5EGLVu6semljT/IgzA2BNuHM13qzZ6oJXqKF3Di6YWw==

"@acm-uiuc/js-shared@^3.5.0":
version "3.5.0"
Expand Down