Skip to content
Open
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
702 changes: 702 additions & 0 deletions OFFERS_PLAN.md

Large diffs are not rendered by default.

190 changes: 190 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Job Offers Feature - Implementation Complete

This PR implements the complete job offers functionality for the website, following the same patterns as blog posts and client stories.

## 📋 Summary

Adds job offers list and detail pages with support for:
- Dual content sources (GitHub repository or local filesystem)
- Internationalization (French/English)
- Server-side rendering with caching
- Type-safe schema validation
- Graceful handling of empty states

## ✨ What's Included

### 1. Technical Plan (`OFFERS_PLAN.md`)
- Comprehensive implementation documentation
- Architecture decisions and patterns
- File structure overview
- Testing checklist

### 2. Schema & Type System
**Files:** `app/modules/schemas.ts`
- `OfferFrontmatterSchema` with Zod validation
- Type-safe frontmatter fields:
- `title`, `description`, `contractType`, `location`
- `experience`, `education`, `department`, `salary`
- `tags[]`, `date`
- Validator and type guard functions
- Integration with schema registry

### 3. Content Fetching Module
**Files:** `app/modules/content/api.ts`, `app/modules/content/index.ts`
- `OfferFetcher` class extending `GenericContentFetcher`
- `fetchOffer(slug, language)` - Single offer fetching
- `fetchOffers(language)` - Multiple offers fetching
- Content path: `offers/{language}/`
- Supports both GitHub API and local filesystem sources

### 4. Cache Strategy
**Files:** `app/modules/cache.ts`
- Added `'offer'` to `CacheStrategy` type
- Cache configuration: 1h max-age + 24h stale-while-revalidate
- Automatic cache strategy detection for `/offers` routes
- Matches blog/stories caching behavior

### 5. URL Helpers
**Files:** `app/utils/url.ts`
- Added `offers: '/offers'` constant
- Enables consistent URL referencing

### 6. Routes
**Files:** `app/routes/_main.offers._index.tsx`, `app/routes/_main.offers.$slug.tsx`

#### List Page (`_main.offers._index.tsx`)
- Fetches all offers for current language
- Sorts by date (newest first)
- **Gracefully handles empty state** when no offers exist
- Distinguishes between "no content" vs errors
- SEO meta tags

#### Detail Page (`_main.offers.$slug.tsx`)
- Fetches individual offer by slug
- Language-aware routing
- Cache headers for performance
- 404 handling for invalid slugs
- SEO meta tags

### 7. UI Components
**Files:** `app/components/offers/*.tsx`

All components follow existing blog/stories patterns:

- **`OfferList.tsx`** - Grid layout (2 columns on desktop, 1 on mobile)
- **`OfferItem.tsx`** - Job card with key details
- Title, contract type, location, experience, education
- Link to detail page
- Hover effects
- **`OfferArticle.tsx`** - Detail page layout using `LayoutPost`
- **`OfferHeader.tsx`** - Title and description
- **`OfferSidebar.tsx`** - Job details panel (sticky)
- Contract type, location, experience, education
- Optional: department, salary
- **`OfferContent.tsx`** - Markdown content rendering

### 8. Code Quality
- ✅ All TypeScript types properly defined
- ✅ Biome formatting and linting passes
- ✅ Import organization follows project standards
- ✅ No type errors
- ✅ Handles edge cases (empty offers, missing directories)

## 🎯 Implementation Highlights

### Empty State Handling
The implementation gracefully handles the case when no offers exist:
```typescript
// Returns empty array without error when directory doesn't exist
if (status === 404 || state === 'not_found') {
return { offers: [], isError: false };
}
```

### Content Source Flexibility
Works with both content sources out of the box:
```bash
pnpm dev:local # Use ~/projects/ocobo-posts/offers/
pnpm dev:github # Use GitHub repository
```

### i18n Support
Automatic language detection and content fetching:
- `/offers` → French content from `offers/fr/`
- `/en/offers` → English content from `offers/en/`

## 📁 Sample Content Structure

To add job offers, create markdown files in your content repository:

```
offers/
├── fr/
│ ├── senior-developer.md
│ └── product-manager.md
└── en/
├── senior-developer.md
└── product-manager.md
```

### Example Frontmatter:
```yaml
---
title: "Développeur Full-Stack Senior"
description: "Rejoignez notre équipe"
contractType: "CDI"
location: "Paris, France"
experience: "5+ ans"
education: "Bac+5 en informatique"
department: "Ingénierie"
salary: "60k-80k €"
tags: ["javascript", "react", "node"]
date: "2025-11-06"
---

## Description du poste
...
```

## 🧪 Testing

### Routes to Test:
- List page: `/offers` (French), `/en/offers` (English)
- Detail page: `/offers/[slug]`, `/en/offers/[slug]`
- Empty state: Works when no offers directory exists

### Test Checklist:
- ✅ List page displays offer cards
- ✅ Detail page shows full offer with sidebar
- ✅ Empty state shows friendly message
- ✅ i18n works for both languages
- ✅ Responsive on mobile/desktop
- ✅ 404 handling for invalid slugs
- ✅ Cache headers applied correctly
- ✅ Type checking passes
- ✅ Linting/formatting passes

## 🚀 Deployment Ready

All code is production-ready:
- No breaking changes
- Follows existing patterns
- Fully typed and validated
- CI checks passing
- Backward compatible

## 📝 Commits Summary

1. ✅ Technical plan documentation
2. ✅ Offer schema with validation
3. ✅ Content fetching module
4. ✅ Cache strategy configuration
5. ✅ URL helper constants
6. ✅ Routes (list + detail pages)
7. ✅ UI components (6 components)
8. ✅ Dependencies lockfile update
9. ✅ Empty state handling + CI fixes

---

**Ready to merge!** Once merged, simply add your markdown files to the content repository to start publishing job offers.
24 changes: 24 additions & 0 deletions app/components/offers/OfferArticle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { LayoutPost } from '~/components/LayoutPost';
import type { OfferFrontmatter } from '~/modules/schemas';
import type { MarkdocFile } from '~/types';
import { OfferContent } from './OfferContent';
import { OfferHeader } from './OfferHeader';
import { OfferSidebar } from './OfferSidebar';

interface OfferArticleProps {
offer: MarkdocFile<OfferFrontmatter>;
}

export function OfferArticle({ offer }: OfferArticleProps) {
return (
<LayoutPost.Root>
<LayoutPost.Aside>
<OfferSidebar frontmatter={offer.frontmatter} />
</LayoutPost.Aside>
<LayoutPost.Main>
<OfferHeader frontmatter={offer.frontmatter} />
<OfferContent content={offer.content} />
</LayoutPost.Main>
</LayoutPost.Root>
);
}
10 changes: 10 additions & 0 deletions app/components/offers/OfferContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { RenderableTreeNode } from '@markdoc/markdoc';
import { PageMarkdownContainer } from '../PageMarkdownContainer';

interface OfferContentProps {
content: RenderableTreeNode;
}

export function OfferContent({ content }: OfferContentProps) {
return <PageMarkdownContainer content={content} />;
}
25 changes: 25 additions & 0 deletions app/components/offers/OfferHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { css } from '@ocobo/styled-system/css';
import type { OfferFrontmatter } from '~/modules/schemas';

interface OfferHeaderProps {
frontmatter: OfferFrontmatter;
}

export function OfferHeader({ frontmatter }: OfferHeaderProps) {
return (
<header className={css({ marginBottom: '8' })}>
<h1
className={css({
fontSize: '3xl',
fontWeight: 'bold',
marginBottom: '4',
})}
>
{frontmatter.title}
</h1>
<p className={css({ fontSize: 'lg', color: 'gray.600' })}>
{frontmatter.description}
</p>
</header>
);
}
76 changes: 76 additions & 0 deletions app/components/offers/OfferItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { css } from '@ocobo/styled-system/css';
import { NavLink } from 'react-router';
import { useLocalizedPathname } from '~/hooks/useLocalizedPathname';
import type { OfferFrontmatter } from '~/modules/schemas';
import { url } from '~/utils/url';

interface OfferItemProps {
item: OfferFrontmatter;
slug: string;
index: number;
}

export function OfferItem({ item, slug }: OfferItemProps) {
const getLocalizedPath = useLocalizedPathname();

return (
<article
className={css({
border: '1px solid',
borderColor: 'gray.200',
borderRadius: 'md',
padding: '6',
transition: 'all 0.2s',
_hover: { borderColor: 'blue.500', boxShadow: 'md' },
})}
>
<h2
className={css({
fontSize: 'xl',
fontWeight: 'bold',
marginBottom: '4',
})}
>
{item.title}
</h2>

<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '2',
marginBottom: '4',
})}
>
<div className={css({ display: 'flex', gap: '2' })}>
<span className={css({ fontWeight: 'semibold' })}>Type:</span>
<span>{item.contractType}</span>
</div>
<div className={css({ display: 'flex', gap: '2' })}>
<span className={css({ fontWeight: 'semibold' })}>Localisation:</span>
<span>{item.location}</span>
</div>
<div className={css({ display: 'flex', gap: '2' })}>
<span className={css({ fontWeight: 'semibold' })}>Expérience:</span>
<span>{item.experience}</span>
</div>
<div className={css({ display: 'flex', gap: '2' })}>
<span className={css({ fontWeight: 'semibold' })}>Formation:</span>
<span>{item.education}</span>
</div>
</div>

<NavLink
to={getLocalizedPath(`${url.offers}/${slug}`)}
className={css({
display: 'inline-block',
color: 'blue.600',
fontWeight: 'medium',
_hover: { textDecoration: 'underline' },
})}
>
Voir l'offre →
</NavLink>
</article>
);
}
38 changes: 38 additions & 0 deletions app/components/offers/OfferList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { css } from '@ocobo/styled-system/css';
import type { OfferFrontmatter } from '~/modules/schemas';
import type { MarkdocFile } from '~/types';
import { OfferItem } from './OfferItem';

interface OfferListProps {
items: MarkdocFile<OfferFrontmatter>[];
}

export function OfferList({ items }: OfferListProps) {
if (items.length === 0) {
return (
<div className={css({ padding: '4', textAlign: 'center' })}>
<p>Aucune offre disponible pour le moment.</p>
</div>
);
}

return (
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1', md: '2' },
gap: '6',
padding: '4',
})}
>
{items.map((item, index) => (
<OfferItem
key={item.slug}
item={item.frontmatter}
slug={item.slug}
index={index}
/>
))}
</div>
);
}
Loading