{Array.from({ length: layers }, (_, i) => layers - i).map((layer) => (
))}
-
-
+
{
+ if (event.touches.length !== 1) gesture.current = null;
+ }}
+ onTouchEnd={onTouchEnd}
+ onTouchCancel={() => {
+ gesture.current = null;
+ }}
+ >
+
+
+ {job ? (
+
+ ) : (
+
+
+ {organization?.name}
+
+
{selected.title}
+
+ {failed
+ ? 'Open the job page to see the details.'
+ : 'Loading job details…'}
+
+ {failed && (
+
+ View job details
+
+
+ )}
+
+ )}
+
+ {footer}
+
);
diff --git a/src/features/jobs/components/job-list/use-stack-job.ts b/src/features/jobs/components/job-list/use-stack-job.ts
new file mode 100644
index 0000000..9fe2537
--- /dev/null
+++ b/src/features/jobs/components/job-list/use-stack-job.ts
@@ -0,0 +1,46 @@
+'use client';
+
+import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query';
+import { jobListItemSchema, type JobListItemSchema } from '../../schemas';
+
+const stackJobOptions = (id: string, importRunId: string | null) =>
+ queryOptions({
+ queryKey: ['job-stack-card', importRunId, id],
+ queryFn: async ({ signal }) => {
+ const response = await fetch(
+ `/api/jobs/cards/${encodeURIComponent(id)}`,
+ {
+ signal,
+ },
+ );
+ if (!response.ok) throw new Error('Job unavailable');
+ const job = jobListItemSchema.parse(await response.json());
+ if (job.id !== id) throw new Error('Job lookup mismatch');
+ return job;
+ },
+ staleTime: 5 * 60 * 1000,
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+
+export const useStackJob = (
+ id: string,
+ importRunId: string | null,
+ initialJobs: JobListItemSchema[],
+) => {
+ const client = useQueryClient();
+ const initial = initialJobs.find((job) => job.id === id);
+ const query = useQuery({
+ ...stackJobOptions(id, importRunId),
+ initialData: initial,
+ enabled: !initial,
+ });
+ return {
+ job: initial ?? query.data,
+ failed: query.isError,
+ prefetch: (jobId: string) => {
+ if (!initialJobs.some((job) => job.id === jobId))
+ void client.prefetchQuery(stackJobOptions(jobId, importRunId));
+ },
+ };
+};
diff --git a/src/features/jobs/job-stack.ts b/src/features/jobs/job-stack.ts
new file mode 100644
index 0000000..1eab2a6
--- /dev/null
+++ b/src/features/jobs/job-stack.ts
@@ -0,0 +1,10 @@
+import { z } from 'zod';
+
+export const jobStackTitleSchema = z.object({
+ id: z.string().min(1),
+ title: z.string().min(1),
+ location: z.string().nullable(),
+ href: z.string().min(1),
+});
+
+export type JobStackTitle = z.infer
;
diff --git a/src/features/jobs/server/data/fetch-job-feed.test.ts b/src/features/jobs/server/data/fetch-job-feed.test.ts
new file mode 100644
index 0000000..f3bbec8
--- /dev/null
+++ b/src/features/jobs/server/data/fetch-job-feed.test.ts
@@ -0,0 +1,129 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { makeJobDto } from '../../test/job-fixtures';
+import { fetchJobFeed, jobFeedDto } from './fetch-job-feed';
+
+vi.mock('@/lib/env/client', () => ({
+ clientEnv: { MW_URL: 'https://middleware.test' },
+}));
+afterEach(() => vi.unstubAllGlobals());
+
+const batch = (count: number) => ({
+ page: 1,
+ count: 1,
+ total: 1,
+ totalJobs: count,
+ mode: 'grouped' as const,
+ data: [
+ {
+ key: 'org-acme',
+ organizationId: 'org-acme',
+ importRunId: 'import-2026-09-29',
+ totalJobs: count,
+ jobs: [makeJobDto('job-0')],
+ jobTitles: Array.from({ length: count }, (_, i) => ({
+ id: `db-job-${i}`,
+ shortUUID: `job-${i}`,
+ title: `Engineer ${i}`,
+ location: i % 2 ? 'Berlin' : 'Amsterdam',
+ })),
+ },
+ ],
+});
+
+describe('organization job feed', () => {
+ it.each([1, 7, 63, 251])(
+ 'preserves an entire %i-job import without an arbitrary stack limit',
+ async (count) => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(Response.json(batch(count))),
+ );
+ const result = await fetchJobFeed(1, {});
+ expect(result.mode).toBe('grouped');
+ if (result.mode !== 'grouped') throw new Error('Expected grouped feed');
+ expect(result.data[0].jobTitles).toHaveLength(count);
+ expect(result.data[0].jobs).toHaveLength(1);
+ expect(result.data[0].importRunId).toBe('import-2026-09-29');
+ expect(result.data[0].jobTitles.at(-1)).toEqual({
+ id: `job-${count - 1}`,
+ title: `Engineer ${count - 1}`,
+ location: (count - 1) % 2 ? 'Berlin' : 'Amsterdam',
+ href: `/engineer-${count - 1}-acme/job-${count - 1}`,
+ });
+ },
+ );
+
+ it('keeps server pagination and filters, without requesting a per-organization cap', async () => {
+ const fetch = vi.fn().mockResolvedValue(Response.json(batch(63)));
+ vi.stubGlobal('fetch', fetch);
+ await fetchJobFeed(3, {
+ workModes: 'remote',
+ organizations: 'acme',
+ page: '2',
+ });
+ const url = new URL(fetch.mock.calls[0][0]);
+ expect(url.pathname).toBe('/jobs/feed');
+ expect(Object.fromEntries(url.searchParams)).toEqual({
+ workModes: 'remote',
+ organizations: 'acme',
+ page: '3',
+ limit: '10',
+ batch: 'latest-import',
+ });
+ expect(fetch.mock.calls[0][1]).toEqual({ cache: 'no-store' });
+ });
+
+ it('still maps individual organization results without adding stack navigation', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ Response.json({
+ page: 2,
+ count: 1,
+ total: 11,
+ totalJobs: 11,
+ mode: 'individual',
+ data: [makeJobDto()],
+ }),
+ ),
+ );
+ const result = await fetchJobFeed(2, { organizations: 'org-acme' });
+ expect(result.mode).toBe('individual');
+ expect(result.data[0]).toMatchObject({
+ id: 'job-1',
+ organization: { href: '/o-acme~org-acme' },
+ });
+ expect(result.data[0]).not.toHaveProperty('jobTitles');
+ });
+
+ it('rejects empty groups so the card always has its first job and title', () => {
+ const emptyJobs = batch(1);
+ emptyJobs.data[0].jobs = [];
+ expect(jobFeedDto.safeParse(emptyJobs).success).toBe(false);
+ expect(jobFeedDto.safeParse(batch(0)).success).toBe(false);
+ });
+
+ it('uses each job’s own role as the shared fallback when its title is missing', async () => {
+ const source = batch(2);
+ const titles = source.data[0].jobTitles.map((item, index) => ({
+ ...item,
+ title: index === 0 ? '' : ' ',
+ seniority: index === 0 ? '3' : null,
+ classification: index === 0 ? 'engineering' : null,
+ }));
+ const data = {
+ ...source,
+ data: [{ ...source.data[0], jobTitles: titles }],
+ };
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json(data)));
+ const result = await fetchJobFeed(1, {});
+ if (result.mode !== 'grouped') throw new Error('Expected grouped feed');
+ expect(result.data[0].jobTitles.map(({ title }) => title)).toEqual([
+ 'Senior Engineering at Acme',
+ 'Role at Acme',
+ ]);
+ expect(result.data[0].jobTitles[0].href).toBe(
+ '/senior-engineering-at-acme-acme/job-0',
+ );
+ });
+});
diff --git a/src/features/jobs/server/data/fetch-job-feed.ts b/src/features/jobs/server/data/fetch-job-feed.ts
index c00f9b8..3768bca 100644
--- a/src/features/jobs/server/data/fetch-job-feed.ts
+++ b/src/features/jobs/server/data/fetch-job-feed.ts
@@ -2,7 +2,11 @@ import 'server-only';
import { z } from 'zod';
import { clientEnv } from '@/lib/env/client';
import { jobListItemDto } from '../dtos/job-list-item.dto';
-import { dtoToJobListItem } from '../dtos/dto-to-job-list-item';
+import {
+ createJobItemHref,
+ dtoToJobListItem,
+ getJobTitle,
+} from '../dtos/dto-to-job-list-item';
const paging = {
page: z.number(),
@@ -23,8 +27,21 @@ export const jobFeedDto = z.discriminatedUnion('mode', [
z.object({
key: z.string(),
organizationId: z.string().nullable(),
+ importRunId: z.string().nullable(),
totalJobs: z.number(),
- jobs: jobListItemDto.array().min(1).max(5),
+ jobs: jobListItemDto.array().min(1),
+ jobTitles: z
+ .array(
+ z.object({
+ id: z.string(),
+ shortUUID: z.string(),
+ title: z.string(),
+ location: z.string().nullable(),
+ seniority: z.string().nullable().optional(),
+ classification: z.string().nullable().optional(),
+ }),
+ )
+ .min(1),
}),
),
}),
@@ -38,6 +55,7 @@ export const fetchJobFeed = async (
...searchParams,
page: String(page),
limit: '10',
+ batch: 'latest-import',
});
const response = await fetch(`${clientEnv.MW_URL}/jobs/feed?${params}`, {
cache: 'no-store',
@@ -51,6 +69,22 @@ export const fetchJobFeed = async (
data: result.data.map((entry) => ({
...entry,
jobs: entry.jobs.map(dtoToJobListItem),
+ jobTitles: entry.jobTitles.map((item) => {
+ const descriptor = {
+ ...entry.jobs[0],
+ shortUUID: item.shortUUID,
+ title: item.title,
+ seniority: item.seniority ?? null,
+ classification: item.classification ?? null,
+ };
+ const title = getJobTitle(descriptor);
+ return {
+ id: item.shortUUID,
+ title,
+ location: item.location,
+ href: createJobItemHref(title, descriptor),
+ };
+ }),
})),
};
};
diff --git a/src/features/jobs/server/dtos/dto-to-job-list-item.ts b/src/features/jobs/server/dtos/dto-to-job-list-item.ts
index 3db31f0..a259114 100644
--- a/src/features/jobs/server/dtos/dto-to-job-list-item.ts
+++ b/src/features/jobs/server/dtos/dto-to-job-list-item.ts
@@ -51,9 +51,12 @@ const getDefaultTitle = (dto: JobListItemDto): string => {
return title || 'Open Role';
};
+export const getJobTitle = (dto: JobListItemDto): string =>
+ dto.title?.trim() || getDefaultTitle(dto);
+
const getDefaultSummary = (dto: JobListItemDto): string => {
const orgName = dto.organization?.name ?? dto.project?.name;
- const title = dto.title ?? getDefaultTitle(dto);
+ const title = getJobTitle(dto);
return orgName
? `${orgName} is looking for a ${title}.`
: `Apply for ${title} position.`;
@@ -76,7 +79,7 @@ export const dtoToJobListItem = (dto: JobListItemDto): JobListItemSchema => {
? `[REMOTE] ${location}`
: location;
const addressLookup = lookupAddresses(lookupKey);
- const title = dto.title ?? getDefaultTitle(dto);
+ const title = getJobTitle(dto);
const href = createJobItemHref(title, dto);
const infoTags = createJobInfoTags(dto, addressLookup, timestamp);
const mappedTags = dtoToJobItemTag(tags);
@@ -145,7 +148,7 @@ const dtoToAvailability = (
};
};
-const createJobItemHref = (title: string, dto: JobListItemDto) => {
+export const createJobItemHref = (title: string, dto: JobListItemDto) => {
const employerName = dto.organization?.name ?? dto.project?.name;
const orgText = employerName ? `-${employerName}` : '';
const slug = slugify(`${title}${orgText}`);
diff --git a/src/features/jobs/test/job-fixtures.ts b/src/features/jobs/test/job-fixtures.ts
new file mode 100644
index 0000000..a5f3b29
--- /dev/null
+++ b/src/features/jobs/test/job-fixtures.ts
@@ -0,0 +1,89 @@
+import type { JobListItemSchema } from '../schemas';
+import type { JobStackTitle } from '../job-stack';
+import type { JobListItemDto } from '../server/dtos/job-list-item.dto';
+
+export const makeJob = (id: string): JobListItemSchema => ({
+ id,
+ title: `Engineer ${id}`,
+ href: `/engineer-acme/${id}`,
+ hasApplyUrl: true,
+ classification: 'engineering',
+ summary: 'Build useful things.',
+ location: 'Amsterdam',
+ locationType: 'hybrid',
+ addresses: null,
+ infoTags: [],
+ tags: [],
+ availability: [],
+ organization: {
+ name: 'Acme',
+ href: '/o-acme~org-acme',
+ websiteUrl: 'https://acme.example',
+ location: 'Amsterdam',
+ logo: null,
+ employeeCount: null,
+ summary: null,
+ description: null,
+ socials: null,
+ projects: [],
+ fundingRounds: [],
+ investors: [],
+ fundingStage: null,
+ recentlyFunded: false,
+ teamCoverageStatus: null,
+ teamSignalsAsOf: null,
+ currentMaintainerCount: null,
+ activeLeadCount: null,
+ newActiveLeadCount: null,
+ steppedDownLeadCount: null,
+ movedLeadCount: null,
+ earlyLeadDepartureCount: null,
+ intelligenceUrl:
+ 'https://ecosystem.vision/organizations/info/acme~org-acme',
+ },
+ timestampText: '2 hours ago',
+ datePosted: '2026-09-29',
+ badge: null,
+});
+
+export const jobTitles = (jobs: JobListItemSchema[]): JobStackTitle[] =>
+ jobs.map(({ id, title, href, location }) => ({ id, title, href, location }));
+
+export const makeJobDto = (id = 'job-1'): JobListItemDto => ({
+ id: `db-${id}`,
+ shortUUID: id,
+ title: `Engineer ${id}`,
+ url: 'https://acme.example/apply',
+ timestamp: 1_700_000_000_000,
+ summary: 'Build useful things.',
+ seniority: null,
+ salary: null,
+ minimumSalary: null,
+ maximumSalary: null,
+ location: 'Amsterdam',
+ locationType: 'hybrid',
+ commitment: null,
+ paysInCrypto: null,
+ offersTokenAllocation: null,
+ salaryCurrency: null,
+ classification: 'engineering',
+ workArrangement: null,
+ tags: [],
+ access: 'public',
+ featured: false,
+ featureStartDate: null,
+ featureEndDate: null,
+ onboardIntoWeb3: false,
+ organization: {
+ id: 'org-acme',
+ orgId: 'org-acme',
+ name: 'Acme',
+ normalizedName: 'acme',
+ website: 'https://acme.example',
+ logoUrl: null,
+ headcountEstimate: null,
+ fundingRounds: [],
+ investors: [],
+ },
+ project: null,
+});