Skip to content
Merged
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
19 changes: 18 additions & 1 deletion __tests__/components/Leaderboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

jest.mock('next/link', () => ({
__esModule: true,
default: ({ children }: any) => <a>{children}</a>,

Check warning on line 25 in __tests__/components/Leaderboard.test.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and Test

Unexpected any. Specify a different type
}));

Object.defineProperty(window, 'matchMedia', {
Expand Down Expand Up @@ -62,16 +62,33 @@
displayName: 'User B',
photoURL: null,
count: 5,
isPrivateMode: true,
},
{
idKey: 'key_3',
uid: null,
displayName: 'Anonymous',
photoURL: null,
count: 3,
},
{
idKey: 'key_4',
uid: '4',
displayName: 'User D',
photoURL: null,
count: 1,
},
];

it('should render leaderboard entries correctly', () => {
renderWithMantine(<Leaderboard entries={mockEntries} />);
renderWithMantine(<Leaderboard entries={mockEntries} currentUserId="2" />);

expect(screen.getByText(/User A/i)).toBeInTheDocument();
expect(screen.getByText(/10 Countries/i)).toBeInTheDocument();
expect(screen.getByText(/User B/i)).toBeInTheDocument();
expect(screen.getByText(/5 Countries/i)).toBeInTheDocument();
expect(screen.getByText(/Anonymous/i)).toBeInTheDocument();
expect(screen.getByText(/User D/i)).toBeInTheDocument();
});

it('should display a fallback message when there are no entries', () => {
Expand Down
20 changes: 16 additions & 4 deletions __tests__/components/VisitedCountriesSummary.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MantineProvider } from '@mantine/core';
import VisitedCountriesSummary from '@/components/VisitedCountriesSummary';
import { theme } from '@/theme';
import VisitedCountriesSummary, { getExplorerMilestone } from '@/components/VisitedCountriesSummary';
import { theme, getRegionAccent } from '@/theme';

jest.mock('@/data/countries', () => {
const actual = jest.requireActual('@/data/countries');
return {
...actual,
countries: [
{ code: 'AT', name: 'Atlantis', flag: '🔱' },
{ code: 'ED', name: 'El Dorado' },
{ code: 'AT', cca3: 'ATL', name: 'Atlantis', region: 'Asia', flag: '🔱' },
{ code: 'ED', name: 'El Dorado', region: 'Americas' },
{ code: 'AV', name: 'Avalon' },
],
};
Expand Down Expand Up @@ -62,4 +62,16 @@ describe('VisitedCountriesSummary Component', () => {
expect(screen.getByText('1 / 3')).toBeInTheDocument();
expect(screen.getByText(/33\.3%/)).toBeInTheDocument();
});

it('should resolve explorer milestones and continent accents across all tiers', () => {
expect(getExplorerMilestone(0)).toBe('Passport Ready');
expect(getExplorerMilestone(3)).toBe('Daytripper');
expect(getExplorerMilestone(10)).toBe('Continent Hopper');
expect(getExplorerMilestone(20)).toBe('Jetsetter');
expect(getExplorerMilestone(40)).toBe('Globetrotter');

expect(getRegionAccent('Asia').name).toBe('Asia');
expect(getRegionAccent('UnknownRegion').name).toBe('World');
expect(getRegionAccent().name).toBe('World');
});
});
2 changes: 1 addition & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ The application should be highly responsive and fast, especially the map visuali
The application's tone and voice should be playful and adventurous, inspiring users to explore new places. It should also incorporate a competitive aspect, but in a lighthearted manner, emphasizing good-natured fun among connections rather than intense rivalry.

### Visual Identity
The visual identity of the application should feel modern and sleek, utilizing clean lines, minimalist design, and a strong focus on data visualization. This approach will highlight the travel data and leaderboards effectively while maintaining an engaging aesthetic.
The visual identity of the application follows a **Sunlit Passport & Vintage Air Mail** design system—combining warm parchment surfaces, diagonal *Par Avion* airmail accents, classic editorial serif headings, continent-coded cartography, and vintage postmark ink stamps (with full dark-mode support) to celebrate travel milestones and leaderboards in an inviting, tactile logbook aesthetic.

## MVP Specifications

Expand Down
2 changes: 1 addition & 1 deletion docs/STYLE_GUIDES.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ The project follows Google's style guides for frontend development.
## Testing Style Guide
- **Requirement:** Every new feature, component, and code change must have corresponding unit tests.
- **Tools:** Use Jest and React Testing Library (or whatever testing framework is configured in the project).
- **Pre-commit Checks:** ALWAYS run the entire test suite and verify that all tests pass before committing. Committing code with failing or missing tests is strictly prohibited.
- **Pre-commit Checks:** Before every commit or pull request, ALWAYS run the exact checks executed by the `.github/workflows/ci.yml` PR job (`npm run lint`, `npx tsc --noEmit`, and `npm run test:coverage` to enforce coverage thresholds, not just `npm test`) and verify all checks exit with code `0`. Committing code with lint errors, type errors, failing tests, or below-threshold test coverage is strictly prohibited.
85 changes: 64 additions & 21 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -14,49 +14,92 @@
* limitations under the License.
*/

/**
* Global Stylesheet — "Sunlit Passport & Vintage Air Mail" (Option 1)
*
* Architectural Rationale:
* Provides reusable decorative surfaces that reinforce the tactile travel logbook theme:
* 1. `.airmail-border`: Classic diagonal Par Avion red-white-and-navy envelope trim used
* along the top header and summary card edges.
* 2. `.passport-stamp`: Double-bordered vintage postmark ink stamp anchored in the lower-right
* corner of visited destination cards so country names and flags remain 100% legible and
* celebrated (never dimmed out). Supports per-card `--stamp-color` (continent ink) and
* `--stamp-rotate` (deterministic hand-stamped angle) custom properties.
* 3. `.country-card`: Tactile card hover elevation mimicking physical passport pages.
*/

@import '@mantine/core/styles.css';

/* Vintage Par Avion Diagonal Trim */
.airmail-border {
height: 6px;
width: 100%;
background: repeating-linear-gradient(
-45deg,
#E63946 0px,
#E63946 12px,
light-dark(#FFFDF9, #16213A) 12px,
light-dark(#FFFDF9, #16213A) 20px,
#1D3557 20px,
#1D3557 32px,
light-dark(#FFFDF9, #16213A) 32px,
light-dark(#FFFDF9, #16213A) 40px
);
}

/* Passport Stamp Animation */
@keyframes stamp-in {
0% {
opacity: 0;
transform: scale(3) rotate(-25deg);
transform: scale(2.6) rotate(-24deg);
}
40% {
45% {
opacity: 1;
transform: scale(0.8) rotate(-10deg);
transform: scale(0.9) rotate(var(--stamp-rotate, -10deg));
}
100% {
opacity: 0.85;
transform: scale(1) rotate(-15deg);
opacity: 0.92;
transform: scale(1) rotate(var(--stamp-rotate, -10deg));
}
}

.passport-stamp {
position: absolute;
top: 50%;
left: 50%;
margin-top: -30px;
margin-left: -60px;
width: 120px;
height: 60px;
border: 4px solid var(--mantine-color-digital-atlas-cyan-6);
border-radius: 8px;
color: var(--mantine-color-digital-atlas-cyan-6);
bottom: 10px;
right: 12px;
padding: 2px 8px;
border: 2px solid var(--stamp-color, #E63946);
outline: 1px dashed var(--stamp-color, #E63946);
outline-offset: -4px;
border-radius: 6px;
color: var(--stamp-color, #E63946);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-weight: 800;
font-size: 1.4rem;
letter-spacing: 2px;
font-size: 0.68rem;
letter-spacing: 1.4px;
text-transform: uppercase;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
pointer-events: none;
background-color: light-dark(rgba(240, 248, 255, 0.9), rgba(30, 30, 30, 0.9));
background-color: light-dark(rgba(255, 253, 249, 0.92), rgba(15, 23, 42, 0.88));
box-shadow: 0 1px 2px rgba(29, 53, 87, 0.08);
z-index: 10;
transform: scale(1) rotate(-15deg);
opacity: 0.85;
transform: scale(1) rotate(var(--stamp-rotate, -10deg));
opacity: 0.92;
}

.passport-stamp.animating {
animation: stamp-in 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
}

/* Tactile Country Card Hover */
.country-card {
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}

.country-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px -6px rgba(29, 53, 87, 0.14);
}
5 changes: 3 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<head>
<ColorSchemeScript defaultColorScheme="dark" />
{/* Default to light mode so the "Sunlit Passport & Vintage Air Mail" parchment theme shines out of the box */}
<ColorSchemeScript defaultColorScheme="light" />
</head>
<body className={inter.className}>
<MantineProvider theme={theme} defaultColorScheme="dark">
<MantineProvider theme={theme} defaultColorScheme="light">
<AuthProvider>
<AppShellLayout>
{children}
Expand Down
20 changes: 11 additions & 9 deletions src/app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,43 +68,45 @@ export default function LoginPage() {
<Container size="xs" w="100%" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Card
shadow="xl"
p="xl"
p={0}
radius="lg"
withBorder
style={{
textAlign: 'center',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-7))',
backgroundColor: 'light-dark(#FFFDF9, #16213A)',
position: 'relative',
overflow: 'hidden',
width: '100%'
}}
>
<div className="airmail-border" />
<div style={{
position: 'absolute',
top: 0,
top: 6,
left: 0,
right: 0,
bottom: 0,
background: 'radial-gradient(circle at top left, light-dark(rgba(0,182,207,0.1), rgba(0,182,207,0.05)) 0%, transparent 40%), radial-gradient(circle at bottom right, light-dark(rgba(65,98,159,0.1), rgba(65,98,159,0.05)) 0%, transparent 40%)',
background: 'radial-gradient(circle at top left, light-dark(rgba(29,53,87,0.08), rgba(29,53,87,0.12)) 0%, transparent 45%), radial-gradient(circle at bottom right, light-dark(rgba(230,57,70,0.08), rgba(230,57,70,0.08)) 0%, transparent 45%)',
zIndex: 0,
pointerEvents: 'none'
}} />

<Stack gap="lg" align="center" style={{ position: 'relative', zIndex: 1 }}>
<Stack gap="lg" align="center" p="xl" style={{ position: 'relative', zIndex: 1 }}>
<div style={{
padding: '1rem',
borderRadius: '50%',
background: 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))',
boxShadow: 'var(--mantine-shadow-sm)'
background: '#1D3557',
color: '#FAF6EE',
boxShadow: '0 4px 12px rgba(29, 53, 87, 0.2)'
}}>
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--mantine-color-digital-atlas-cyan-6)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg xmlns="http://www.w3.org/2000/svg" width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7l6 -3l6 3l6 -3v13l-6 3l-6 -3l-6 3v-13"/>
<path d="M9 4v13"/>
<path d="M15 7v13"/>
</svg>
</div>

<Title order={2} style={{ fontSize: '2rem' }}>World Tracker</Title>
<Title order={2} style={{ fontSize: '2rem', fontFamily: 'Georgia, "Times New Roman", serif' }}>World Tracker</Title>
<Text c="dimmed" size="md" mb="sm">
Track the countries you've explored and see where you rank against the rest of the world.
</Text>
Expand Down
99 changes: 59 additions & 40 deletions src/app/profile/[uid]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useAuth } from '@/lib/AuthContext';
import { fetchPublicProfile } from '@/app/actions/profile';
import WorldMap from '@/components/WorldMap';
import { countries, getFlagEmoji, getCountryByCode } from '@/data/countries';
import { getRegionAccent } from '@/theme';
import Link from 'next/link';

/** JSDoc
Expand Down Expand Up @@ -83,38 +84,49 @@ export default function PublicProfilePage({ params }: { params: Promise<{ uid: s

return (
<Container size="lg" py="xl">
<Card withBorder shadow="sm" radius="lg" padding="xl" mb="xl">
<Group justify="space-between" align="flex-start">
<Group align="center">
<Avatar src={profile.photoURL} radius="50%" size="xl" color="digital-atlas-cyan" name={profile.displayName} />
<Stack gap={2}>
<Title order={2}>{profile.displayName}'s Passport</Title>
<Text c="dimmed">{profile.isOwner ? "This is your public profile." : "Public Travel Profile"}</Text>
<Card withBorder shadow="sm" radius="lg" padding={0} mb="xl" style={{ overflow: 'hidden' }}>
<div className="airmail-border" />
<div style={{ padding: 'var(--mantine-spacing-xl)' }}>
<Group justify="space-between" align="flex-start">
<Group align="center">
<Avatar src={profile.photoURL} radius="50%" size="xl" color="passport-navy" name={profile.displayName} />
<Stack gap={2}>
<Title order={2}>{profile.displayName}'s Passport</Title>
<Text c="dimmed">{profile.isOwner ? "This is your public profile." : "Public Travel Profile"}</Text>
</Stack>
</Group>
<Stack align="center" gap={0}>
<Text
size="3rem"
fw={900}
c="postmark-red.5"
style={{
lineHeight: 1,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
}}
>
{fullVisitedData.length}
</Text>
<Text fw={600} tt="uppercase" size="xs" c="dimmed" style={{ letterSpacing: '1px' }}>
{fullVisitedData.length === 1 ? 'Country' : 'Countries'} Visited
</Text>
</Stack>
</Group>
<Stack align="center" gap={0}>
<Text size="3rem" fw={900} c="digital-atlas-cyan.6" style={{ lineHeight: 1 }}>
{fullVisitedData.length}
</Text>
<Text fw={600} tt="uppercase" size="xs" c="dimmed" style={{ letterSpacing: '1px' }}>
{fullVisitedData.length === 1 ? 'Country' : 'Countries'} Visited
</Text>
</Stack>
</Group>

{profile.isOwner && (
<Group mt="xl">
<Button variant="light" color="digital-atlas-cyan" onClick={() => {
navigator.clipboard.writeText(window.location.href);
alert("Profile Link Copied to clipboard!");
}}>
Copy Public Link 🔗
</Button>
<Button component={Link} href="/settings" variant="subtle" color="gray">
Edit Privacy
</Button>
</Group>
)}
{profile.isOwner && (
<Group mt="xl">
<Button variant="light" color="digital-atlas-cyan" onClick={() => {
navigator.clipboard.writeText(window.location.href);
alert("Profile Link Copied to clipboard!");
}}>
Copy Public Link 🔗
</Button>
<Button component={Link} href="/settings" variant="subtle" color="gray">
Edit Privacy
</Button>
</Group>
)}
</div>
</Card>

<Card withBorder shadow="sm" radius="lg" padding="xl" mb="xl">
Expand All @@ -128,17 +140,24 @@ export default function PublicProfilePage({ params }: { params: Promise<{ uid: s
<Text c="dimmed">This passport is empty right now.</Text>
) : (
<Group gap="sm">
{fullVisitedData.map((c: any) => (
<Badge
key={c.code}
size="xl"
variant="light"
color="digital-atlas-cyan"
leftSection={<span style={{ fontSize: '1.2rem' }}>{getFlagEmoji(c.code, c.flag)}</span>}
>
{c.name}
</Badge>
))}
{fullVisitedData.map((c: any) => {
const accent = getRegionAccent(c.region);
return (
<Badge
key={c.code}
size="xl"
variant="outline"
style={{
borderColor: accent.hex,
color: accent.hex,
backgroundColor: `light-dark(${accent.bgLight}, rgba(15, 23, 42, 0.5))`,
}}
leftSection={<span style={{ fontSize: '1.2rem' }}>{getFlagEmoji(c.code, c.flag)}</span>}
>
{c.name}
</Badge>
);
})}
</Group>
)}
</Card>
Expand Down
Loading
Loading