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
5 changes: 3 additions & 2 deletions src/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { SearchToolsProvider } from '@/components/app-header/search-tools';
import { AppHeader } from '@/components/app-header/app-header';
import { AppFooter } from '@/components/app-footer/app-footer';

type Props = Readonly<React.PropsWithChildren>;

const MainLayout = ({ children }: Props) => (
<>
<SearchToolsProvider>
<AppHeader />
<main className='mx-auto min-h-[calc(100vh-4rem)] max-w-7xl px-2 pt-4 lg:min-h-[calc(100vh-5rem)]'>
{children}
</main>
<AppFooter />
</>
</SearchToolsProvider>
);

export default MainLayout;
20 changes: 20 additions & 0 deletions src/app/(main)/terms/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ const TermsPage = () => {
<p className='text-muted-foreground'>
This page will contain the JobStash terms of service.
</p>
<section className='mt-10 space-y-3'>
<h2 className='text-xl font-semibold'>Data attribution</h2>
<p className='text-muted-foreground'>
Location data includes information from{' '}
<a
className='underline underline-offset-4 hover:text-foreground'
href='https://www.geonames.org/'
>
GeoNames
</a>
, licensed under{' '}
<a
className='underline underline-offset-4 hover:text-foreground'
href='https://creativecommons.org/licenses/by/4.0/'
>
Creative Commons Attribution 4.0
</a>
. We normalize this data for job location search.
</p>
</section>
</div>
);
};
Expand Down
1 change: 1 addition & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-accent: #a78bfa;
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
Expand Down
12 changes: 0 additions & 12 deletions src/components/app-footer/footer-legal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,5 @@ export const FooterLegal = () => (
<p className='mt-4 text-xs text-muted-foreground'>
&copy; {CURRENT_YEAR} JobStash. All rights reserved.
</p>
<p className='text-xs text-muted-foreground'>
Location data by{' '}
<a
href='https://www.geonames.org/'
target='_blank'
rel='noreferrer'
className='transition-colors hover:text-foreground'
>
GeoNames
</a>
.
</p>
</div>
);
30 changes: 9 additions & 21 deletions src/components/app-header/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import Link from 'next/link';

import { SearchHeader } from '@/features/search';

import { SearchToolsSlot } from './search-tools';
import { MobileNavigation } from './mobile-navigation';
import { Brand } from './brand';
import { HeaderAuthButton } from './header-auth-button.lazy';

Expand All @@ -10,9 +12,10 @@ export const AppHeader = () => {
<header className='sticky top-0 z-40 flex justify-center border-b border-neutral-900 bg-background/40 backdrop-blur-lg'>
<div className='w-full max-w-7xl'>
<div className='flex h-16 items-center gap-3 px-2 lg:h-20 lg:gap-3'>
<div className='w-fit xl:w-48'>
<div className='hidden w-fit xl:block xl:w-48'>
<Brand />
</div>
<MobileNavigation />

<nav
aria-label='Primary navigation'
Expand All @@ -32,30 +35,15 @@ export const AppHeader = () => {
</Link>
</nav>

<div className='flex grow items-center lg:pl-3'>
<div className='flex min-w-0 grow items-center rounded-lg border border-border bg-card xl:border-0 xl:bg-transparent xl:pl-3'>
<SearchHeader />
<SearchToolsSlot />
</div>

<HeaderAuthButton />
<div className='hidden xl:block'>
<HeaderAuthButton />
</div>
</div>

<nav
aria-label='Analytics navigation'
className='grid grid-cols-2 border-t border-neutral-900 xl:hidden'
>
<Link
href='/market'
className='flex min-h-10 items-center justify-center border-r border-neutral-900 px-2 py-2 text-center text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-card hover:text-foreground focus-visible:bg-card focus-visible:text-foreground focus-visible:outline-none sm:text-xs'
>
Job Market Analytics
</Link>
<Link
href='/developers'
className='flex min-h-10 items-center justify-center px-2 py-2 text-center text-[11px] font-semibold text-muted-foreground transition-colors hover:bg-card hover:text-foreground focus-visible:bg-card focus-visible:text-foreground focus-visible:outline-none sm:text-xs'
>
Developer Ecosystem Analytics
</Link>
</nav>
</div>
</header>
);
Expand Down
50 changes: 50 additions & 0 deletions src/components/app-header/mobile-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, it, vi } from 'vitest';
import { MobileNavigation } from './mobile-navigation';
const session = vi.hoisted(() => ({
isAuthenticated: false,
isLoading: false,
}));
vi.mock('@/hooks/use-eligibility', () => ({ useEligibility: () => session }));
vi.mock('@/components/jobstash-logo', () => ({
JobstashLogo: () => <span>JobStash logo</span>,
}));
afterEach(cleanup);
it('opens reports and login from the logo and closes when a link is selected', async () => {
session.isAuthenticated = false;
const user = userEvent.setup();
render(<MobileNavigation />);
await user.click(screen.getByRole('button', { name: 'Open navigation' }));
expect(screen.getByRole('link', { name: 'Log in' })).toHaveAttribute(
'href',
'/login',
);
expect(
screen.queryByRole('link', { name: 'Settings' }),
).not.toBeInTheDocument();
const report = screen.getByRole('link', {
name: 'Developer ecosystem report',
});
expect(report).toHaveAttribute('href', '/developers');
await user.click(report);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('shows profile and settings for an authenticated user', async () => {
session.isAuthenticated = true;
const user = userEvent.setup();
render(<MobileNavigation />);
await user.click(screen.getByRole('button', { name: 'Open navigation' }));
expect(screen.getByRole('link', { name: 'Profile' })).toHaveAttribute(
'href',
'/profile',
);
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute(
'href',
'/profile/settings',
);
expect(
screen.queryByRole('link', { name: 'Log in' }),
).not.toBeInTheDocument();
});
91 changes: 91 additions & 0 deletions src/components/app-header/mobile-navigation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
'use client';

import Link from 'next/link';
import { useState } from 'react';
import { MenuIcon } from 'lucide-react';
import { useEligibility } from '@/hooks/use-eligibility';
import { JobstashLogo } from '@/components/jobstash-logo';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetTrigger,
SheetClose,
} from '@/components/ui/sheet';

const browseLinks = [
['All jobs', '/'],
['Urgently hiring', '/urgently-hiring'],
['Crypto beginner jobs', '/crypto-beginner-jobs'],
['Fully remote jobs', '/lt-fully-remote'],
];
const reportLinks = [
['Job market analytics', '/market'],
['Developer ecosystem report', '/developers'],
];

export const MobileNavigation = () => {
const [open, setOpen] = useState(false);
const { isAuthenticated, isLoading } = useEligibility();
const navLink = ([label, href]: string[]) => (
<SheetClose asChild key={href}>
<Link
href={href}
className='flex min-h-11 items-center rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
>
{label}
</Link>
</SheetClose>
);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
type='button'
aria-label='Open navigation'
className='relative flex size-12 shrink-0 items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none xl:hidden'
>
<JobstashLogo className='size-9' />
<MenuIcon
className='absolute right-0 bottom-0 size-4 rounded bg-background'
aria-hidden
/>
</button>
</SheetTrigger>
<SheetContent side='left' className='h-dvh w-full max-w-sm gap-0'>
<SheetHeader className='border-b'>
<SheetTitle>JobStash</SheetTitle>
<SheetDescription className='sr-only'>
Browse jobs, reports, and your account.
</SheetDescription>
</SheetHeader>
<nav
aria-label='Mobile navigation'
className='min-h-0 flex-1 space-y-4 overflow-y-auto p-4'
>
<div>{browseLinks.map(navLink)}</div>
<div className='border-t pt-4'>{reportLinks.map(navLink)}</div>
{!isLoading && (
<div className='border-t pt-4'>
{(isAuthenticated
? [
['Profile', '/profile'],
['Settings', '/profile/settings'],
]
: [['Log in', '/login']]
).map(navLink)}
</div>
)}
<div className='border-t pt-4 text-muted-foreground'>
{[
['Privacy policy', '/privacy'],
['Terms of service', '/terms'],
].map(navLink)}
</div>
</nav>
</SheetContent>
</Sheet>
);
};
27 changes: 27 additions & 0 deletions src/components/app-header/search-tools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import { createContext, useContext, useState, type ReactNode } from 'react';

const SearchToolsContext = createContext<{
target: HTMLDivElement | null;
setTarget: (target: HTMLDivElement | null) => void;
} | null>(null);

export const SearchToolsProvider = ({ children }: { children: ReactNode }) => {
const [target, setTarget] = useState<HTMLDivElement | null>(null);
return (
<SearchToolsContext value={{ target, setTarget }}>
{children}
</SearchToolsContext>
);
};

export const SearchToolsSlot = () => {
const tools = useContext(SearchToolsContext);
return (
<div ref={tools?.setTarget} className='shrink-0 empty:hidden lg:hidden' />
);
};

export const useSearchToolsTarget = () =>
useContext(SearchToolsContext)?.target ?? null;
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,16 @@ export const FiltersDrawerClient = ({
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant='secondary' size='sm' className='gap-2'>
<Button
variant='ghost'
size='sm'
aria-label={
activeCount ? `Filters (${activeCount} active)` : 'Filters'
}
className='min-h-11 rounded-none rounded-r-lg border-l border-border px-3'
>
<FunnelIcon className='size-3.5' />
Filters
<span className='sr-only'>Filters</span>
{activeCount > 0 && (
<Badge variant='default' className='px-1.5 text-[10px]'>
{activeCount}
Expand Down
30 changes: 15 additions & 15 deletions src/features/job-market/components/chart-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ export const activityChartOption = (
itemStyle: {
color:
name === 'Open jobs'
? '#34d399'
? '#a78bfa'
: name === 'Hiring companies'
? '#60a5fa'
: '#a78bfa',
? '#d4d4d8'
: '#71717a',
opacity: name === 'New jobs' ? 0.55 : 1,
},
};
Expand Down Expand Up @@ -155,10 +155,10 @@ export const salaryChartOption = (
itemStyle: {
color:
name === 'Median'
? '#fbbf24'
? '#a78bfa'
: name === '25th'
? '#fb7185'
: '#38bdf8',
? '#d4d4d8'
: '#71717a',
},
};
});
Expand All @@ -167,15 +167,15 @@ export const salaryChartOption = (

export const tickerColor = (ticker: JobMarketTicker): string => {
if (ticker.activity.openInventory.direction === 'new') {
return '#334155';
return '#27272a';
}
const change = ticker.activity.marketComparison.openInventoryPercentagePoints;
if (change === null) return '#334155';
if (change <= -25) return '#9f3345';
if (change < -5) return '#71343e';
if (change === null) return '#27272a';
if (change <= -25) return '#52525b';
if (change < -5) return '#3f3f46';
if (change <= 5) return '#3f3f46';
if (change < 25) return '#285b4d';
return '#2f8063';
if (change < 25) return '#432e63';
return '#5b398c';
};

const marketDifferenceLabel = (ticker: JobMarketTicker): string => {
Expand Down Expand Up @@ -405,10 +405,10 @@ export const skillSalaryTrendOption = (
},
itemStyle: {
color: median
? '#34d399'
? '#a78bfa'
: record.name === '25th percentile'
? '#60a5fa'
: '#f59e0b',
? '#d4d4d8'
: '#8743ff',
},
};
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@ export const CompensationBandChart = ({
<div
className={`absolute top-1/2 h-2 -translate-y-1/2 rounded-full ${
band.segment === 'remote'
? 'bg-emerald-400/70'
: 'bg-cyan-400/70'
? 'bg-chart-accent/70'
: 'bg-primary/70'
}`}
style={{ left: `${start}%`, width: `${width}%` }}
/>
Expand Down
Loading
Loading