├── app
├── favicon.ico
├── robots.ts
├── not-found.tsx
├── page.tsx
├── jobs
│ ├── not-found.tsx
│ ├── language
│ │ └── [language]
│ │ │ └── page.tsx
│ ├── type
│ │ └── [type]
│ │ │ └── page.tsx
│ ├── level
│ │ └── [level]
│ │ │ └── page.tsx
│ ├── location
│ │ └── [location]
│ │ │ └── page.tsx
│ └── types
│ │ └── page.tsx
├── feed.xml
│ └── route.ts
├── atom.xml
│ └── route.ts
├── feed.json
│ └── route.ts
├── api
│ ├── og
│ │ ├── route.tsx
│ │ └── jobs
│ │ │ └── [slug]
│ │ │ └── route.tsx
│ └── subscribe
│ │ └── route.ts
├── changelog
│ └── page.tsx
├── globals.css
├── faq
│ └── page.tsx
├── job-alerts
│ └── page.tsx
└── sitemap.ts
├── public
├── office.jpg
├── screenshot.png
├── assets
│ ├── bordful.png
│ ├── social
│ │ ├── rss.svg
│ │ ├── linkedin.svg
│ │ ├── bluesky.svg
│ │ ├── github.svg
│ │ ├── twitter.svg
│ │ └── reddit.svg
│ ├── mastercard.svg
│ ├── applepay.svg
│ ├── visa.svg
│ ├── amex.svg
│ ├── googlepay.svg
│ └── paypal.svg
├── avatars
│ ├── marketful.png
│ ├── uithings.png
│ └── bestwriting.png
├── bordful-localhost.jpg
├── download-nodejs.jpg
├── hero-background.jpg
├── airtable-add-new-job.jpg
├── copy-airtable-base-id.jpg
├── check-node-and-npm-versions.jpg
├── bordful-job-board-software-demo.jpg
├── vercel.svg
├── use-cursor-to-customize-your-job-board-using-natural-language.jpg
├── window.svg
├── file.svg
├── globe.svg
├── next.svg
└── bordful.svg
├── .cursor
└── rules
│ ├── lib-utilities.mdc
│ ├── project-overview.mdc
│ ├── components-architecture.mdc
│ ├── configuration-system.mdc
│ ├── development-workflow.mdc
│ ├── autofix.mdc
│ └── app-router-structure.mdc
├── lib
├── utils.ts
├── email
│ ├── index.ts
│ ├── types.ts
│ └── providers
│ │ └── encharge.ts
├── hooks
│ ├── useJobsPerPage.ts
│ ├── usePagination.ts
│ ├── useSortOrder.ts
│ └── useJobSearch.ts
├── utils
│ ├── filter-jobs.ts
│ ├── slugify.ts
│ ├── image-utils.ts
│ ├── formatDate.ts
│ ├── job-validation.ts
│ ├── fonts.ts
│ ├── rss.ts
│ ├── font-utils.ts
│ └── metadata.ts
├── constants
│ ├── job-types.ts
│ ├── career-levels.ts
│ ├── workplace.ts
│ ├── defaults.ts
│ └── locations.ts
└── config
│ └── routes.ts
├── postcss.config.mjs
├── biome.jsonc
├── .env.example
├── components
├── jobs
│ ├── JobCardList.tsx
│ ├── CompactJobCardList.tsx
│ ├── JobListings.tsx
│ ├── JobSearch.tsx
│ ├── CompactJobCard.tsx
│ └── JobCard.tsx
├── ui
│ ├── label.tsx
│ ├── toaster.tsx
│ ├── input.tsx
│ ├── client-breadcrumb.tsx
│ ├── collapsible-text.tsx
│ ├── checkbox.tsx
│ ├── switch.tsx
│ ├── badge.tsx
│ ├── about-schema.tsx
│ ├── avatar.tsx
│ ├── contact-schema.tsx
│ ├── metadata-breadcrumb.tsx
│ ├── card.tsx
│ ├── accordion.tsx
│ ├── button.tsx
│ ├── post-job-banner.tsx
│ ├── icons.tsx
│ ├── jobs-per-page-select.tsx
│ ├── sort-order-select.tsx
│ ├── job-badge.tsx
│ ├── job-search-input.tsx
│ ├── similar-jobs.tsx
│ └── pagination.tsx
└── contact
│ ├── ContactInfoSection.tsx
│ └── SupportChannelCard.tsx
├── .gitignore
├── components.json
├── SECURITY.md
├── tsconfig.json
├── config
├── index.ts
└── README.md
├── .vscode
└── settings.json
├── LICENSE
├── docs
├── _template.md
├── guides
│ └── index.md
├── integrations
│ └── index.md
├── advanced
│ └── index.md
├── troubleshooting
│ └── index.md
├── reference
│ └── index.md
└── getting-started
│ └── index.md
├── tailwind.config.ts
├── CONTRIBUTING.md
├── package.json
└── next.config.ts
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/app/favicon.ico
--------------------------------------------------------------------------------
/public/office.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/office.jpg
--------------------------------------------------------------------------------
/.cursor/rules/lib-utilities.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: false
5 | ---
6 |
--------------------------------------------------------------------------------
/public/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/screenshot.png
--------------------------------------------------------------------------------
/.cursor/rules/project-overview.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: false
5 | ---
6 |
--------------------------------------------------------------------------------
/public/assets/bordful.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/assets/bordful.png
--------------------------------------------------------------------------------
/.cursor/rules/components-architecture.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: false
5 | ---
6 |
--------------------------------------------------------------------------------
/.cursor/rules/configuration-system.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: false
5 | ---
6 |
--------------------------------------------------------------------------------
/.cursor/rules/development-workflow.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: false
5 | ---
6 |
--------------------------------------------------------------------------------
/public/avatars/marketful.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/avatars/marketful.png
--------------------------------------------------------------------------------
/public/avatars/uithings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/avatars/uithings.png
--------------------------------------------------------------------------------
/public/bordful-localhost.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/bordful-localhost.jpg
--------------------------------------------------------------------------------
/public/download-nodejs.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/download-nodejs.jpg
--------------------------------------------------------------------------------
/public/hero-background.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/hero-background.jpg
--------------------------------------------------------------------------------
/public/avatars/bestwriting.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/avatars/bestwriting.png
--------------------------------------------------------------------------------
/public/airtable-add-new-job.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/airtable-add-new-job.jpg
--------------------------------------------------------------------------------
/public/copy-airtable-base-id.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/copy-airtable-base-id.jpg
--------------------------------------------------------------------------------
/public/check-node-and-npm-versions.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/check-node-and-npm-versions.jpg
--------------------------------------------------------------------------------
/public/bordful-job-board-software-demo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/bordful-job-board-software-demo.jpg
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/use-cursor-to-customize-your-job-board-using-natural-language.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/craftled/bordful/HEAD/public/use-cursor-to-customize-your-job-board-using-natural-language.jpg
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | autoprefixer: {},
6 | },
7 | };
8 |
9 | export default config;
10 |
--------------------------------------------------------------------------------
/biome.jsonc:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://biomejs.dev/schemas/2.2.2/schema.json",
3 | "extends": ["ultracite"],
4 | "linter": {
5 | "rules": {
6 | "suspicious": {
7 | "noUnknownAtRules": "off"
8 | }
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/lib/email/index.ts:
--------------------------------------------------------------------------------
1 | import { EnchargeProvider } from './providers/encharge';
2 |
3 | // Export a pre-configured instance of the Encharge provider
4 | export const emailProvider = new EnchargeProvider();
5 |
6 | // Export types for convenience
7 | export * from './types';
8 |
--------------------------------------------------------------------------------
/public/assets/social/rss.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | AIRTABLE_ACCESS_TOKEN=
2 | AIRTABLE_BASE_ID=
3 | AIRTABLE_TABLE_NAME=Jobs
4 |
5 | # Email Provider Configuration
6 | # Choose your email provider: encharge, mailchimp, convertkit, sendgrid
7 | EMAIL_PROVIDER=encharge
8 |
9 | # Encharge Configuration
10 | ENCHARGE_WRITE_KEY=your_encharge_write_key_here
11 |
12 | # Application URL
13 | NEXT_PUBLIC_APP_URL=https://yourdomain.com
--------------------------------------------------------------------------------
/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/file.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/jobs/JobCardList.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { JobCard } from '@/components/jobs/JobCard';
4 | import type { Job } from '@/lib/db/airtable';
5 |
6 | export function JobCardList({ jobs }: { jobs: Job[] }) {
7 | return (
8 |
9 | {jobs.map((job) => (
10 |
11 | ))}
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/.cursor/rules/autofix.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: false
5 | ---
6 | Please use the Playwright MCP to open a browser to the URL I provide, check for any errors, and then automatically fix those errors in my codebase, then continue to iterate until all the errors are gone.
7 |
8 | Always check if there are any red buttons or other openable boxes that should pop open a dialog, which will reveal more errors for you to fix.
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # lockfiles
7 | package-lock.json
8 | yarn.lock
9 | pnpm-lock.yaml
10 | bun.lockb
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # misc
17 | .DS_Store
18 | *.pem
19 |
20 | # debug
21 | npm-debug.log*
22 |
23 | # env files
24 | .env*
25 | !.env.example
26 |
27 | # vercel
28 | .vercel
29 |
30 | # typescript
31 | *.tsbuildinfo
32 | next-env.d.ts
--------------------------------------------------------------------------------
/public/assets/social/linkedin.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils",
16 | "ui": "@/components/ui",
17 | "lib": "@/lib",
18 | "hooks": "@/hooks"
19 | },
20 | "iconLibrary": "lucide"
21 | }
22 |
--------------------------------------------------------------------------------
/public/assets/social/bluesky.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/lib/hooks/useJobsPerPage.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { parseAsInteger, useQueryState } from 'nuqs';
4 | import { DEFAULT_PER_PAGE } from '@/lib/constants/defaults';
5 |
6 | export function useJobsPerPage() {
7 | const [jobsPerPage, setJobsPerPage] = useQueryState(
8 | 'per_page',
9 | parseAsInteger.withDefault(DEFAULT_PER_PAGE)
10 | );
11 |
12 | return {
13 | jobsPerPage,
14 | setJobsPerPage: (value: number) => {
15 | // If value is the default, remove the parameter from URL
16 | setJobsPerPage(value === DEFAULT_PER_PAGE ? null : value);
17 | },
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/lib/utils/filter-jobs.ts:
--------------------------------------------------------------------------------
1 | import type { Job } from '@/lib/db/airtable';
2 |
3 | export function filterJobsBySearch(jobs: Job[], searchTerm: string): Job[] {
4 | if (!searchTerm) {
5 | return jobs;
6 | }
7 |
8 | const searchLower = searchTerm.toLowerCase();
9 |
10 | return jobs.filter(
11 | (job) =>
12 | job.title.toLowerCase().includes(searchLower) ||
13 | job.company.toLowerCase().includes(searchLower) ||
14 | (job.workplace_city?.toLowerCase() || '').includes(searchLower) ||
15 | (job.workplace_country?.toLowerCase() || '').includes(searchLower)
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/robots.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next';
2 | import config from '@/config';
3 |
4 | export default function robots(): MetadataRoute.Robots {
5 | // Use the URL from config
6 | const baseUrl = config.url;
7 |
8 | return {
9 | rules: {
10 | userAgent: '*',
11 | // Allow root path and /api/og/* paths for Open Graph images
12 | allow: ['/', '/api/og/*'],
13 | // Disallow any potential admin or private routes
14 | disallow: ['/api/subscribe/*', '/api/encharge-logs/*'],
15 | },
16 | sitemap: `${baseUrl}/sitemap.xml`,
17 | host: baseUrl,
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/components/jobs/CompactJobCardList.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { CompactJobCard } from '@/components/jobs/CompactJobCard';
4 | import type { Job } from '@/lib/db/airtable';
5 |
6 | export function CompactJobCardList({
7 | jobs,
8 | className = '',
9 | }: {
10 | jobs: Job[];
11 | className?: string;
12 | }) {
13 | return (
14 |
15 | {jobs.map((job, index) => (
16 |
17 |
18 |
19 | ))}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/lib/constants/job-types.ts:
--------------------------------------------------------------------------------
1 | import type { Job } from '@/lib/db/airtable';
2 |
3 | export type JobType = Job['type'];
4 |
5 | export const JOB_TYPE_DISPLAY_NAMES: Record = {
6 | 'Full-time': 'Full-time',
7 | 'Part-time': 'Part-time',
8 | Contract: 'Contract',
9 | Freelance: 'Freelance',
10 | } as const;
11 |
12 | export const JOB_TYPE_DESCRIPTIONS: Record = {
13 | 'Full-time': 'Permanent positions with standard working hours',
14 | 'Part-time': 'Positions with reduced or flexible hours',
15 | Contract: 'Fixed-term or project-based positions',
16 | Freelance: 'Self-employed or project-based contractual work',
17 | } as const;
18 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as LabelPrimitive from '@radix-ui/react-label';
2 | import * as React from 'react';
3 | import { cn } from '@/lib/utils';
4 |
5 | const Label = React.forwardRef<
6 | React.ElementRef,
7 | React.ComponentPropsWithoutRef
8 | >(({ className, ...props }, ref) => (
9 |
17 | ));
18 | Label.displayName = LabelPrimitive.Root.displayName;
19 |
20 | export { Label };
21 |
--------------------------------------------------------------------------------
/lib/constants/career-levels.ts:
--------------------------------------------------------------------------------
1 | import type { CareerLevel } from '@/lib/db/airtable';
2 |
3 | export const CAREER_LEVEL_DISPLAY_NAMES: Record = {
4 | Internship: 'Internship',
5 | EntryLevel: 'Entry Level',
6 | Associate: 'Associate',
7 | Junior: 'Junior',
8 | MidLevel: 'Mid Level',
9 | Senior: 'Senior',
10 | Staff: 'Staff',
11 | Principal: 'Principal',
12 | Lead: 'Lead',
13 | Manager: 'Manager',
14 | SeniorManager: 'Senior Manager',
15 | Director: 'Director',
16 | SeniorDirector: 'Senior Director',
17 | VP: 'VP',
18 | SVP: 'SVP',
19 | EVP: 'EVP',
20 | CLevel: 'C-Level',
21 | Founder: 'Founder',
22 | NotSpecified: 'Not Specified',
23 | } as const;
24 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | | Version | Supported |
6 | | ------- | ------------------ |
7 | | 1.0.x | :white_check_mark: |
8 |
9 | ## Reporting a Vulnerability
10 |
11 | If you discover a security vulnerability, please follow these steps:
12 |
13 | 1. **Do Not** open a public issue
14 | 2. Email security details to support@bordful.com
15 | 3. Include:
16 | - Description of the vulnerability
17 | - Steps to reproduce
18 | - Potential impact
19 | - Suggested fix (if any)
20 |
21 | We will:
22 | - Acknowledge receipt within 48 hours
23 | - Provide an estimated timeline for a fix
24 | - Keep you updated on the progress
25 |
26 | Thank you for helping keep Bordful secure!
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | },
24 | "strictNullChecks": true
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Home } from 'lucide-react';
2 | import Link from 'next/link';
3 | import { Button } from '@/components/ui/button';
4 |
5 | export default function NotFound() {
6 | return (
7 |
8 |
404
9 |
Page Not Found
10 |
11 | Sorry, we couldn't find the page you're looking for.
12 |
13 |
14 |
15 |
16 | Back to Home
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/components/jobs/JobListings.tsx:
--------------------------------------------------------------------------------
1 | import { JobCard } from '@/components/jobs/JobCard';
2 | import type { Job } from '@/lib/db/airtable';
3 |
4 | type JobListingsProps = {
5 | jobs: Job[];
6 | showFiltered?: boolean;
7 | };
8 |
9 | export function JobListings({ jobs, showFiltered = true }: JobListingsProps) {
10 | return (
11 |
12 | {showFiltered && (
13 |
14 | Showing {jobs.length.toLocaleString()}{' '}
15 | {jobs.length === 1 ? 'position' : 'positions'}
16 |
17 | )}
18 |
19 | {jobs.map((job) => (
20 |
21 | ))}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/lib/hooks/usePagination.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { parseAsInteger, useQueryState } from 'nuqs';
4 | import config from '@/config';
5 | import { DEFAULT_PER_PAGE } from '@/lib/constants/defaults';
6 |
7 | export function usePagination() {
8 | // Get default per page from config or fallback
9 | const defaultPerPage = config.jobListings?.defaultPerPage || DEFAULT_PER_PAGE;
10 |
11 | const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
12 |
13 | const [perPage, setPerPage] = useQueryState(
14 | 'per_page',
15 | parseAsInteger.withDefault(defaultPerPage)
16 | );
17 |
18 | // Ensure page is at least 1
19 | const validPage = Math.max(1, page);
20 |
21 | return {
22 | page: validPage,
23 | setPage,
24 | perPage,
25 | setPerPage,
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/public/assets/social/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/lib/email/types.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Basic subscriber data structure
3 | */
4 | export type SubscriberData = {
5 | email: string;
6 | name?: string;
7 | ip?: string;
8 | metadata?: Record; // More specific type for metadata
9 | };
10 |
11 | /**
12 | * Common interface for all email providers
13 | */
14 | export type EmailProvider = {
15 | name: string;
16 | subscribe(data: SubscriberData): Promise<{
17 | success: boolean;
18 | error?: string;
19 | }>;
20 | };
21 |
22 | /**
23 | * Custom error class for email provider errors
24 | */
25 | export class EmailProviderError extends Error {
26 | constructor(
27 | message: string,
28 | public provider: string
29 | ) {
30 | super(message);
31 | this.name = 'EmailProviderError';
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/public/assets/social/twitter.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from '@/components/ui/toast';
11 | import { useToast } from '@/hooks/use-toast';
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast();
15 |
16 | return (
17 |
18 | {toasts.map(({ id, title, description, action, ...props }) => (
19 |
20 |
21 | {title && {title} }
22 | {description && {description} }
23 |
24 | {action}
25 |
26 |
27 | ))}
28 |
29 |
30 | );
31 | }
32 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 |
3 | import { cn } from '@/lib/utils';
4 |
5 | const Input = React.forwardRef>(
6 | ({ className, type, ...props }, ref) => {
7 | return (
8 |
17 | );
18 | }
19 | );
20 | Input.displayName = 'Input';
21 |
22 | export { Input };
23 |
--------------------------------------------------------------------------------
/app/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { HomePage } from '@/components/home/HomePage';
3 | import config from '@/config';
4 | import { getJobs } from '@/lib/db/airtable';
5 | import { generateMetadata } from '@/lib/utils/metadata';
6 |
7 | // Add metadata for SEO
8 | export const metadata: Metadata = generateMetadata({
9 | title: config.title,
10 | description: config.description,
11 | path: '/',
12 | openGraph: {
13 | type: 'website',
14 | images: [
15 | {
16 | url: '/api/og',
17 | width: 1200,
18 | height: 630,
19 | alt: `${config.title} - ${config.description}`,
20 | },
21 | ],
22 | },
23 | });
24 |
25 | // Revalidate every 5 minutes
26 | export const revalidate = 300;
27 |
28 | export default async function Home() {
29 | const jobs = await getJobs();
30 | return ;
31 | }
32 |
--------------------------------------------------------------------------------
/config/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Configuration Loader
3 | * ------------------
4 | * This module exports the configuration for the job board.
5 | *
6 | * Quick Start:
7 | * 1. Copy config.example.ts to config.ts
8 | * 2. Customize config.ts with your settings
9 | * 3. The app will use your custom configuration
10 | */
11 |
12 | import type { Config } from './config.example';
13 | import { config as exampleConfig } from './config.example';
14 |
15 | let customConfig: Partial | undefined;
16 |
17 | // Try to load custom config if it exists
18 | try {
19 | customConfig = require('./config').config;
20 | } catch {
21 | // No custom config found, will use example config
22 | }
23 |
24 | // Create the final config object, merging custom config if it exists
25 | const config: Config = {
26 | ...exampleConfig,
27 | ...(customConfig || {}),
28 | };
29 |
30 | export type { Config };
31 | export default config;
32 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "[javascript]": {
3 | "editor.defaultFormatter": "biomejs.biome"
4 | },
5 | "[typescript]": {
6 | "editor.defaultFormatter": "biomejs.biome"
7 | },
8 | "[javascriptreact]": {
9 | "editor.defaultFormatter": "biomejs.biome"
10 | },
11 | "[typescriptreact]": {
12 | "editor.defaultFormatter": "biomejs.biome"
13 | },
14 | "[json]": {
15 | "editor.defaultFormatter": "biomejs.biome"
16 | },
17 | "[jsonc]": {
18 | "editor.defaultFormatter": "biomejs.biome"
19 | },
20 | "[css]": {
21 | "editor.defaultFormatter": "biomejs.biome"
22 | },
23 | "[graphql]": {
24 | "editor.defaultFormatter": "biomejs.biome"
25 | },
26 | "typescript.tsdk": "node_modules/typescript/lib",
27 | "editor.formatOnSave": true,
28 | "editor.formatOnPaste": true,
29 | "emmet.showExpandedAbbreviation": "never",
30 | "editor.codeActionsOnSave": {
31 | "source.fixAll.biome": "explicit",
32 | "source.organizeImports.biome": "explicit"
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/public/globe.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/client-breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname } from 'next/navigation';
4 | import { PageBreadcrumb } from './server-breadcrumb';
5 |
6 | type ClientBreadcrumbProps = {
7 | /**
8 | * Optional dynamic data to override the automatically generated breadcrumb
9 | * Useful for job detail pages where the title comes from the job data
10 | */
11 | dynamicData?: {
12 | name: string;
13 | url: string;
14 | };
15 |
16 | /**
17 | * Optional className to apply to the breadcrumb container
18 | */
19 | className?: string;
20 | };
21 |
22 | /**
23 | * Client-side breadcrumb wrapper that gets the current pathname
24 | * and passes it to the server-side breadcrumb component
25 | */
26 | export function ClientBreadcrumb({
27 | dynamicData,
28 | className,
29 | }: ClientBreadcrumbProps) {
30 | const pathname = usePathname();
31 |
32 | return (
33 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/components/ui/collapsible-text.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 |
5 | type CollapsibleTextProps = {
6 | text: string;
7 | maxLength: number;
8 | };
9 |
10 | export function CollapsibleText({ text, maxLength }: CollapsibleTextProps) {
11 | const [isExpanded, setIsExpanded] = useState(false);
12 |
13 | // If text is shorter than maxLength, just return it
14 | if (text.length <= maxLength) {
15 | return {text}
;
16 | }
17 |
18 | const displayText = isExpanded
19 | ? text
20 | : `${text.substring(0, maxLength).trim()}...`;
21 |
22 | return (
23 |
24 |
{displayText}
25 |
setIsExpanded(!isExpanded)}
28 | >
29 | {isExpanded ? 'Show less' : 'Show more'}
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/lib/hooks/useSortOrder.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { parseAsString, useQueryState } from 'nuqs';
4 | import config from '@/config';
5 |
6 | export type SortOrder = 'newest' | 'oldest' | 'salary';
7 |
8 | export function useSortOrder() {
9 | // Get default sort order from config or fallback to "newest"
10 | const defaultSortOrder =
11 | (config.jobListings?.defaultSortOrder as SortOrder) || 'newest';
12 |
13 | const [sortOrder, setSortOrderState] = useQueryState(
14 | 'sort',
15 | parseAsString.withDefault(defaultSortOrder)
16 | );
17 |
18 | // Validate sort order to ensure it's one of the allowed values
19 | const validSortOrder =
20 | sortOrder === 'newest' || sortOrder === 'oldest' || sortOrder === 'salary'
21 | ? sortOrder
22 | : defaultSortOrder;
23 |
24 | // Handle setting sort order with proper null handling
25 | const setSortOrder = (value: SortOrder | null) => {
26 | setSortOrderState(value === null ? null : value);
27 | };
28 |
29 | return {
30 | sortOrder: validSortOrder as SortOrder,
31 | setSortOrder,
32 | };
33 | }
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Tomas Laurinavicius
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/config/README.md:
--------------------------------------------------------------------------------
1 | # Configuration System
2 |
3 | This directory contains the configuration system for the job board.
4 |
5 | ## Quick Start
6 |
7 | 1. Copy `config.example.ts` to `config.ts`
8 | 2. Customize `config.ts` with your settings
9 | 3. The app will use your custom configuration
10 |
11 | ## How It Works
12 |
13 | - The source repository only includes `config.example.ts` as a template
14 | - When you fork the repository, you can create your own `config.ts` based on the example
15 | - Your custom configuration will override the example configuration
16 | - This approach allows you to pull updates from the source repository without conflicts
17 |
18 | ## Customizing Your Configuration
19 |
20 | To customize your configuration:
21 |
22 | ```bash
23 | # Copy the example configuration
24 | cp config/config.example.ts config/config.ts
25 |
26 | # Edit the configuration with your settings
27 | # (Open config/config.ts in your editor)
28 | ```
29 |
30 | Then modify the values in `config.ts` to match your requirements.
31 |
32 | ## Configuration Options
33 |
34 | See `config.example.ts` for a complete list of configuration options and their descriptions.
--------------------------------------------------------------------------------
/components/jobs/JobSearch.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * @deprecated Use JobSearchInput component instead.
3 | * This component is maintained for backward compatibility.
4 | */
5 |
6 | 'use client';
7 |
8 | import { useEffect } from 'react';
9 | import { JobSearchInput } from '@/components/ui/job-search-input';
10 | import type { Job } from '@/lib/db/airtable';
11 | import { useJobSearch } from '@/lib/hooks/useJobSearch';
12 | import { filterJobsBySearch } from '@/lib/utils/filter-jobs';
13 |
14 | export function JobSearch({
15 | jobs,
16 | onSearch,
17 | }: {
18 | jobs: Job[];
19 | onSearch: (filtered: Job[]) => void;
20 | }) {
21 | const { searchTerm } = useJobSearch();
22 |
23 | // Filter jobs when search term changes
24 | useEffect(() => {
25 | const filtered = filterJobsBySearch(jobs, searchTerm || '');
26 | onSearch(filtered);
27 | }, [jobs, searchTerm, onSearch]);
28 |
29 | return (
30 |
31 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/public/assets/mastercard.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
2 | import { Check } from 'lucide-react';
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
22 |
23 |
24 |
25 | ));
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName;
27 |
28 | export { Checkbox };
29 |
--------------------------------------------------------------------------------
/public/assets/applepay.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as SwitchPrimitives from '@radix-ui/react-switch';
2 | import * as React from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from 'class-variance-authority';
2 | import type * as React from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const badgeVariants = cva(
7 | 'inline-flex items-center rounded-md border px-2.5 py-0.5 font-semibold text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
13 | secondary:
14 | 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
15 | destructive:
16 | 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
17 | outline: 'text-foreground',
18 | },
19 | },
20 | defaultVariants: {
21 | variant: 'default',
22 | },
23 | }
24 | );
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | );
34 | }
35 |
36 | export { Badge, badgeVariants };
37 |
--------------------------------------------------------------------------------
/app/jobs/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { Search } from 'lucide-react';
2 | import Link from 'next/link';
3 | import { Button } from '@/components/ui/button';
4 | import config from '@/config';
5 | import { resolveColor } from '@/lib/utils/colors';
6 |
7 | export default function JobNotFound() {
8 | return (
9 |
10 |
11 |
Job Not Found
12 |
13 | The job posting you're looking for doesn't exist, has been
14 | removed, or has expired.
15 |
16 |
17 | Companies may remove job listings when positions are filled or no
18 | longer available.
19 |
20 |
21 |
29 | Browse Jobs
30 |
31 |
32 |
33 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/components/ui/about-schema.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { FC } from 'react';
4 | import type { AboutPage, WithContext } from 'schema-dts';
5 | import config from '@/config';
6 |
7 | type AboutSchemaProps = {
8 | companyName?: string;
9 | description?: string;
10 | url?: string;
11 | logo?: string;
12 | };
13 |
14 | export const AboutSchema: FC = ({
15 | companyName = config.title,
16 | description = 'Learn about our mission to connect talented professionals with exciting career opportunities.',
17 | url = `${config.url}/about` || 'https://example.com/about',
18 | logo = config.nav?.logo?.enabled
19 | ? `${config.url}${config.nav.logo.src}`
20 | : undefined,
21 | }) => {
22 | // Create type-safe schema using schema-dts
23 | const aboutSchema: WithContext = {
24 | '@context': 'https://schema.org',
25 | '@type': 'AboutPage',
26 | name: `About ${companyName}`,
27 | description,
28 | mainEntity: {
29 | '@type': 'Organization',
30 | name: companyName,
31 | description,
32 | url: config.url,
33 | ...(logo && { logo }),
34 | },
35 | url,
36 | };
37 |
38 | return (
39 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/lib/utils/slugify.ts:
--------------------------------------------------------------------------------
1 | import {
2 | LEADING_DASHES_REGEX,
3 | TRAILING_DASHES_REGEX,
4 | } from '@/lib/constants/defaults';
5 |
6 | export function slugify(text: string | null | undefined): string {
7 | // Handle null, undefined, or empty values
8 | if (!text) {
9 | return '';
10 | }
11 |
12 | return text
13 | .toString()
14 | .toLowerCase()
15 | .trim()
16 | .replace(/\s+/g, '-') // Replace spaces with -
17 | .replace(/&/g, '-and-') // Replace & with 'and'
18 | .replace(/[^\w-]+/g, '') // Remove all non-word characters
19 | .replace(/--+/g, '-') // Replace multiple - with single -
20 | .replace(LEADING_DASHES_REGEX, '') // Trim - from start of text
21 | .replace(TRAILING_DASHES_REGEX, ''); // Trim - from end of text
22 | }
23 |
24 | export function generateJobSlug(
25 | title: string | null | undefined,
26 | company: string | null | undefined
27 | ): string {
28 | const titleSlug = slugify(title);
29 | const companySlug = slugify(company);
30 |
31 | // Handle cases where either title or company is missing
32 | if (!(titleSlug || companySlug)) {
33 | return 'job'; // Fallback for completely missing data
34 | }
35 | if (!titleSlug) {
36 | return companySlug;
37 | }
38 | if (!companySlug) {
39 | return titleSlug;
40 | }
41 |
42 | return `${titleSlug}-at-${companySlug}`;
43 | }
44 |
--------------------------------------------------------------------------------
/app/feed.xml/route.ts:
--------------------------------------------------------------------------------
1 | import config from '@/config';
2 | import { DEFAULT_DESCRIPTION_LENGTH } from '@/lib/constants/defaults';
3 | import {
4 | createFeed,
5 | type FeedConfig,
6 | isFeedEnabled,
7 | processJobsForFeed,
8 | } from '@/lib/utils/feed-utils';
9 |
10 | export const revalidate = 300; // 5 minutes, matching other dynamic routes
11 |
12 | export async function GET() {
13 | try {
14 | // Check if RSS feeds are enabled
15 | if (!isFeedEnabled(config.rssFeed, 'rss')) {
16 | return new Response('RSS feed not enabled', { status: 404 });
17 | }
18 |
19 | const baseUrl = config.url;
20 |
21 | // Create and configure feed
22 | const feedConfig: FeedConfig = config.rssFeed || { enabled: true };
23 | const feed = createFeed(baseUrl, feedConfig);
24 |
25 | // Use configured description length or default
26 | const descriptionLength =
27 | feedConfig.descriptionLength || DEFAULT_DESCRIPTION_LENGTH;
28 |
29 | // Process jobs and add them to feed
30 | await processJobsForFeed(feed, baseUrl, descriptionLength);
31 |
32 | // Return the feed as RSS XML
33 | return new Response(feed.rss2(), {
34 | headers: {
35 | 'Content-Type': 'application/rss+xml; charset=utf-8',
36 | },
37 | });
38 | } catch (_error) {
39 | return new Response('Error generating RSS feed', { status: 500 });
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/atom.xml/route.ts:
--------------------------------------------------------------------------------
1 | import config from '@/config';
2 | import { DEFAULT_DESCRIPTION_LENGTH } from '@/lib/constants/defaults';
3 | import {
4 | createFeed,
5 | type FeedConfig,
6 | isFeedEnabled,
7 | processJobsForFeed,
8 | } from '@/lib/utils/feed-utils';
9 |
10 | export const revalidate = 300; // 5 minutes, matching other dynamic routes
11 |
12 | export async function GET() {
13 | try {
14 | // Check if Atom feeds are enabled
15 | if (!isFeedEnabled(config.rssFeed, 'atom')) {
16 | return new Response('Atom feed not enabled', { status: 404 });
17 | }
18 |
19 | const baseUrl = config.url;
20 |
21 | // Create and configure feed
22 | const feedConfig: FeedConfig = config.rssFeed || { enabled: true };
23 | const feed = createFeed(baseUrl, feedConfig);
24 |
25 | // Use configured description length or default
26 | const descriptionLength =
27 | feedConfig.descriptionLength || DEFAULT_DESCRIPTION_LENGTH;
28 |
29 | // Process jobs and add them to feed
30 | await processJobsForFeed(feed, baseUrl, descriptionLength);
31 |
32 | // Return the feed as Atom XML
33 | return new Response(feed.atom1(), {
34 | headers: {
35 | 'Content-Type': 'application/atom+xml; charset=utf-8',
36 | },
37 | });
38 | } catch (_error) {
39 | return new Response('Error generating Atom feed', { status: 500 });
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/feed.json/route.ts:
--------------------------------------------------------------------------------
1 | import config from '@/config';
2 | import { DEFAULT_DESCRIPTION_LENGTH } from '@/lib/constants/defaults';
3 | import {
4 | createFeed,
5 | type FeedConfig,
6 | isFeedEnabled,
7 | processJobsForFeed,
8 | } from '@/lib/utils/feed-utils';
9 |
10 | export const revalidate = 300; // 5 minutes, matching other dynamic routes
11 |
12 | export async function GET() {
13 | try {
14 | // Check if JSON feeds are enabled
15 | if (!isFeedEnabled(config.rssFeed, 'json')) {
16 | return new Response('JSON feed not enabled', { status: 404 });
17 | }
18 |
19 | const baseUrl = config.url;
20 |
21 | // Create and configure feed
22 | const feedConfig: FeedConfig = config.rssFeed || { enabled: true };
23 | const feed = createFeed(baseUrl, feedConfig);
24 |
25 | // Use configured description length or default
26 | const descriptionLength =
27 | feedConfig.descriptionLength || DEFAULT_DESCRIPTION_LENGTH;
28 |
29 | // Process jobs and add them to feed
30 | await processJobsForFeed(feed, baseUrl, descriptionLength);
31 |
32 | // Return the feed as JSON
33 | return new Response(feed.json1(), {
34 | headers: {
35 | 'Content-Type': 'application/feed+json; charset=utf-8',
36 | },
37 | });
38 | } catch (_error) {
39 | return new Response('Error generating JSON feed', { status: 500 });
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/docs/_template.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Document Title
3 | description: Brief description of what this document covers and why it's useful.
4 | lastUpdated: "YYYY-MM-DD"
5 | ---
6 |
7 | Brief introduction paragraph explaining the purpose of this document and what the reader will learn.
8 |
9 | ## 1. First Step
10 |
11 | Clear instructions for the first step with explanation:
12 |
13 | ```bash
14 | # Example command if applicable
15 | command --with-options
16 | ```
17 |
18 | ## 2. Second Step
19 |
20 | Instructions for the second step:
21 |
22 | ```typescript
23 | // Code example if applicable
24 | interface Example {
25 | property: string;
26 | anotherProperty: number;
27 | }
28 | ```
29 |
30 | ## 3. Third Step
31 |
32 | Instructions for the third step with additional details:
33 |
34 | - Bullet point 1
35 | - Bullet point 2
36 | - Bullet point 3
37 |
38 | ## 4. Fourth Step
39 |
40 | Final step instructions with any necessary details:
41 |
42 | ```env
43 | # Example configuration if applicable
44 | KEY=value
45 | ANOTHER_KEY=another_value
46 | ```
47 |
48 | ## Next Steps
49 |
50 | Now that you've completed this guide, you can:
51 |
52 | - Suggestion for next action
53 | - Another possible next step
54 | - Additional feature to explore
55 |
56 | Check out these related guides for more information:
57 |
58 | - [Related Guide 1](/docs/path/to/guide1)
59 | - [Related Guide 2](/docs/path/to/guide2)
60 | - [Related Guide 3](/docs/path/to/guide3)
--------------------------------------------------------------------------------
/lib/utils/image-utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Fetch an image and convert it to a data URI string
3 | */
4 | export async function fetchImageAsDataURI(
5 | imageUrl: string,
6 | baseUrl: string
7 | ): Promise {
8 | try {
9 | // Handle relative URLs
10 | const fullUrl = imageUrl.startsWith('http')
11 | ? imageUrl
12 | : `${baseUrl}${imageUrl}`;
13 |
14 | const response = await fetch(fullUrl);
15 | if (!response.ok) {
16 | return '';
17 | }
18 |
19 | const contentType = response.headers.get('content-type') || 'image/png';
20 | const imageData = await response.arrayBuffer();
21 | const base64 = Buffer.from(imageData).toString('base64');
22 |
23 | return `data:${contentType};base64,${base64}`;
24 | } catch (error: unknown) {
25 | const errorMessage = error instanceof Error ? error.message : String(error);
26 | console.error('Error fetching image:', errorMessage);
27 | return '';
28 | }
29 | }
30 |
31 | /**
32 | * Validate and process logo configuration
33 | */
34 | export function processLogoConfig(logoConfig: any, baseUrl: string) {
35 | if (!(logoConfig?.show && logoConfig.src)) {
36 | return null;
37 | }
38 |
39 | return {
40 | src: logoConfig.src.startsWith('http')
41 | ? logoConfig.src
42 | : `${baseUrl}${logoConfig.src}`,
43 | width: logoConfig.width || 100,
44 | height: logoConfig.height || 100,
45 | position: logoConfig.position || { top: 50, left: 50 },
46 | };
47 | }
48 |
--------------------------------------------------------------------------------
/lib/constants/workplace.ts:
--------------------------------------------------------------------------------
1 | import type { Country } from './countries';
2 |
3 | export type WorkplaceType = 'On-site' | 'Hybrid' | 'Remote' | 'Not specified';
4 |
5 | export const workplaceTypes: WorkplaceType[] = [
6 | 'On-site',
7 | 'Hybrid',
8 | 'Remote',
9 | 'Not specified',
10 | ] as const;
11 |
12 | export type RemoteRegion =
13 | | 'Worldwide'
14 | | 'Americas Only'
15 | | 'Europe Only'
16 | | 'Asia-Pacific Only'
17 | | 'US Only'
18 | | 'EU Only'
19 | | 'UK/EU Only'
20 | | 'US/Canada Only'
21 | | null;
22 |
23 | export const remoteRegions: RemoteRegion[] = [
24 | 'Worldwide',
25 | 'Americas Only',
26 | 'Europe Only',
27 | 'Asia-Pacific Only',
28 | 'US Only',
29 | 'EU Only',
30 | 'UK/EU Only',
31 | 'US/Canada Only',
32 | ] as const;
33 |
34 | export type WorkplaceSettings = {
35 | workplace_type: WorkplaceType;
36 | remote_region: RemoteRegion;
37 | timezone_requirements: string | null;
38 | workplace_city: string | null;
39 | workplace_country: Country | null;
40 | };
41 |
42 | export type RemoteFriendly = 'Yes' | 'No' | 'Hybrid' | 'Not specified';
43 |
44 | export function getRemoteFriendlyStatus(
45 | settings: Pick
46 | ): RemoteFriendly {
47 | switch (settings.workplace_type) {
48 | case 'Remote':
49 | return 'Yes';
50 | case 'Hybrid':
51 | return 'Hybrid';
52 | case 'On-site':
53 | return 'No';
54 | default:
55 | return 'Not specified';
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/public/assets/visa.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import typography from '@tailwindcss/typography';
2 | import type { Config } from 'tailwindcss';
3 |
4 | const config: Config = {
5 | content: [
6 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
7 | './components/**/*.{js,ts,jsx,tsx,mdx}',
8 | './app/**/*.{js,ts,jsx,tsx,mdx}',
9 | ],
10 | theme: {
11 | extend: {
12 | fontFamily: {
13 | sans: [
14 | 'var(--font-geist-sans)',
15 | 'var(--font-inter)',
16 | 'system-ui',
17 | 'sans-serif',
18 | ],
19 | serif: [
20 | 'var(--font-ibm-plex-serif)',
21 | 'Georgia',
22 | 'Times New Roman',
23 | 'serif',
24 | ],
25 | mono: ['var(--font-geist-mono)', 'monospace'],
26 | },
27 | container: {
28 | center: true,
29 | padding: '1rem',
30 | screens: {
31 | '2xl': '1100px',
32 | },
33 | },
34 | keyframes: {
35 | 'accordion-down': {
36 | from: { height: '0' },
37 | to: { height: 'var(--radix-accordion-content-height)' },
38 | },
39 | 'accordion-up': {
40 | from: { height: 'var(--radix-accordion-content-height)' },
41 | to: { height: '0' },
42 | },
43 | },
44 | animation: {
45 | 'accordion-down': 'accordion-down 0.2s ease-out',
46 | 'accordion-up': 'accordion-up 0.2s ease-out',
47 | },
48 | },
49 | },
50 | plugins: [
51 | typography,
52 | // ... other plugins
53 | ],
54 | };
55 |
56 | export default config;
57 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
4 | import * as React from 'react';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ));
21 | Avatar.displayName = AvatarPrimitive.Root.displayName;
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ));
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ));
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
49 |
50 | export { Avatar, AvatarImage, AvatarFallback };
51 |
--------------------------------------------------------------------------------
/public/assets/amex.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/contact-schema.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { FC } from 'react';
4 | import type { ContactPage, WithContext } from 'schema-dts';
5 | import config from '@/config';
6 |
7 | type ContactSchemaProps = {
8 | companyName?: string;
9 | email?: string;
10 | phone?: string;
11 | address?: string;
12 | url?: string;
13 | description?: string;
14 | };
15 |
16 | export const ContactSchema: FC = ({
17 | companyName = config.contact?.contactInfo?.companyName || config.title,
18 | email = config.contact?.contactInfo?.email || 'contact@example.com',
19 | phone = config.contact?.contactInfo?.phone || '+1-555-123-4567',
20 | address = config.contact?.contactInfo?.address ||
21 | '123 Main St, Anytown, AN 12345',
22 | url = `${config.url}/contact` || 'https://example.com/contact',
23 | description = config.contact?.schema?.description ||
24 | config.contact?.description ||
25 | 'Get in touch with our team for any questions or support needs.',
26 | }) => {
27 | // Create type-safe schema using schema-dts
28 | const contactSchema: WithContext = {
29 | '@context': 'https://schema.org',
30 | '@type': 'ContactPage',
31 | name: `Contact ${companyName}`,
32 | description,
33 | mainEntity: {
34 | '@type': 'Organization',
35 | name: companyName,
36 | email,
37 | telephone: phone,
38 | address: {
39 | '@type': 'PostalAddress',
40 | streetAddress: address,
41 | },
42 | url: config.url,
43 | },
44 | url,
45 | };
46 |
47 | return (
48 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Job Board Starter
2 |
3 | Thank you for your interest in contributing! Here's how you can help:
4 |
5 | ## Getting Started
6 |
7 | 1. Fork the repo
8 | 2. Clone it to your machine
9 | 3. Create a new branch:
10 | ```bash
11 | git checkout -b feature/your-feature-name
12 | ```
13 |
14 | 4. Make your changes
15 | 5. Run linting: `bun run lint`
16 | 6. Commit your changes: `git commit -m 'Add some feature'`
17 | 7. Push to the branch: `git push origin feature/your-feature-name`
18 | 8. Submit a pull request
19 |
20 | ## Development Setup
21 |
22 | 1. Install dependencies:
23 | ```bash
24 | # Install dependencies
25 | bun install
26 | ```
27 |
28 | 2. Set up Airtable:
29 | - Create a base with a "Jobs" table
30 | - Get your Personal Access Token
31 | - Add required scopes (data.records:read, schema.bases:read)
32 |
33 | 3. Create a `.env` file:
34 | ```env
35 | AIRTABLE_ACCESS_TOKEN=your_token_here
36 | AIRTABLE_BASE_ID=your_base_id_here
37 | ```
38 |
39 | 4. Run the development server:
40 | ```bash
41 | # Start the development server
42 | bun run dev
43 | ```
44 |
45 | ## Project Structure
46 |
47 | ```
48 | app/
49 | layout.tsx # Root layout with Geist font
50 | page.tsx # Home page with job listings
51 | jobs/
52 | [id]/
53 | page.tsx # Individual job page
54 | lib/
55 | db/
56 | airtable.ts # Airtable integration
57 | utils/
58 | formatDate.ts # Date formatting utilities
59 | components/
60 | jobs/
61 | JobCard.tsx # Job listing card
62 | JobSearch.tsx # Search component
63 | ```
64 |
65 | ## Code Style
66 |
67 | - Use TypeScript
68 | - Follow Ultracite rules
69 | - Use double quotes for strings
70 | - Use semicolons
71 | - Write meaningful commit messages
72 |
73 | Thank you for contributing!
--------------------------------------------------------------------------------
/lib/utils/formatDate.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SECONDS_PER_DAY,
3 | SECONDS_PER_HOUR,
4 | SECONDS_PER_MINUTE,
5 | SECONDS_PER_MONTH,
6 | } from '@/lib/constants/defaults';
7 |
8 | export function formatDate(dateString: string | null | undefined) {
9 | // Handle null, undefined, or empty date strings
10 | if (!dateString) {
11 | return {
12 | fullDate: 'Date not available',
13 | relativeTime: 'Date not available',
14 | };
15 | }
16 |
17 | const date = new Date(dateString);
18 |
19 | // Check if the date is valid
20 | if (Number.isNaN(date.getTime())) {
21 | return { fullDate: 'Invalid date', relativeTime: 'Invalid date' };
22 | }
23 |
24 | // Format full date like "Dec 10, 2024"
25 | const fullDate = date.toLocaleDateString('en-US', {
26 | year: 'numeric',
27 | month: 'short',
28 | day: 'numeric',
29 | });
30 |
31 | // Calculate relative time
32 | const now = new Date();
33 | const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
34 |
35 | let relativeTime;
36 | if (diffInSeconds < SECONDS_PER_MINUTE) {
37 | relativeTime = 'just now';
38 | } else if (diffInSeconds < SECONDS_PER_HOUR) {
39 | const minutes = Math.floor(diffInSeconds / SECONDS_PER_MINUTE);
40 | relativeTime = `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`;
41 | } else if (diffInSeconds < SECONDS_PER_DAY) {
42 | const hours = Math.floor(diffInSeconds / SECONDS_PER_HOUR);
43 | relativeTime = `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`;
44 | } else if (diffInSeconds < SECONDS_PER_MONTH) {
45 | const days = Math.floor(diffInSeconds / SECONDS_PER_DAY);
46 | relativeTime = `${days} ${days === 1 ? 'day' : 'days'} ago`;
47 | } else {
48 | relativeTime = fullDate;
49 | }
50 |
51 | return { fullDate, relativeTime };
52 | }
53 |
--------------------------------------------------------------------------------
/public/assets/social/reddit.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/lib/utils/job-validation.ts:
--------------------------------------------------------------------------------
1 | import { getJobs } from '@/lib/db/airtable';
2 | import { generateJobSlug } from '@/lib/utils/slugify';
3 |
4 | export type MinimalJob = {
5 | title: string;
6 | company: string;
7 | type: string;
8 | workplace_type: string;
9 | };
10 |
11 | /**
12 | * Find job by slug from all jobs
13 | */
14 | export async function getJobBySlugMinimal(
15 | slug: string
16 | ): Promise {
17 | try {
18 | const jobs = await getJobs();
19 |
20 | for (const job of jobs) {
21 | if (job.status === 'active') {
22 | const jobSlug = generateJobSlug(job.title, job.company);
23 | if (jobSlug === slug) {
24 | return {
25 | title: job.title,
26 | company: job.company,
27 | type: job.type,
28 | workplace_type: job.workplace_type,
29 | };
30 | }
31 | }
32 | }
33 |
34 | return null;
35 | } catch (error) {
36 | console.error('Error finding job by slug:', error);
37 | return null;
38 | }
39 | }
40 |
41 | /**
42 | * Validate job parameters and fetch job data
43 | */
44 | export async function validateJobAndParams(context: {
45 | params: { slug: string };
46 | }): Promise<{ job: MinimalJob } | Response> {
47 | try {
48 | const params = await context.params;
49 | const { slug } = params;
50 |
51 | if (!slug || typeof slug !== 'string') {
52 | return new Response('Invalid slug parameter', { status: 400 });
53 | }
54 |
55 | const job = await getJobBySlugMinimal(slug);
56 | if (!job) {
57 | return new Response(`Job not found: ${slug}`, { status: 404 });
58 | }
59 |
60 | return { job };
61 | } catch (error) {
62 | console.error('Error validating job parameters:', error);
63 | return new Response('Invalid request parameters', { status: 400 });
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/lib/hooks/useJobSearch.ts:
--------------------------------------------------------------------------------
1 | import { parseAsString, useQueryState } from 'nuqs';
2 | import { useCallback, useEffect, useRef, useState } from 'react';
3 | import config from '@/config';
4 |
5 | export function useJobSearch() {
6 | const [searchTerm, setSearchTermState] = useQueryState(
7 | 'q',
8 | parseAsString.withDefault('')
9 | );
10 | const [isSearching, setIsSearching] = useState(false);
11 | const timerRef = useRef(null);
12 |
13 | // Get debounce time from config or use default
14 | const debounceMs = config.search?.debounceMs || 500;
15 |
16 | // Clear timeout on unmount
17 | useEffect(() => {
18 | return () => {
19 | if (timerRef.current) {
20 | clearTimeout(timerRef.current);
21 | }
22 | };
23 | }, []);
24 |
25 | // Handle search with debounce
26 | const handleSearch = useCallback(
27 | (value: string) => {
28 | // Clear any existing timer
29 | if (timerRef.current) {
30 | clearTimeout(timerRef.current);
31 | }
32 |
33 | // Only show searching indicator for non-empty searches
34 | if (value) {
35 | setIsSearching(true);
36 | }
37 |
38 | // Set a new timer
39 | timerRef.current = setTimeout(() => {
40 | setSearchTermState(value || null);
41 | setIsSearching(false);
42 | timerRef.current = null;
43 | }, debounceMs); // Use configurable debounce time
44 | },
45 | [setSearchTermState, debounceMs]
46 | );
47 |
48 | // Clear search
49 | const clearSearch = useCallback(() => {
50 | // Cancel any pending search
51 | if (timerRef.current) {
52 | clearTimeout(timerRef.current);
53 | timerRef.current = null;
54 | }
55 | setIsSearching(false);
56 | setSearchTermState(null);
57 | }, [setSearchTermState]);
58 |
59 | return {
60 | searchTerm,
61 | isSearching,
62 | handleSearch,
63 | clearSearch,
64 | };
65 | }
66 |
--------------------------------------------------------------------------------
/app/api/og/route.tsx:
--------------------------------------------------------------------------------
1 | import config from '@/config';
2 | import {
3 | isOGEnabled,
4 | type OGConfig,
5 | parseOGConfig,
6 | } from '@/lib/utils/og-config';
7 | import {
8 | createOGImageResponse,
9 | type LogoConfig,
10 | prepareBackgroundImage,
11 | prepareOGAssets,
12 | processLogo,
13 | } from '@/lib/utils/og-helpers';
14 |
15 | // Specify that this route should run on Vercel's edge runtime
16 | export const runtime = 'edge';
17 |
18 | /**
19 | * Generate a dynamic Open Graph image based on the site configuration
20 | * using dynamically fetched Google Fonts.
21 | * @returns {Promise} The generated image response or an error response
22 | */
23 | export async function GET() {
24 | // Get and type-check the OG configuration
25 | const ogConfig: OGConfig = config.og || {};
26 |
27 | // Check if OG image generation is enabled
28 | if (!isOGEnabled(ogConfig)) {
29 | return new Response('OG image generation is disabled in config', {
30 | status: 404,
31 | });
32 | }
33 |
34 | // Parse configuration with fallbacks
35 | const parsedConfig = parseOGConfig(ogConfig);
36 |
37 | try {
38 | // Prepare all assets (fonts, etc.)
39 | const { fontFamilyCSS, imageResponseFonts } =
40 | await prepareOGAssets(parsedConfig);
41 |
42 | // Prepare background image
43 | const bgImageDataUri = await prepareBackgroundImage(
44 | parsedConfig.backgroundImage
45 | );
46 |
47 | // Process logo
48 | const logoConfig: LogoConfig = ogConfig.logo || {};
49 | const processedLogo = await processLogo(logoConfig);
50 |
51 | // Generate and return the image
52 | return await createOGImageResponse(
53 | parsedConfig,
54 | fontFamilyCSS,
55 | imageResponseFonts,
56 | bgImageDataUri,
57 | processedLogo
58 | );
59 | } catch (_error: unknown) {
60 | return new Response('Error generating OG image', { status: 500 });
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/lib/utils/fonts.ts:
--------------------------------------------------------------------------------
1 | import { GeistMono, GeistSans } from 'geist/font';
2 | import { IBM_Plex_Serif, Inter } from 'next/font/google';
3 |
4 | // Export Geist fonts (self-hosted)
5 | export const geistSans = GeistSans;
6 | export const geistMono = GeistMono;
7 |
8 | // Load Google fonts - these are loaded at build time regardless of configuration
9 | // We don't import config here to avoid circular dependencies
10 | export const inter = Inter({
11 | subsets: ['latin'],
12 | display: 'swap',
13 | variable: '--font-inter',
14 | weight: ['400', '500', '600', '700'],
15 | preload: true,
16 | });
17 |
18 | // Load IBM Plex Serif font with improved config
19 | export const ibmPlexSerif = IBM_Plex_Serif({
20 | subsets: ['latin'],
21 | display: 'swap',
22 | variable: '--font-ibm-plex-serif',
23 | weight: ['400', '500', '600', '700'],
24 | preload: true,
25 | fallback: ['Georgia', 'Times New Roman', 'serif'],
26 | });
27 |
28 | // Helper function to get the appropriate font class based on config
29 | export function getFontClass(fontFamily = 'geist') {
30 | try {
31 | switch (fontFamily) {
32 | case 'inter':
33 | return inter.variable;
34 | case 'ibm-plex-serif':
35 | return ibmPlexSerif.variable;
36 | default:
37 | return geistSans.variable;
38 | }
39 | } catch {
40 | // Fallback to Geist Sans if config is not available
41 | return geistSans.variable;
42 | }
43 | }
44 |
45 | // Helper function to get appropriate CSS class for body
46 | export function getBodyClass(fontFamily = 'geist') {
47 | try {
48 | if (fontFamily === 'ibm-plex-serif') {
49 | return 'font-serif';
50 | }
51 | return '';
52 | } catch {
53 | return '';
54 | }
55 | }
56 |
57 | // Export all configured fonts together
58 | export const fonts = {
59 | inter,
60 | ibmPlexSerif,
61 | geistSans,
62 | geistMono,
63 | getFontClass,
64 | getBodyClass,
65 | };
66 |
67 | export default fonts;
68 |
--------------------------------------------------------------------------------
/lib/utils/rss.ts:
--------------------------------------------------------------------------------
1 | import { Feed } from 'feed';
2 | import { DEFAULT_DESCRIPTION_LENGTH } from '@/lib/constants/defaults';
3 | import { getJobs } from '@/lib/db/airtable';
4 | import { generateJobSlug } from '@/lib/utils/slugify';
5 |
6 | export type RSSFeedOptions = {
7 | baseUrl: string;
8 | feedTitle: string;
9 | feedDescription: string;
10 | feedLink: string;
11 | descriptionLength?: number;
12 | };
13 |
14 | export async function generateRSSFeed(options: RSSFeedOptions): Promise {
15 | const {
16 | baseUrl,
17 | feedTitle,
18 | feedDescription,
19 | feedLink,
20 | descriptionLength = DEFAULT_DESCRIPTION_LENGTH,
21 | } = options;
22 |
23 | const feed = new Feed({
24 | title: feedTitle,
25 | description: feedDescription,
26 | id: baseUrl,
27 | link: baseUrl,
28 | language: 'en',
29 | favicon: `${baseUrl}/favicon.ico`,
30 | copyright: `All rights reserved ${new Date().getFullYear()}`,
31 | generator: 'bordful',
32 | feedLinks: {
33 | rss: feedLink,
34 | },
35 | });
36 |
37 | // Get jobs and add them to the feed
38 | const jobs = await getJobs();
39 |
40 | for (const job of jobs) {
41 | // Only include active jobs
42 | if (job.status === 'active') {
43 | const jobSlug = generateJobSlug(job.title, job.company);
44 | const jobUrl = `${baseUrl}/jobs/${jobSlug}`;
45 |
46 | feed.addItem({
47 | title: `${job.title} at ${job.company}`,
48 | id: jobUrl,
49 | link: jobUrl,
50 | description: job.description?.substring(0, descriptionLength) || '',
51 | content: job.description || '',
52 | author: [
53 | {
54 | name: job.company,
55 | link: job.companyWebsite || undefined,
56 | },
57 | ],
58 | date: new Date(job.createdTime || Date.now()),
59 | category: job.type ? [{ name: job.type }] : undefined,
60 | });
61 | }
62 | }
63 |
64 | return feed;
65 | }
66 |
--------------------------------------------------------------------------------
/docs/guides/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Guides
3 | description: Comprehensive guides for configuring and customizing your Bordful job board.
4 | lastUpdated: "2025-05-22"
5 | ---
6 |
7 | # Bordful Guides
8 |
9 | Welcome to the Bordful guides section. These detailed guides will help you configure and customize your job board to match your specific requirements.
10 |
11 | ## Available Guides
12 |
13 | ### Customization
14 |
15 | - [Customization](/docs/guides/customization.md) - Comprehensive guide to customizing your job board's appearance and functionality
16 | - [Hero Section](/docs/guides/hero-section.md) - Customize the hero section with various background styles and content
17 | - [Navigation](/docs/guides/navigation.md) - Configure your site's main navigation bar and menu structure
18 | - [Footer](/docs/guides/footer.md) - Customize the footer layout, links, and content
19 |
20 | ### Features
21 |
22 | - [Job Alerts](/docs/guides/job-alerts.md) - Set up and configure job alert subscriptions for your users
23 | - [Pricing Page](/docs/guides/pricing.md) - Customize the pricing page to showcase your job posting plans
24 | - [Contact Page](/docs/guides/contact.md) - Configure your contact page with support channels and information
25 | - [Post Job Banner](/docs/guides/post-job-banner.md) - Customize the sidebar banner for job posting promotion
26 |
27 | ### Integrations
28 |
29 | - [Email Integration](/docs/guides/email-integration.md) - Connect your job board to email providers for newsletters and alerts
30 |
31 | ## Upcoming Guides
32 |
33 | We're working on additional guides to cover more aspects of Bordful:
34 |
35 | - Job Listings Customization
36 | - Filtering System Configuration
37 | - Advanced SEO Optimization
38 | - User Experience Enhancements
39 |
40 | ## How to Use These Guides
41 |
42 | Each guide provides:
43 |
44 | 1. Step-by-step instructions
45 | 2. Configuration examples
46 | 3. Best practices
47 | 4. Troubleshooting tips
48 |
49 | For general setup instructions, please refer to the [Getting Started](/docs/getting-started/index.md) section first.
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bordful",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "bun --bun next dev --turbopack",
7 | "build": "bun --bun next build",
8 | "start": "bun --bun next start",
9 | "lint": "bunx ultracite lint",
10 | "format": "bunx ultracite format"
11 | },
12 | "dependencies": {
13 | "@fontsource/ibm-plex-serif": "^5.2.6",
14 | "@fontsource/inter": "^5.2.6",
15 | "@radix-ui/react-accordion": "^1.2.12",
16 | "@radix-ui/react-avatar": "^1.1.10",
17 | "@radix-ui/react-checkbox": "^1.3.3",
18 | "@radix-ui/react-dropdown-menu": "^2.1.16",
19 | "@radix-ui/react-label": "^2.1.7",
20 | "@radix-ui/react-select": "^2.2.6",
21 | "@radix-ui/react-slot": "^1.2.3",
22 | "@radix-ui/react-switch": "^1.2.6",
23 | "@radix-ui/react-toast": "^1.2.15",
24 | "airtable": "^0.12.2",
25 | "axios": "^1.11.0",
26 | "class-variance-authority": "^0.7.1",
27 | "clsx": "^2.1.1",
28 | "date-fns": "^4.1.0",
29 | "dotenv": "^17.2.1",
30 | "feed": "^5.1.0",
31 | "geist": "^1.4.2",
32 | "lucide-react": "^0.542.0",
33 | "next": "^15.5.2",
34 | "node-fetch": "^3.3.2",
35 | "nuqs": "^2.5.2",
36 | "react": "^19.1.1",
37 | "react-dom": "^19.1.1",
38 | "react-markdown": "^10.1.0",
39 | "remark-breaks": "^4.0.0",
40 | "remark-gfm": "^4.0.1",
41 | "remark-parse": "^11.0.0",
42 | "remark-stringify": "^11.0.0",
43 | "schema-dts": "^1.1.5",
44 | "tailwind-merge": "^3.3.1",
45 | "tailwindcss-animate": "^1.0.7",
46 | "unified": "^11.0.5"
47 | },
48 | "devDependencies": {
49 | "@biomejs/biome": "2.2.2",
50 | "@tailwindcss/forms": "^0.5.10",
51 | "@tailwindcss/typography": "^0.5.16",
52 | "@types/airtable": "^0.10.5",
53 | "@types/node": "^22.18.0",
54 | "@types/react": "^19.1.12",
55 | "@types/react-dom": "^19.1.9",
56 | "autoprefixer": "^10.4.21",
57 | "postcss": "^8.5.6",
58 | "tailwindcss": "^3.4.17",
59 | "typescript": "^5.9.2",
60 | "ultracite": "^5.2.17"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/components/ui/metadata-breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { ServerBreadcrumb } from '@/components/ui/breadcrumb';
3 |
4 | type MetadataBreadcrumbProps = {
5 | /**
6 | * The page metadata to extract breadcrumb information from
7 | */
8 | metadata: Metadata;
9 |
10 | /**
11 | * Current pathname to determine the active breadcrumb
12 | */
13 | pathname: string;
14 |
15 | /**
16 | * Optional className to apply to the breadcrumb container
17 | */
18 | className?: string;
19 |
20 | /**
21 | * Optional explicit breadcrumb items to use instead of generating from metadata
22 | */
23 | items?: { name: string; url: string }[];
24 | };
25 |
26 | /**
27 | * Extracts breadcrumb items from page metadata
28 | */
29 | function extractBreadcrumbsFromMetadata(
30 | metadata: Metadata,
31 | pathname: string
32 | ): { name: string; url: string }[] {
33 | // Start with home
34 | const breadcrumbs: { name: string; url: string }[] = [
35 | { name: 'Home', url: '/' },
36 | ];
37 |
38 | // Get title from metadata
39 | let title = '';
40 | if (typeof metadata.title === 'string') {
41 | title = metadata.title;
42 | } else if (metadata.title && 'default' in metadata.title) {
43 | title = metadata.title.default as string;
44 | }
45 |
46 | // If we have a title, add the current page
47 | if (title) {
48 | // Remove any site suffix (e.g., " | My Site")
49 | const cleanedTitle = title.split('|')[0].trim();
50 | breadcrumbs.push({
51 | name: cleanedTitle,
52 | url: pathname,
53 | });
54 | }
55 |
56 | return breadcrumbs;
57 | }
58 |
59 | /**
60 | * Server component that generates breadcrumbs from page metadata
61 | */
62 | export function MetadataBreadcrumb({
63 | metadata,
64 | pathname,
65 | className,
66 | items: explicitItems,
67 | }: MetadataBreadcrumbProps) {
68 | // Use provided items or extract from metadata
69 | const breadcrumbItems =
70 | explicitItems || extractBreadcrumbsFromMetadata(metadata, pathname);
71 |
72 | return ;
73 | }
74 |
--------------------------------------------------------------------------------
/components/contact/ContactInfoSection.tsx:
--------------------------------------------------------------------------------
1 | import { Mail, MapPin, Phone } from 'lucide-react';
2 | // No card components needed
3 | import config from '@/config';
4 | import { resolveColor } from '@/lib/utils/colors';
5 |
6 | type ContactInfoSectionProps = {
7 | title: string;
8 | description: string;
9 | companyName: string;
10 | email: string;
11 | phone: string;
12 | address: string;
13 | };
14 |
15 | export function ContactInfoSection({
16 | title,
17 | description,
18 | companyName,
19 | email,
20 | phone,
21 | address,
22 | }: ContactInfoSectionProps) {
23 | const primaryColor = resolveColor(config.ui.primaryColor);
24 |
25 | return (
26 |
27 |
28 |
{title}
29 |
{description}
30 |
31 |
32 |
33 |
{companyName}
34 |
35 |
45 |
46 |
56 |
57 |
58 |
59 | {address}
60 |
61 |
62 |
63 |
64 | );
65 | }
66 |
--------------------------------------------------------------------------------
/docs/integrations/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Bordful Integrations
3 | description: Connect your Bordful job board with third-party services and platforms.
4 | lastUpdated: "2025-05-22"
5 | ---
6 |
7 | Bordful supports integration with various third-party services to extend functionality and enhance your job board. This section covers all available integrations and how to set them up.
8 |
9 | ## Available Integrations
10 |
11 | Bordful currently supports the following integrations:
12 |
13 | ### Email Providers
14 |
15 | - [Encharge](/docs/integrations/encharge.md) - Marketing automation platform for email campaigns
16 | - [Email Providers Documentation](/docs/integrations/email-providers.md) - Comprehensive documentation for all supported email providers
17 |
18 | ### Analytics Platforms
19 |
20 | - [Analytics Platform Integrations](/docs/integrations/analytics-platforms.md) - Documentation for integrating with analytics services
21 |
22 | ### Third-Party Services
23 |
24 | - [Third-Party Service Integrations](/docs/integrations/third-party-services.md) - Guide to integrating with various third-party services
25 | - [Custom Integration Development](/docs/advanced/custom-integrations.md) - Guide to creating your own integrations
26 |
27 | ### Future Integrations
28 |
29 | Additional integrations are planned for future releases, including:
30 |
31 | - More email providers (Mailchimp, SendGrid)
32 | - Notification services
33 | - Automation tools
34 |
35 | ## Adding a New Integration
36 |
37 | If you want to integrate Bordful with a service not listed here, you have two options:
38 |
39 | 1. [Create a custom integration](/docs/advanced/custom-integrations.md)
40 | 2. [Contribute an integration](/docs/contributing/integration-contribution.md) to the Bordful project
41 |
42 | ## Integration Best Practices
43 |
44 | When integrating Bordful with third-party services:
45 |
46 | - Store API keys and secrets in environment variables
47 | - Use the configuration system for user-configurable options
48 | - Implement proper error handling and fallbacks
49 | - Follow the security guidelines in our [Security Best Practices](/docs/advanced/security.md) guide
--------------------------------------------------------------------------------
/public/assets/googlepay.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/docs/advanced/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Advanced Features
3 | description: Documentation for advanced features and configurations in Bordful job board.
4 | lastUpdated: "2025-05-22"
5 | ---
6 |
7 | # Advanced Bordful Features
8 |
9 | This section covers advanced features and configurations for the Bordful job board platform. These topics are intended for users who want to go beyond the basic setup and customization options.
10 |
11 | ## Available Documentation
12 |
13 | - [**Schema Implementation**](./schema-implementation.md) - Learn about the comprehensive Schema.org JobPosting structured data implementation for improved SEO and Google Jobs integration.
14 |
15 | - [**Rate Limiting**](./rate-limiting.md) - Understand how to configure and customize rate limiting for your API endpoints to protect against abuse.
16 |
17 | - [**Script Management & Analytics**](./script-management.md) - Discover how to manage scripts, integrate analytics, and optimize script loading performance.
18 |
19 | - [**Data Revalidation**](./data-revalidation.md) - Learn about Bordful's implementation of Next.js Incremental Static Regeneration (ISR) for data freshness and performance.
20 |
21 | ## Coming Soon
22 |
23 | - **API Endpoints** - Documentation for Bordful's API endpoints, authentication, and usage.
24 |
25 | - **Analytics Integration** - Guide for integrating various analytics platforms with your job board.
26 |
27 | - **Security Best Practices** - Comprehensive security guidelines for your Bordful installation.
28 |
29 | - **Performance Optimization** - Tips and techniques for optimizing your job board's performance.
30 |
31 | ## Who Should Read This Section?
32 |
33 | These advanced topics are particularly useful for:
34 |
35 | - Developers extending or customizing Bordful
36 | - SEO specialists optimizing job listings
37 | - Site administrators managing high-traffic job boards
38 | - Anyone looking to maximize performance and features
39 |
40 | ## Prerequisites
41 |
42 | Before diving into advanced topics, we recommend:
43 |
44 | 1. Having a working Bordful installation
45 | 2. Familiarity with the basic configuration options
46 | 3. Understanding of Next.js concepts (for some topics)
47 | 4. Basic knowledge of web performance and SEO principles
--------------------------------------------------------------------------------
/components/ui/card.tsx:
--------------------------------------------------------------------------------
1 | import type React from 'react';
2 | import { forwardRef } from 'react';
3 |
4 | import { cn } from '@/lib/utils';
5 |
6 | const Card = forwardRef>(
7 | ({ className, ...props }, ref) => (
8 |
16 | )
17 | );
18 | Card.displayName = 'Card';
19 |
20 | const CardHeader = forwardRef<
21 | HTMLDivElement,
22 | React.HTMLAttributes
23 | >(({ className, ...props }, ref) => (
24 |
29 | ));
30 | CardHeader.displayName = 'CardHeader';
31 |
32 | const CardTitle = forwardRef<
33 | HTMLDivElement,
34 | React.HTMLAttributes
35 | >(({ className, ...props }, ref) => (
36 |
41 | ));
42 | CardTitle.displayName = 'CardTitle';
43 |
44 | const CardDescription = forwardRef<
45 | HTMLDivElement,
46 | React.HTMLAttributes
47 | >(({ className, ...props }, ref) => (
48 |
53 | ));
54 | CardDescription.displayName = 'CardDescription';
55 |
56 | const CardContent = forwardRef<
57 | HTMLDivElement,
58 | React.HTMLAttributes
59 | >(({ className, ...props }, ref) => (
60 |
61 | ));
62 | CardContent.displayName = 'CardContent';
63 |
64 | const CardFooter = forwardRef<
65 | HTMLDivElement,
66 | React.HTMLAttributes
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | CardFooter.displayName = 'CardFooter';
75 |
76 | export {
77 | Card,
78 | CardHeader,
79 | CardFooter,
80 | CardTitle,
81 | CardDescription,
82 | CardContent,
83 | };
84 |
--------------------------------------------------------------------------------
/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import * as AccordionPrimitive from '@radix-ui/react-accordion';
4 | import { ChevronDown } from 'lucide-react';
5 | import * as React from 'react';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | const Accordion = AccordionPrimitive.Root;
10 |
11 | const AccordionItem = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
20 | ));
21 | AccordionItem.displayName = 'AccordionItem';
22 |
23 | const AccordionTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, children, ...props }, ref) => (
27 |
28 | svg]:rotate-180',
31 | className
32 | )}
33 | ref={ref}
34 | {...props}
35 | >
36 | {children}
37 |
38 |
39 |
40 | ));
41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
42 |
43 | const AccordionContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, children, ...props }, ref) => (
47 |
52 | {children}
53 |
54 | ));
55 |
56 | AccordionContent.displayName = AccordionPrimitive.Content.displayName;
57 |
58 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
59 |
--------------------------------------------------------------------------------
/lib/constants/defaults.ts:
--------------------------------------------------------------------------------
1 | // Magic number constants and default values
2 | // These constants are used throughout the application to avoid magic numbers
3 | // and provide centralized configuration
4 |
5 | // Color and hex parsing constants
6 | export const HEX_COLOR_SHORTHAND_LENGTH = 3;
7 | export const HEXADECIMAL_BASE = 16;
8 | export const HEX_COLOR_COMPONENT_LENGTH = 2;
9 | export const HEX_COLOR_FULL_LENGTH = 6; // Full hex color length (e.g., #RRGGBB)
10 |
11 | // UI defaults
12 | export const DEFAULT_BACKGROUND_OPACITY = 0.9;
13 | export const DEFAULT_LOGO_HEIGHT = 56;
14 | export const DEFAULT_LOGO_WIDTH = 185;
15 |
16 | // Content defaults
17 | export const DEFAULT_DESCRIPTION_LENGTH = 500;
18 | export const LATEST_JOBS_COUNT = 7;
19 |
20 | // UI layout constants
21 | export const TRIGGER_WIDTH_SM = 80;
22 | export const TRIGGER_WIDTH_MD = 90;
23 | export const TRIGGER_HEIGHT = 7;
24 | export const SORT_TRIGGER_WIDTH = 130;
25 | export const SORT_TRIGGER_WIDTH_SM = 110;
26 |
27 | // Pagination constants
28 | export const DEFAULT_PER_PAGE = 10;
29 | export const PER_PAGE_OPTIONS = [5, 10, 25, 50, 100] as const;
30 |
31 | // ARIA label constants
32 | export const MIN_WIDTH_SELECT = 90;
33 |
34 | // Pagination constants
35 | export const PAGINATION_DELTA = 2;
36 | export const LOADING_STATE_DELAY = 300;
37 |
38 | // Time constants
39 | export const MILLISECONDS_PER_SECOND = 1000;
40 | export const SECONDS_PER_MINUTE = 60;
41 | export const MINUTES_PER_HOUR = 60;
42 | export const HOURS_PER_DAY = 24;
43 | export const DAYS_PER_MONTH = 30;
44 |
45 | // Derived time constants
46 | export const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR;
47 | export const SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY;
48 | export const SECONDS_PER_MONTH = SECONDS_PER_DAY * DAYS_PER_MONTH;
49 |
50 | // Derived time constants
51 | export const RATE_LIMIT_WINDOW_MS =
52 | MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
53 |
54 | // Regex patterns for performance optimization
55 | export const FONT_URL_REGEX =
56 | /src: url\((.+?)\) format\('(opentype|truetype)'\)/;
57 | export const LEADING_DASHES_REGEX = /^-+/;
58 | export const TRAILING_DASHES_REGEX = /-+$/;
59 | export const WHITESPACE_REGEX = /\s/g;
60 | export const PARENTHESIS_CONTENT_REGEX = /\s*\([^)]*\)\s*/g;
61 |
--------------------------------------------------------------------------------
/app/changelog/page.tsx:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs';
2 | import path from 'node:path';
3 | import type { Metadata } from 'next';
4 | import ReactMarkdown from 'react-markdown';
5 | import { MetadataBreadcrumb } from '@/components/ui/metadata-breadcrumb';
6 | import config from '@/config';
7 |
8 | // Add metadata for SEO
9 | export const metadata: Metadata = {
10 | title: 'Changelog | Latest Updates and Improvements',
11 | description:
12 | "Stay up to date with the latest features, improvements, and bug fixes in our job board platform. Track our progress and see what's new.",
13 | keywords:
14 | 'changelog, updates, features, improvements, job board updates, release notes',
15 | openGraph: {
16 | title: 'Changelog | Latest Updates and Improvements',
17 | description:
18 | "Stay up to date with the latest features, improvements, and bug fixes in our job board platform. Track our progress and see what's new.",
19 | type: 'website',
20 | url: `${config.url}/changelog`,
21 | },
22 | twitter: {
23 | card: 'summary_large_image',
24 | title: 'Changelog | Latest Updates and Improvements',
25 | description:
26 | "Stay up to date with the latest features, improvements, and bug fixes in our job board platform. Track our progress and see what's new.",
27 | },
28 | alternates: {
29 | canonical: '/changelog',
30 | languages: {
31 | en: `${config.url}/changelog`,
32 | 'x-default': `${config.url}/changelog`,
33 | },
34 | },
35 | };
36 |
37 | // This page will be static
38 | export const dynamic = 'force-static';
39 |
40 | function getChangelogContent() {
41 | const filePath = path.join(process.cwd(), 'CHANGELOG.md');
42 | const content = fs.readFileSync(filePath, 'utf8');
43 | return content;
44 | }
45 |
46 | export default function ChangelogPage() {
47 | const content = getChangelogContent();
48 |
49 | return (
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | {content}
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from '@radix-ui/react-slot';
2 | import { cva, type VariantProps } from 'class-variance-authority';
3 | import * as React from 'react';
4 |
5 | import { cn } from '@/lib/utils';
6 |
7 | const buttonVariants = cva(
8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | 'bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-sm',
14 | destructive:
15 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90 hover:shadow-sm',
16 | outline:
17 | 'border border-input bg-background hover:border-accent hover:bg-accent hover:text-accent-foreground hover:shadow-sm',
18 | secondary:
19 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80 hover:shadow-sm',
20 | ghost: 'hover:bg-accent hover:text-accent-foreground',
21 | link: 'text-primary underline-offset-4 hover:underline',
22 | primary: 'text-white hover:opacity-90 hover:shadow-sm',
23 | },
24 | size: {
25 | xs: 'h-7 rounded-md px-2.5 text-xs',
26 | sm: 'h-9 rounded-md px-3',
27 | default: 'h-10 px-4 py-2',
28 | lg: 'h-11 rounded-md px-8',
29 | icon: 'h-10 w-10',
30 | },
31 | },
32 | defaultVariants: {
33 | variant: 'default',
34 | size: 'default',
35 | },
36 | }
37 | );
38 |
39 | export interface ButtonProps
40 | extends React.ButtonHTMLAttributes,
41 | VariantProps {
42 | asChild?: boolean;
43 | }
44 |
45 | const Button = React.forwardRef(
46 | ({ className, variant, size, asChild = false, ...props }, ref) => {
47 | const Comp = asChild ? Slot : 'button';
48 | return (
49 |
54 | );
55 | }
56 | );
57 | Button.displayName = 'Button';
58 |
59 | export { Button, buttonVariants };
60 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from 'next';
2 |
3 | const nextConfig: NextConfig = {
4 | typescript: {
5 | // !! WARN !!
6 | // Dangerously allow production builds to successfully complete even if
7 | // your project has type errors.
8 | // !! WARN !!
9 | ignoreBuildErrors: true,
10 | },
11 | env: {
12 | AIRTABLE_ACCESS_TOKEN: process.env.AIRTABLE_ACCESS_TOKEN,
13 | AIRTABLE_BASE_ID: process.env.AIRTABLE_BASE_ID,
14 | AIRTABLE_TABLE_NAME: process.env.AIRTABLE_TABLE_NAME,
15 | },
16 | headers() {
17 | return [
18 | {
19 | // Apply these headers to all routes
20 | source: '/:path*',
21 | headers: [
22 | {
23 | key: 'X-Robots-Tag',
24 | value:
25 | 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1',
26 | },
27 | ],
28 | },
29 | {
30 | // Apply specific headers to image files
31 | source: '/:path*.jpg',
32 | headers: [
33 | {
34 | key: 'X-Robots-Tag',
35 | value: 'index, max-image-preview:large',
36 | },
37 | ],
38 | },
39 | {
40 | // Apply specific headers to image files
41 | source: '/:path*.jpeg',
42 | headers: [
43 | {
44 | key: 'X-Robots-Tag',
45 | value: 'index, max-image-preview:large',
46 | },
47 | ],
48 | },
49 | {
50 | // Apply specific headers to image files
51 | source: '/:path*.png',
52 | headers: [
53 | {
54 | key: 'X-Robots-Tag',
55 | value: 'index, max-image-preview:large',
56 | },
57 | ],
58 | },
59 | {
60 | // Apply specific headers to image files
61 | source: '/:path*.svg',
62 | headers: [
63 | {
64 | key: 'X-Robots-Tag',
65 | value: 'index, max-image-preview:large',
66 | },
67 | ],
68 | },
69 | {
70 | // Apply specific headers to PDF files
71 | source: '/:path*.pdf',
72 | headers: [
73 | {
74 | key: 'X-Robots-Tag',
75 | value: 'index, nosnippet',
76 | },
77 | ],
78 | },
79 | ];
80 | },
81 | };
82 |
83 | export default nextConfig;
84 |
--------------------------------------------------------------------------------
/lib/email/providers/encharge.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import config from '@/config';
3 | import {
4 | type EmailProvider,
5 | EmailProviderError,
6 | type SubscriberData,
7 | } from '../types';
8 |
9 | export class EnchargeProvider implements EmailProvider {
10 | name = 'encharge';
11 | private readonly writeKey: string;
12 | private readonly defaultTags: string;
13 | private readonly eventName: string;
14 |
15 | constructor() {
16 | // Get configuration from config file or environment variables
17 | const enchargeConfig = config.email?.encharge || {};
18 |
19 | this.writeKey =
20 | enchargeConfig.writeKey || process.env.ENCHARGE_WRITE_KEY || '';
21 | this.defaultTags = enchargeConfig.defaultTags || 'job-alerts-subscriber';
22 | this.eventName = enchargeConfig.eventName || 'Job Alert Subscription';
23 |
24 | if (!this.writeKey && process.env.NODE_ENV === 'production') {
25 | throw new Error('Encharge write key is required in production');
26 | }
27 | }
28 |
29 | async subscribe(data: SubscriberData) {
30 | try {
31 | // In development without a key, simulate success
32 | if (!this.writeKey && process.env.NODE_ENV === 'development') {
33 | return { success: true };
34 | }
35 |
36 | // Format the payload for Encharge
37 | const payload = {
38 | name: this.eventName,
39 | user: {
40 | email: data.email,
41 | firstName: data.name?.split(' ')[0] || '',
42 | lastName: data.name?.split(' ').slice(1).join(' ') || '',
43 | tags: this.defaultTags,
44 | ip: data.ip,
45 | },
46 | properties: {
47 | ...data.metadata,
48 | signupDate: new Date().toISOString(),
49 | submittedName: data.name || 'Not provided',
50 | },
51 | sourceIp: data.ip,
52 | };
53 |
54 | // Make the API call to Encharge
55 | await axios.post(
56 | `https://ingest.encharge.io/v1/${this.writeKey}`,
57 | payload,
58 | { headers: { 'Content-Type': 'application/json' } }
59 | );
60 |
61 | return { success: true };
62 | } catch (error) {
63 | const errorMessage =
64 | error instanceof Error ? error.message : 'Subscription failed';
65 | throw new EmailProviderError(errorMessage, 'encharge');
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/components/ui/post-job-banner.tsx:
--------------------------------------------------------------------------------
1 | import { Briefcase } from 'lucide-react';
2 | import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
3 | import { Button } from '@/components/ui/button';
4 | import { Card } from '@/components/ui/card';
5 | import config from '@/config';
6 | import { resolveColor } from '@/lib/utils/colors';
7 |
8 | export function PostJobBanner() {
9 | // Early return if banner is disabled
10 | if (!config.postJobBanner.enabled) {
11 | return null;
12 | }
13 |
14 | const {
15 | title,
16 | description,
17 | showTrustedBy,
18 | trustedByText,
19 | companyAvatars,
20 | cta,
21 | trustMessage,
22 | } = config.postJobBanner;
23 |
24 | return (
25 |
26 | {title}
27 | {description}
28 |
29 | {showTrustedBy && (
30 |
31 |
32 | {companyAvatars.map((avatar, index) => (
33 |
34 |
35 | {avatar.fallback}
36 |
37 | ))}
38 |
39 |
{trustedByText}
40 |
41 | )}
42 |
43 |
49 |
55 |
56 | {cta.text}
57 |
58 |
59 |
60 |
61 |
62 |
63 | {trustMessage}
64 |
65 |
66 | );
67 | }
68 |
--------------------------------------------------------------------------------
/docs/troubleshooting/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Troubleshooting Guides
3 | description: Find solutions to common issues with Bordful job board setup and operation.
4 | lastUpdated: "2025-05-22"
5 | ---
6 |
7 | # Troubleshooting Guides
8 |
9 | Welcome to the Bordful troubleshooting section. Here you'll find solutions to common issues that may arise during installation, configuration, or operation of your job board.
10 |
11 | ## Available Guides
12 |
13 | - [Installation Issues](/docs/troubleshooting/installation-issues.md) - Solutions for common problems during installation and initial setup
14 |
15 | ## Coming Soon
16 |
17 | - **Configuration Problems** - Resolving issues with configuration files and settings
18 | - **Deployment Troubleshooting** - Fixing common deployment errors on Vercel, Netlify, and other platforms
19 | - **Database Connection Issues** - Resolving Airtable connection problems
20 | - **API Error Resolution** - Fixing common API-related errors
21 | - **Performance Problems** - Addressing slow loading and performance bottlenecks
22 |
23 | ## General Troubleshooting Tips
24 |
25 | Before diving into specific guides, try these general troubleshooting steps:
26 |
27 | 1. **Check Environment Variables**: Many issues stem from incorrect or missing environment variables
28 | 2. **Verify Airtable Setup**: Ensure your Airtable base is properly configured with all required fields
29 | 3. **Clear Cache**: Delete the `.next` folder and node_modules to start fresh
30 | 4. **Check Console Errors**: Look for specific error messages in your terminal or browser console
31 | 5. **Restart Development Server**: Sometimes a simple restart resolves temporary issues
32 | 6. **Check Dependencies**: Ensure all dependencies are correctly installed with `bun install`
33 | 7. **Verify Node Version**: Confirm you're using a compatible Node.js version (v18+ recommended)
34 |
35 | ## Frequently Asked Questions
36 |
37 | For quick answers to common questions, check these resources:
38 |
39 | - [General FAQ](/docs/faq.md) - General questions about Bordful features and capabilities
40 | - [Technical FAQ](/docs/troubleshooting/faq.md) - Technical questions related to development and customization (coming soon)
41 |
42 | ## Getting Help
43 |
44 | If you can't find a solution in these guides:
45 |
46 | 1. Check the [GitHub Issues](https://github.com/craftled/bordful/issues) for similar problems
47 | 2. Join our [GitHub Discussions](https://github.com/craftled/bordful/discussions) community
48 | 3. Submit a new issue with detailed information about your problem
49 |
50 | When reporting issues, always include:
51 | - Exact error messages
52 | - Your environment details (Node.js version, OS, browser)
53 | - Steps to reproduce the issue
54 | - Any customizations you've made
--------------------------------------------------------------------------------
/components/ui/icons.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | AlertTriangle,
3 | ArrowRight,
4 | Check,
5 | ChevronLeft,
6 | ChevronRight,
7 | Command,
8 | CreditCard,
9 | File,
10 | FileText,
11 | HelpCircle,
12 | Image,
13 | Laptop,
14 | Loader2,
15 | type LucideIcon,
16 | type LucideProps,
17 | Moon,
18 | MoreVertical,
19 | Pizza,
20 | Plus,
21 | Settings,
22 | SunMedium,
23 | Trash,
24 | Twitter,
25 | User,
26 | X,
27 | } from 'lucide-react';
28 |
29 | export type Icon = LucideIcon;
30 |
31 | export const Icons = {
32 | logo: Command,
33 | close: X,
34 | spinner: Loader2,
35 | chevronLeft: ChevronLeft,
36 | chevronRight: ChevronRight,
37 | trash: Trash,
38 | post: FileText,
39 | page: File,
40 | media: Image,
41 | settings: Settings,
42 | billing: CreditCard,
43 | ellipsis: MoreVertical,
44 | add: Plus,
45 | warning: AlertTriangle,
46 | user: User,
47 | arrowRight: ArrowRight,
48 | help: HelpCircle,
49 | pizza: Pizza,
50 | sun: SunMedium,
51 | moon: Moon,
52 | laptop: Laptop,
53 | gitHub: ({ ...props }: LucideProps) => (
54 |
64 |
68 |
69 | ),
70 | twitter: Twitter,
71 | check: Check,
72 | };
73 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --font-geist-sans: "Geist Sans";
8 | --font-geist-mono: "Geist Mono";
9 | --font-inter: "Inter";
10 | --font-ibm-plex-serif: "IBM Plex Serif";
11 | }
12 |
13 | /* Font styling for IBM Plex Serif */
14 | html[data-font="ibm-plex-serif"] {
15 | font-family: var(--font-ibm-plex-serif), Georgia, serif;
16 | }
17 | }
18 |
19 | @layer components {
20 | .markdown-content {
21 | @apply prose prose-sm prose-gray max-w-none;
22 |
23 | h1 {
24 | @apply text-2xl font-semibold mb-2;
25 | }
26 | h2 {
27 | @apply text-xl font-semibold mb-2;
28 | }
29 | h3 {
30 | @apply text-lg font-semibold mb-2;
31 | }
32 | h4 {
33 | @apply text-base font-semibold mb-2;
34 | }
35 |
36 | /* Base text size */
37 | p,
38 | ul,
39 | ol {
40 | @apply text-sm text-gray-700 leading-relaxed;
41 | }
42 |
43 | /* Lists */
44 | ul {
45 | @apply list-disc pl-4 mb-4 space-y-1;
46 | }
47 | ol {
48 | @apply list-decimal pl-4 mb-4 space-y-1;
49 | }
50 |
51 | /* Links */
52 | a {
53 | @apply text-blue-600 hover:text-blue-800 no-underline hover:underline;
54 | }
55 |
56 | /* Code blocks */
57 | pre {
58 | @apply bg-gray-50 p-4 rounded-lg mb-4 overflow-x-auto text-sm;
59 | }
60 | code {
61 | @apply bg-gray-50 px-1.5 py-0.5 rounded text-sm font-normal text-gray-800;
62 | }
63 |
64 | /* Blockquotes */
65 | blockquote {
66 | @apply border-l-4 border-gray-200 pl-4 italic my-4 text-gray-600;
67 | }
68 |
69 | /* Lists inside lists */
70 | ul ul,
71 | ol ol,
72 | ul ol,
73 | ol ul {
74 | @apply mt-1 mb-1;
75 | }
76 |
77 | /* Tables */
78 | table {
79 | @apply w-full border-collapse mb-4 text-sm;
80 | }
81 |
82 | thead {
83 | @apply bg-gray-50;
84 | }
85 |
86 | th {
87 | @apply border border-gray-200 px-3 py-2 text-left font-medium text-gray-700;
88 | }
89 |
90 | td {
91 | @apply border border-gray-200 px-3 py-2 text-gray-700;
92 | }
93 |
94 | tr:nth-child(even) {
95 | @apply bg-gray-50;
96 | }
97 | }
98 | }
99 |
100 | /* Pulse animation for search indicator */
101 | @keyframes pulse {
102 | 0% {
103 | transform: scale(0.95);
104 | opacity: 0.5;
105 | }
106 | 50% {
107 | transform: scale(1.05);
108 | opacity: 0.8;
109 | }
110 | 100% {
111 | transform: scale(0.95);
112 | opacity: 0.5;
113 | }
114 | }
115 |
116 | .pulse-dot {
117 | animation: pulse 2s infinite;
118 | }
119 |
--------------------------------------------------------------------------------
/app/api/og/jobs/[slug]/route.tsx:
--------------------------------------------------------------------------------
1 | import config from '@/config';
2 | import {
3 | getFontFamilyCSS,
4 | getFontInfo,
5 | loadGoogleFontData,
6 | prepareImageResponseFonts,
7 | } from '@/lib/utils/font-utils';
8 | import { fetchImageAsDataURI } from '@/lib/utils/image-utils';
9 | import { validateJobAndParams } from '@/lib/utils/job-validation';
10 | import {
11 | createJobOGConfig,
12 | createJobOGImageResponse,
13 | } from '@/lib/utils/og-job-helpers';
14 |
15 | // Use the nodejs runtime to ensure full environment variable access
16 | export const runtime = 'nodejs';
17 |
18 | /**
19 | * Generate a dynamic Open Graph image for a specific job post
20 | * using dynamically fetched Google Fonts.
21 | */
22 | export async function GET(
23 | _request: Request,
24 | context: { params: { slug: string } }
25 | ) {
26 | try {
27 | // Validate parameters and fetch job
28 | const validationResult = await validateJobAndParams(context);
29 | if (validationResult instanceof Response) {
30 | return validationResult;
31 | }
32 | const { job } = validationResult;
33 |
34 | // Create job-specific OG configuration
35 | const jobConfig = createJobOGConfig(job);
36 |
37 | // Prepare font assets
38 | const fontInfo = getFontInfo(jobConfig.fontFamily);
39 | let fontData: ArrayBuffer | null = null;
40 |
41 | if (fontInfo.nameToLoad) {
42 | fontData = await loadGoogleFontData(
43 | fontInfo.nameToLoad,
44 | `${job.title} ${job.company}`
45 | );
46 | }
47 |
48 | const imageResponseFonts = prepareImageResponseFonts(
49 | jobConfig.fontFamily,
50 | fontData,
51 | `${job.title} ${job.company}`
52 | );
53 |
54 | const fontFamilyCSS = getFontFamilyCSS(
55 | jobConfig.fontFamily,
56 | fontInfo,
57 | imageResponseFonts
58 | );
59 |
60 | // Prepare background image
61 | const bgImageDataUri = jobConfig.backgroundImage
62 | ? await fetchImageAsDataURI(jobConfig.backgroundImage, config.url)
63 | : '';
64 |
65 | // Prepare logo
66 | const ogJobConfig = config.og?.jobs || {};
67 | const logoConfig = ogJobConfig.logo || {};
68 | const logoDataUri =
69 | logoConfig.show !== false && logoConfig.src
70 | ? await fetchImageAsDataURI(logoConfig.src, config.url)
71 | : '';
72 |
73 | // Generate and return the image
74 | return await createJobOGImageResponse(
75 | jobConfig,
76 | fontFamilyCSS,
77 | imageResponseFonts,
78 | bgImageDataUri,
79 | logoDataUri
80 | );
81 | } catch (error: unknown) {
82 | const errorMessage = error instanceof Error ? error.message : String(error);
83 | return new Response(`Failed to generate job image: ${errorMessage}`, {
84 | status: 500,
85 | });
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/public/assets/paypal.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/ui/jobs-per-page-select.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | Select,
5 | SelectContent,
6 | SelectItem,
7 | SelectTrigger,
8 | SelectValue,
9 | } from '@/components/ui/select';
10 | import config from '@/config';
11 | import {
12 | DEFAULT_PER_PAGE,
13 | MIN_WIDTH_SELECT,
14 | PER_PAGE_OPTIONS,
15 | TRIGGER_HEIGHT,
16 | TRIGGER_WIDTH_MD,
17 | TRIGGER_WIDTH_SM,
18 | } from '@/lib/constants/defaults';
19 | import { usePagination } from '@/lib/hooks/usePagination';
20 |
21 | export function JobsPerPageSelect() {
22 | const { perPage, setPerPage } = usePagination();
23 | const defaultPerPage = config.jobListings?.defaultPerPage || DEFAULT_PER_PAGE;
24 |
25 | // Options for per page
26 | const perPageOptions = PER_PAGE_OPTIONS;
27 |
28 | // Get label configuration with fallbacks
29 | const showLabel = config.jobListings?.labels?.perPage?.show ?? true;
30 | const labelText =
31 | config.jobListings?.labels?.perPage?.text || 'Jobs per page:';
32 |
33 | // Ensure perPage is a valid option
34 | const validPerPage = perPageOptions.includes(perPage)
35 | ? perPage
36 | : defaultPerPage;
37 |
38 | // Adjust width based on whether label is shown
39 | const triggerWidth = `w-[${TRIGGER_WIDTH_SM}px] sm:w-[${TRIGGER_WIDTH_MD}px]`;
40 |
41 | return (
42 |
43 | {/* Only show label if configured */}
44 | {showLabel && (
45 |
49 | {labelText}
50 |
51 | )}
52 | {
54 | const newValue = Number.parseInt(value, 10);
55 | setPerPage(newValue === defaultPerPage ? null : newValue);
56 | }}
57 | value={validPerPage.toString()}
58 | >
59 |
64 |
67 |
68 |
73 | {perPageOptions.map((option) => (
74 |
79 | {showLabel ? option : `${option} per page`}
80 |
81 | ))}
82 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/app/faq/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import Link from 'next/link';
3 | import { Button } from '@/components/ui/button';
4 | import { FAQContent } from '@/components/ui/faq-content';
5 | import { HeroSection } from '@/components/ui/hero-section';
6 | import { MetadataBreadcrumb } from '@/components/ui/metadata-breadcrumb';
7 | import config from '@/config';
8 |
9 | // Add metadata for SEO
10 | export const metadata: Metadata = {
11 | title: `${config.faq?.title || 'FAQ'} | ${config.title}`,
12 | description:
13 | config.faq?.description ||
14 | 'Find answers to common questions about our job board and services.',
15 | keywords:
16 | config.faq?.keywords ||
17 | 'job board FAQ, frequently asked questions, job search help, employer questions',
18 | openGraph: {
19 | title: `${config.faq?.title || 'FAQ'} | ${config.title}`,
20 | description:
21 | config.faq?.description ||
22 | 'Find answers to common questions about our job board and services.',
23 | type: 'website',
24 | url: `${config.url}/faq`,
25 | },
26 | twitter: {
27 | card: 'summary_large_image',
28 | title: `${config.faq?.title || 'FAQ'} | ${config.title}`,
29 | description:
30 | config.faq?.description ||
31 | 'Find answers to common questions about our job board and services.',
32 | },
33 | alternates: {
34 | canonical: '/faq',
35 | languages: {
36 | en: `${config.url}/faq`,
37 | 'x-default': `${config.url}/faq`,
38 | },
39 | },
40 | };
41 |
42 | // This page will be static
43 | export const dynamic = 'force-static';
44 |
45 | export default function FAQPage() {
46 | // If FAQ is not enabled, redirect to home page
47 | if (!config.faq?.enabled) {
48 | return (
49 |
50 |
51 |
FAQ Not Available
52 |
The FAQ page is currently not available.
53 |
54 |
Return Home
55 |
56 |
57 |
58 | );
59 | }
60 |
61 | return (
62 |
63 |
69 |
70 | {/* FAQ Content */}
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/docs/reference/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Reference Documentation
3 | description: Complete reference documentation for Bordful job board configurations, systems, and features.
4 | lastUpdated: "2025-05-22"
5 | ---
6 |
7 | # Bordful Reference Documentation
8 |
9 | This section provides comprehensive reference documentation for all Bordful configuration options, systems, and features. Use these references as your guide to understanding and implementing all available options.
10 |
11 | ## Systems Documentation
12 |
13 | - [**Language System**](./language-system.md) - Complete documentation on the internationalization-ready language system, including language codes and implementation.
14 |
15 | - [**FAQ System**](./faq-system.md) - Reference for the feature-rich FAQ page with client-side search, schema markup, and more.
16 |
17 | - [**RSS Feed System**](./rss-feed-system.md) - Guide to the comprehensive RSS feed system for job listings.
18 |
19 | - [**Robots.txt Generation**](./robots-generation.md) - Reference for automatic robots.txt generation and crawl control.
20 |
21 | - [**Sitemap Generation**](./sitemap-generation.md) - Documentation for automatic XML sitemap generation with ISR updates.
22 |
23 | ## Data Structures
24 |
25 | - [**Salary Structure**](./salary-structure.md) - Complete reference for the comprehensive salary handling system with multiple currencies and formats.
26 |
27 | - [**Pagination, Sorting, and URL Parameters**](./pagination-sorting.md) - Documentation for the pagination system, sorting options, and URL parameters.
28 |
29 | - [**Currencies**](./currencies.md) - Complete reference for all supported currencies, including fiat, cryptocurrency, and stablecoins.
30 |
31 | ## Coming Soon
32 |
33 | - **Configuration Options** - Comprehensive guide to all configuration options and their effects.
34 |
35 | - **Environment Variables** - Complete reference for all environment variables used by Bordful.
36 |
37 | - **Language Codes** - Reference for all supported language codes and their implementation.
38 |
39 | - **Career Levels** - Documentation for standardized career levels and their implementation.
40 |
41 | - **Glossary** - Comprehensive glossary of technical terms used throughout Bordful.
42 |
43 | - **CLI Commands** - Reference for any command-line tools and utilities provided with Bordful.
44 |
45 | - **File Structure** - Complete reference for the Bordful file and directory structure.
46 |
47 | ## How to Use This Section
48 |
49 | This reference documentation is designed to be:
50 |
51 | 1. **Comprehensive** - Covering all available options and configurations
52 | 2. **Detailed** - Providing specific implementation details
53 | 3. **Practical** - Including examples for all options
54 | 4. **Up-to-date** - Maintained alongside code changes
55 |
56 | Use the sidebar navigation to quickly find the specific reference documentation you need. Each reference document includes complete examples and code snippets to help you implement the features correctly.
--------------------------------------------------------------------------------
/docs/getting-started/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: Getting Started with Bordful
3 | description: Learn how to set up and configure your Bordful job board in minutes.
4 | lastUpdated: "2025-05-22"
5 | ---
6 |
7 | Bordful is a modern, open-source job board built with Next.js, Tailwind CSS, and Airtable. This guide will help you get your job board up and running quickly.
8 |
9 | ## 1. Clone the Repository
10 |
11 | Start by cloning the Bordful repository to your local machine:
12 |
13 | ```bash
14 | git clone https://github.com/tomaslau/bordful.git
15 | cd bordful
16 | ```
17 |
18 | ## 2. Install Dependencies
19 |
20 | Install the required dependencies using Bun:
21 |
22 | ```bash
23 | # Install dependencies
24 | bun install
25 | ```
26 |
27 | ## 3. Set Up Airtable
28 |
29 | Bordful uses Airtable as its database. You have two options for setting up Airtable:
30 |
31 | ### Option A: Use the Template (Recommended)
32 |
33 | - Visit the [Bordful Airtable template](https://airtable.com/apprhCjWTxfG3JX5p/shrLqfxgbensCY393/tblBFcWLWFxosr0ey)
34 | - Click "Use this data" in the top right corner
35 | - Note the name of your table (default is "Jobs")
36 |
37 | ### Option B: Manual Setup
38 |
39 | Create a new Airtable base with a "Jobs" table containing these fields:
40 |
41 | ```typescript
42 | interface Job {
43 | title: string; // Single line text
44 | company: string; // Single line text
45 | description: string; // Long text
46 | location: string; // Single line text
47 | type: string; // Single select: Full-time, Part-time, Contract, Freelance
48 | remote: boolean; // Checkbox
49 | salary_min: number; // Number
50 | salary_max: number; // Number
51 | currency: string; // Single select
52 | posted_date: Date; // Date
53 | }
54 | ```
55 |
56 | ## 4. Configure Environment Variables
57 |
58 | Create a `.env.local` file in the project root with your Airtable credentials:
59 |
60 | ```env
61 | AIRTABLE_ACCESS_TOKEN=your_access_token
62 | AIRTABLE_BASE_ID=your_base_id
63 | AIRTABLE_TABLE_NAME=Jobs
64 | NEXT_PUBLIC_APP_URL=http://localhost:3000
65 | ```
66 |
67 | ## 5. Start the Development Server
68 |
69 | Run the development server to see your job board in action:
70 |
71 | ```bash
72 | # Start the development server
73 | bun run dev
74 | ```
75 |
76 | Visit `http://localhost:3000` in your browser to see your job board.
77 |
78 | ## Next Steps
79 |
80 | Now that your job board is up and running, explore these resources to customize it:
81 |
82 | - Add your branding and customize the theme
83 | - Configure job filters and search options
84 | - Set up email notifications for job alerts
85 | - Deploy your job board to production
86 |
87 | Check out these guides for more detailed information:
88 |
89 | - [Airtable Setup Guide](/docs/guides/airtable-setup)
90 | - [Theming and Customization](/docs/guides/theming-customization)
91 | - [Job Alerts Configuration](/docs/guides/job-alerts-configuration)
92 | - [Deployment Guide](/docs/guides/deployment)
--------------------------------------------------------------------------------
/components/ui/sort-order-select.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | Select,
5 | SelectContent,
6 | SelectItem,
7 | SelectTrigger,
8 | SelectValue,
9 | } from '@/components/ui/select';
10 | import config from '@/config';
11 | import {
12 | SORT_TRIGGER_WIDTH,
13 | SORT_TRIGGER_WIDTH_SM,
14 | } from '@/lib/constants/defaults';
15 | import { useSortOrder } from '@/lib/hooks/useSortOrder';
16 |
17 | type SortOption = 'newest' | 'oldest' | 'salary';
18 |
19 | // Sort option labels mapping
20 | const sortOptionLabels: Record = {
21 | newest: 'Newest first',
22 | oldest: 'Oldest first',
23 | salary: 'Highest salary',
24 | };
25 |
26 | export function SortOrderSelect() {
27 | const { sortOrder, setSortOrder } = useSortOrder();
28 |
29 | // Get available sort options from config or use default
30 | const availableSortOptions = config.jobListings?.sortOptions || [
31 | 'newest',
32 | 'oldest',
33 | 'salary',
34 | ];
35 |
36 | // Get default sort order from config or use "newest"
37 | const defaultSortOrder =
38 | (config.jobListings?.defaultSortOrder as SortOption) || 'newest';
39 |
40 | // Get label configuration with fallbacks
41 | const showLabel = config.jobListings?.labels?.sortOrder?.show ?? true;
42 | const labelText = config.jobListings?.labels?.sortOrder?.text || 'Sort by:';
43 |
44 | // Adjust width based on whether label is shown
45 | const triggerWidth = `w-[${SORT_TRIGGER_WIDTH}px] sm:w-[${SORT_TRIGGER_WIDTH_SM}px]`;
46 |
47 | return (
48 |
49 | {/* Only show label if configured */}
50 | {showLabel && (
51 |
55 | {labelText}
56 |
57 | )}
58 | {
60 | // Only pass null if it's the default value
61 | if (value === defaultSortOrder) {
62 | setSortOrder(null);
63 | } else {
64 | setSortOrder(value);
65 | }
66 | }}
67 | value={sortOrder}
68 | >
69 |
74 |
80 |
81 |
82 | {availableSortOptions.map((option) => (
83 |
84 | {sortOptionLabels[option as SortOption]}
85 |
86 | ))}
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/components/ui/job-badge.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import type React from 'react';
3 | import config from '@/config';
4 | import { cn } from '@/lib/utils';
5 | import { resolveColor } from '@/lib/utils/colors';
6 |
7 | export type BadgeType =
8 | | 'new'
9 | | 'remote'
10 | | 'onsite'
11 | | 'hybrid'
12 | | 'featured'
13 | | 'default'
14 | | 'not specified'
15 | | 'visa-yes'
16 | | 'visa-no'
17 | | 'visa-not-specified'
18 | | 'career-level'
19 | | 'language'
20 | | 'currency';
21 |
22 | type JobBadgeProps = {
23 | type: BadgeType;
24 | children: React.ReactNode;
25 | className?: string;
26 | icon?: React.ReactNode;
27 | href?: string; // Optional URL for clickable badges
28 | };
29 |
30 | export function JobBadge({
31 | type,
32 | children,
33 | className,
34 | icon,
35 | href,
36 | }: JobBadgeProps) {
37 | // Base badge styles without hover effects
38 | const badgeStyles = {
39 | new: 'bg-green-50 border-green-100 border text-green-700',
40 | remote: 'bg-green-50 border-green-100 border text-green-700',
41 | onsite: 'bg-red-50 border-red-100 border text-red-700',
42 | hybrid: 'bg-blue-50 border-blue-100 border text-blue-700',
43 | featured: 'text-zinc-50',
44 | default: 'bg-white border text-gray-700',
45 | 'not specified': 'bg-white border text-gray-700',
46 | 'visa-yes': 'bg-green-50 border-green-100 border text-green-700',
47 | 'visa-no': 'bg-red-50 border-red-100 border text-red-700',
48 | 'visa-not-specified': 'bg-white border text-gray-700',
49 | 'career-level': 'bg-white border text-gray-700',
50 | language: 'bg-white border text-gray-700',
51 | currency: 'bg-amber-50 border-amber-100 border text-amber-700',
52 | };
53 |
54 | // Apply hover effects only when href is provided (badge is clickable)
55 | const hoverStyles = href ? 'hover:border-gray-400 transition-colors' : '';
56 |
57 | const baseStyles = 'inline-block px-2 py-0.5 text-xs rounded-full';
58 |
59 | // Featured badges have different styling
60 | const featuredStyles =
61 | type === 'featured'
62 | ? 'inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded-full'
63 | : baseStyles;
64 |
65 | const styles = cn(featuredStyles, badgeStyles[type], hoverStyles, className);
66 |
67 | // Apply inline style for featured badge to use primary color
68 | const badgeStyle =
69 | type === 'featured'
70 | ? { backgroundColor: resolveColor(config.ui.primaryColor) }
71 | : undefined;
72 |
73 | // If href is provided, render as a link
74 | if (href) {
75 | return (
76 |
81 | {icon && {icon} }
82 | {children}
83 |
84 | );
85 | }
86 |
87 | // Regular badge (non-clickable)
88 | return (
89 |
90 | {icon && {icon} }
91 | {children}
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/app/jobs/language/[language]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { notFound } from 'next/navigation';
3 | import { JobsLayout } from '@/components/jobs/JobsLayout';
4 | import { HeroSection } from '@/components/ui/hero-section';
5 | import { JobSearchInput } from '@/components/ui/job-search-input';
6 | import config from '@/config';
7 | import {
8 | getDisplayNameFromCode,
9 | LANGUAGE_CODES,
10 | type LanguageCode,
11 | } from '@/lib/constants/languages';
12 | import { getJobs } from '@/lib/db/airtable';
13 | import { generateMetadata as createMetadata } from '@/lib/utils/metadata';
14 |
15 | // Revalidate page every 5 minutes
16 | export const revalidate = 300;
17 |
18 | type Props = {
19 | params: Promise<{
20 | language: string;
21 | }>;
22 | };
23 |
24 | // Generate metadata for SEO
25 | export async function generateMetadata({ params }: Props): Promise {
26 | // Await the entire params object first
27 | const resolvedParams = await params;
28 | const languageCode = resolvedParams.language.toLowerCase();
29 |
30 | // Check if valid language code
31 | if (!LANGUAGE_CODES.includes(languageCode as LanguageCode)) {
32 | return {
33 | title: `Language Not Found | ${config.title}`,
34 | description: "The language you're looking for doesn't exist.",
35 | };
36 | }
37 |
38 | const displayName = getDisplayNameFromCode(languageCode as LanguageCode);
39 |
40 | return createMetadata({
41 | title: `${displayName} Jobs | ${config.title}`,
42 | description: `Browse jobs requiring ${displayName} language skills. Find the perfect role that matches your language abilities.`,
43 | path: `/jobs/language/${languageCode}`,
44 | });
45 | }
46 |
47 | export default async function LanguagePage({ params }: Props) {
48 | const jobs = await getJobs();
49 | // Await the entire params object first
50 | const resolvedParams = await params;
51 | const languageCode = resolvedParams.language.toLowerCase();
52 |
53 | // Check if valid language code
54 | if (!LANGUAGE_CODES.includes(languageCode as LanguageCode)) {
55 | return notFound();
56 | }
57 |
58 | const displayName = getDisplayNameFromCode(languageCode as LanguageCode);
59 |
60 | // Filter jobs by language code
61 | const filteredJobs = jobs.filter((job) =>
62 | job.languages?.includes(languageCode as LanguageCode)
63 | );
64 |
65 | if (filteredJobs.length === 0) {
66 | return notFound();
67 | }
68 |
69 | return (
70 | <>
71 |
79 | {/* Search Bar */}
80 |
81 |
82 |
83 |
84 |
85 | >
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/components/contact/SupportChannelCard.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ArrowRight,
3 | ArrowUpRight,
4 | Github,
5 | HelpCircle,
6 | Linkedin,
7 | type LucideIcon,
8 | Mail,
9 | MessageSquare,
10 | Phone,
11 | Rss,
12 | } from 'lucide-react';
13 | import Image from 'next/image';
14 | import Link from 'next/link';
15 | import { Button } from '@/components/ui/button';
16 | import config from '@/config';
17 | import { resolveColor } from '@/lib/utils/colors';
18 |
19 | type SupportChannelCardProps = {
20 | title: string;
21 | description: string;
22 | buttonText: string;
23 | buttonLink: string;
24 | icon: string;
25 | };
26 |
27 | // Map of icon names to components
28 | const iconMap: Record = {
29 | Mail,
30 | HelpCircle,
31 | Phone,
32 | MessageSquare,
33 | Github,
34 | Linkedin,
35 | Rss,
36 | };
37 |
38 | export function SupportChannelCard({
39 | title,
40 | description,
41 | buttonText,
42 | buttonLink,
43 | icon,
44 | }: SupportChannelCardProps) {
45 | // Check if it's Twitter icon
46 | const isTwitterIcon = icon === 'Twitter';
47 |
48 | // Get the icon component or use HelpCircle as fallback
49 | const IconComponent = isTwitterIcon ? null : iconMap[icon] || HelpCircle;
50 |
51 | const isExternalLink =
52 | buttonLink.startsWith('http') || buttonLink.startsWith('mailto');
53 |
54 | return (
55 |
56 |
57 |
58 | {isTwitterIcon ? (
59 |
60 |
67 |
68 | ) : (
69 |
70 | )}
71 |
72 |
{title}
73 |
74 |
77 |
78 |
85 |
90 | {buttonText}
91 | {isExternalLink && (
92 |
93 | )}
94 | {!isExternalLink && (
95 |
96 | )}
97 |
98 |
99 |
100 |
101 | );
102 | }
103 |
--------------------------------------------------------------------------------
/components/ui/job-search-input.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Search, X } from 'lucide-react';
4 | import { useEffect, useState } from 'react';
5 | import { Input } from '@/components/ui/input';
6 | import config from '@/config';
7 | import { useJobSearch } from '@/lib/hooks/useJobSearch';
8 |
9 | type JobSearchInputProps = {
10 | placeholder?: string;
11 | className?: string;
12 | 'aria-label'?: string;
13 | };
14 |
15 | export function JobSearchInput({
16 | placeholder,
17 | className = 'pl-9 h-10',
18 | 'aria-label': ariaLabel,
19 | }: JobSearchInputProps) {
20 | // Get config values with fallbacks
21 | const defaultPlaceholder =
22 | config.search?.placeholder || 'Search by role, company, or location...';
23 | const defaultAriaLabel = config.search?.ariaLabel || 'Search jobs';
24 |
25 | // Use provided props or fallback to config values
26 | const finalPlaceholder = placeholder || defaultPlaceholder;
27 | const finalAriaLabel = ariaLabel || defaultAriaLabel;
28 |
29 | const { searchTerm, isSearching, handleSearch, clearSearch } = useJobSearch();
30 | const [inputValue, setInputValue] = useState(searchTerm || '');
31 |
32 | // Keep input value in sync with URL search term
33 | useEffect(() => {
34 | setInputValue(searchTerm || '');
35 | }, [searchTerm]);
36 |
37 | // Handle input changes without triggering search on every keystroke
38 | const onChange = (e: React.ChangeEvent) => {
39 | const value = e.target.value;
40 | setInputValue(value);
41 | // The debounced search is handled in the hook
42 | handleSearch(value);
43 | };
44 |
45 | // Handle keyboard navigation
46 | const onKeyDown = (e: React.KeyboardEvent) => {
47 | if (e.key === 'Escape') {
48 | setInputValue('');
49 | clearSearch();
50 | }
51 | // Explicitly don't search on Enter - we use debounce instead
52 | };
53 |
54 | // Handle clear button click
55 | const onClear = () => {
56 | setInputValue('');
57 | clearSearch();
58 | };
59 |
60 | // Get hero search background color from config
61 | const heroSearchBgColor = config?.ui?.heroSearchBgColor || '';
62 |
63 | return (
64 |
65 |
66 |
76 | {inputValue && (
77 |
82 |
83 |
84 | )}
85 | {isSearching && (
86 |
89 | )}
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/lib/config/routes.ts:
--------------------------------------------------------------------------------
1 | export type RouteParams = {
2 | [key: string]: string;
3 | };
4 |
5 | export type RouteConfig = {
6 | path: string;
7 | name: string;
8 | };
9 |
10 | // Route configuration
11 | export const routes: RouteConfig[] = [
12 | {
13 | path: '/',
14 | name: 'Home',
15 | },
16 | {
17 | path: '/jobs',
18 | name: 'Jobs',
19 | },
20 | {
21 | path: '/jobs/[slug]',
22 | name: 'Job Details',
23 | },
24 | {
25 | path: '/jobs/type/[type]',
26 | name: 'Job Type',
27 | },
28 | {
29 | path: '/jobs/level/[level]',
30 | name: 'Career Level',
31 | },
32 | {
33 | path: '/jobs/language/[language]',
34 | name: 'Language',
35 | },
36 | {
37 | path: '/jobs/location/[location]',
38 | name: 'Location',
39 | },
40 | {
41 | path: '/jobs/types',
42 | name: 'Job Types',
43 | },
44 | {
45 | path: '/jobs/levels',
46 | name: 'Career Levels',
47 | },
48 | {
49 | path: '/jobs/languages',
50 | name: 'Languages',
51 | },
52 | {
53 | path: '/jobs/locations',
54 | name: 'Job Locations',
55 | },
56 | {
57 | path: '/pricing',
58 | name: 'Pricing',
59 | },
60 | {
61 | path: '/about',
62 | name: 'About',
63 | },
64 | {
65 | path: '/contact',
66 | name: 'Contact',
67 | },
68 | {
69 | path: '/faq',
70 | name: 'FAQ',
71 | },
72 | {
73 | path: '/job-alerts',
74 | name: 'Job Alerts',
75 | },
76 | {
77 | path: '/changelog',
78 | name: 'Changelog',
79 | },
80 | ];
81 |
82 | // Helper function to match a path against route configurations
83 | export function matchRoute(path: string): RouteConfig | undefined {
84 | return routes.find((route) => {
85 | // Check if route has dynamic parameters (contains [param] syntax)
86 | if (route.path.includes('[')) {
87 | // Convert route path to regex pattern
88 | const pattern = route.path
89 | .replace(/\[([^\]]+)\]/g, '([^/]+)') // Replace [param] with regex capture group
90 | .replace(/\//g, '\\/'); // Escape forward slashes
91 | const regex = new RegExp(`^${pattern}$`);
92 | return regex.test(path);
93 | }
94 | return route.path === path;
95 | });
96 | }
97 |
98 | // Helper function to extract params from dynamic route
99 | export function extractParams(
100 | path: string,
101 | route: RouteConfig
102 | ): Record {
103 | // If the route doesn't contain dynamic parameters, return empty object
104 | if (!route.path.includes('[')) {
105 | return {};
106 | }
107 |
108 | const pattern = route.path
109 | .replace(/\[([^\]]+)\]/g, '([^/]+)') // Replace [param] with regex capture group
110 | .replace(/\//g, '\\/'); // Escape forward slashes
111 | const regex = new RegExp(`^${pattern}$`);
112 | const matches = path.match(regex);
113 |
114 | if (!matches) {
115 | return {};
116 | }
117 |
118 | const params: Record = {};
119 | const paramNames =
120 | route.path.match(/\[([^\]]+)\]/g)?.map((p) => p.slice(1, -1)) || [];
121 |
122 | paramNames.forEach((name, index) => {
123 | params[name] = matches[index + 1];
124 | });
125 |
126 | return params;
127 | }
128 |
--------------------------------------------------------------------------------
/components/ui/similar-jobs.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import type { Job } from '@/lib/db/airtable';
3 | import { generateJobSlug } from '@/lib/utils/slugify';
4 |
5 | type SimilarJobsProps = {
6 | currentJob: Job;
7 | allJobs: Job[];
8 | };
9 |
10 | export function SimilarJobs({ currentJob, allJobs }: SimilarJobsProps) {
11 | // Filter similar jobs based on title, location, or company
12 | const similarJobs = allJobs
13 | .filter((job) => {
14 | // Exclude current job
15 | if (job.id === currentJob.id) {
16 | return false;
17 | }
18 |
19 | // Check if job title contains similar keywords or is in same location
20 | const titleWords = currentJob.title.toLowerCase().split(' ');
21 | const jobTitleLower = job.title.toLowerCase();
22 | const isSimilarTitle = titleWords.some(
23 | (word) => word.length > 3 && jobTitleLower.includes(word)
24 | );
25 |
26 | // Compare workplace location
27 | const isSameLocation =
28 | (job.workplace_type === 'Remote' &&
29 | currentJob.workplace_type === 'Remote') ||
30 | (job.workplace_city &&
31 | currentJob.workplace_city &&
32 | job.workplace_city === currentJob.workplace_city) ||
33 | (job.workplace_country &&
34 | currentJob.workplace_country &&
35 | job.workplace_country === currentJob.workplace_country);
36 |
37 | return isSimilarTitle || isSameLocation;
38 | })
39 | .slice(0, 5); // Show max 5 similar jobs
40 |
41 | if (similarJobs.length === 0) {
42 | return null;
43 | }
44 |
45 | return (
46 |
47 |
Similar Jobs
48 |
49 | {similarJobs.map((job) => {
50 | // Format location based on workplace type
51 | const location =
52 | job.workplace_type === 'Remote'
53 | ? job.remote_region
54 | ? `Remote (${job.remote_region})`
55 | : null
56 | : job.workplace_type === 'Hybrid'
57 | ? [
58 | job.workplace_city,
59 | job.workplace_country,
60 | job.remote_region ? `Hybrid (${job.remote_region})` : null,
61 | ]
62 | .filter(Boolean)
63 | .join(', ') || null
64 | : [job.workplace_city, job.workplace_country]
65 | .filter(Boolean)
66 | .join(', ') || null;
67 |
68 | return (
69 |
74 |
{job.title}
75 |
76 | {job.company}
77 | •
78 | {job.type}
79 | {location && (
80 | <>
81 | •
82 | {location}
83 | >
84 | )}
85 |
86 |
87 | );
88 | })}
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react';
2 | import type React from 'react';
3 | import { forwardRef } from 'react';
4 | import { type ButtonProps, buttonVariants } from '@/components/ui/button';
5 | import { cn } from '@/lib/utils';
6 |
7 | const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
8 |
13 | );
14 | Pagination.displayName = 'Pagination';
15 |
16 | const PaginationContent = forwardRef<
17 | HTMLUListElement,
18 | React.ComponentProps<'ul'>
19 | >(({ className, ...props }, ref) => (
20 |
25 | ));
26 | PaginationContent.displayName = 'PaginationContent';
27 |
28 | const PaginationItem = forwardRef>(
29 | ({ className, ...props }, ref) => (
30 |
31 | )
32 | );
33 | PaginationItem.displayName = 'PaginationItem';
34 |
35 | type PaginationLinkProps = {
36 | isActive?: boolean;
37 | } & Pick &
38 | React.ComponentProps<'a'>;
39 |
40 | const PaginationLink = ({
41 | className,
42 | isActive,
43 | size = 'icon',
44 | ...props
45 | }: PaginationLinkProps) => (
46 |
58 | );
59 | PaginationLink.displayName = 'PaginationLink';
60 |
61 | const PaginationPrevious = ({
62 | className,
63 | ...props
64 | }: React.ComponentProps) => (
65 |
71 |
72 | Previous
73 |
74 | );
75 | PaginationPrevious.displayName = 'PaginationPrevious';
76 |
77 | const PaginationNext = ({
78 | className,
79 | ...props
80 | }: React.ComponentProps) => (
81 |
87 | Next
88 |
89 |
90 | );
91 | PaginationNext.displayName = 'PaginationNext';
92 |
93 | const PaginationEllipsis = ({
94 | className,
95 | ...props
96 | }: React.ComponentProps<'span'>) => (
97 |
102 |
103 | More pages
104 |
105 | );
106 | PaginationEllipsis.displayName = 'PaginationEllipsis';
107 |
108 | export {
109 | Pagination,
110 | PaginationContent,
111 | PaginationLink,
112 | PaginationItem,
113 | PaginationPrevious,
114 | PaginationNext,
115 | PaginationEllipsis,
116 | };
117 |
--------------------------------------------------------------------------------
/components/jobs/CompactJobCard.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import Link from 'next/link';
4 | import { JobBadge } from '@/components/ui/job-badge';
5 | import { formatSalary, type Job } from '@/lib/db/airtable';
6 | import { formatDate } from '@/lib/utils/formatDate';
7 | import { generateJobSlug } from '@/lib/utils/slugify';
8 |
9 | export function CompactJobCard({ job }: { job: Job }) {
10 | const { relativeTime } = formatDate(job.posted_date);
11 | const showSalary =
12 | job.salary && (job.salary.min !== null || job.salary.max !== null);
13 |
14 | // Check if job was posted within the last 48 hours
15 | const isNew = () => {
16 | const now = new Date();
17 | const postedDate = new Date(job.posted_date);
18 | const diffInHours = Math.floor(
19 | (now.getTime() - postedDate.getTime()) / (1000 * 60 * 60)
20 | );
21 | return diffInHours <= 48;
22 | };
23 |
24 | // Simplify location to just the type
25 | const workplaceType = job.workplace_type || '';
26 |
27 | // Limit title length to prevent layout issues
28 | const limitedTitle =
29 | job.title.length > 40 ? `${job.title.substring(0, 40)}...` : job.title;
30 |
31 | return (
32 |
38 |
39 | {/* Title and badges */}
40 |
41 |
42 |
43 | {limitedTitle}
44 |
45 |
46 | {isNew() && (
47 |
51 | New
52 |
53 | )}
54 | {job.featured && (
55 |
59 | Featured
60 |
61 | )}
62 |
63 |
64 |
65 | {/* Company */}
66 |
67 | {job.company}
68 |
69 |
70 |
71 | {/* Essential info */}
72 |
73 | {job.type}
74 | {showSalary && (
75 | <>
76 | •
77 |
78 | {formatSalary(job.salary, true)}
79 |
80 | >
81 | )}
82 | {workplaceType && (
83 | <>
84 | •
85 | {workplaceType}
86 | >
87 | )}
88 | •
89 | {relativeTime}
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/app/jobs/type/[type]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { notFound } from 'next/navigation';
3 | import { JobsLayout } from '@/components/jobs/JobsLayout';
4 | import { HeroSection } from '@/components/ui/hero-section';
5 | import { JobSearchInput } from '@/components/ui/job-search-input';
6 | import config from '@/config';
7 | import {
8 | JOB_TYPE_DESCRIPTIONS,
9 | JOB_TYPE_DISPLAY_NAMES,
10 | type JobType,
11 | } from '@/lib/constants/job-types';
12 | import { getJobs } from '@/lib/db/airtable';
13 | import { generateMetadata as createMetadata } from '@/lib/utils/metadata';
14 |
15 | // Revalidate page every 5 minutes
16 | export const revalidate = 300;
17 |
18 | type Props = {
19 | params: Promise<{
20 | type: string;
21 | }>;
22 | };
23 |
24 | /**
25 | * Convert URL slug to job type
26 | */
27 | function getJobTypeFromSlug(slug: string): JobType | null {
28 | const normalized = slug.toLowerCase();
29 | const match = Object.entries(JOB_TYPE_DISPLAY_NAMES).find(
30 | ([key]) => key.toLowerCase() === normalized
31 | );
32 | return match ? (match[0] as JobType) : null;
33 | }
34 |
35 | // Generate metadata for SEO
36 | export async function generateMetadata({ params }: Props): Promise {
37 | // Await the entire params object first
38 | const resolvedParams = await params;
39 | const typeSlug = decodeURIComponent(resolvedParams.type).toLowerCase();
40 | const jobType = getJobTypeFromSlug(typeSlug);
41 |
42 | if (!jobType) {
43 | return {
44 | title: `Job Type Not Found | ${config.title}`,
45 | description: "The job type you're looking for doesn't exist.",
46 | };
47 | }
48 |
49 | const displayName = JOB_TYPE_DISPLAY_NAMES[jobType];
50 | const description = JOB_TYPE_DESCRIPTIONS[jobType];
51 |
52 | return createMetadata({
53 | title: `${displayName} Jobs | ${config.title}`,
54 | description: `Browse ${displayName.toLowerCase()} jobs. ${description}`,
55 | path: `/jobs/type/${typeSlug}`,
56 | });
57 | }
58 |
59 | export default async function JobTypePage({ params }: Props) {
60 | const jobs = await getJobs();
61 | // Await the entire params object first
62 | const resolvedParams = await params;
63 | const typeSlug = decodeURIComponent(resolvedParams.type).toLowerCase();
64 | const jobType = getJobTypeFromSlug(typeSlug);
65 |
66 | if (!jobType) {
67 | return notFound();
68 | }
69 |
70 | const displayName = JOB_TYPE_DISPLAY_NAMES[jobType];
71 | const description = JOB_TYPE_DESCRIPTIONS[jobType];
72 |
73 | const filteredJobs = jobs.filter((job) => job.type === jobType);
74 |
75 | if (filteredJobs.length === 0) {
76 | return notFound();
77 | }
78 |
79 | return (
80 | <>
81 |
89 | {/* Search Bar */}
90 |
91 |
94 |
95 |
96 |
97 | >
98 | );
99 | }
100 |
--------------------------------------------------------------------------------
/app/jobs/level/[level]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { notFound } from 'next/navigation';
3 | import { JobsLayout } from '@/components/jobs/JobsLayout';
4 | import { HeroSection } from '@/components/ui/hero-section';
5 | import { JobSearchInput } from '@/components/ui/job-search-input';
6 | import config from '@/config';
7 | import { CAREER_LEVEL_DISPLAY_NAMES } from '@/lib/constants/career-levels';
8 | import { type CareerLevel, getJobs } from '@/lib/db/airtable';
9 | import { generateMetadata as createMetadata } from '@/lib/utils/metadata';
10 |
11 | // Revalidate page every 5 minutes
12 | export const revalidate = 300;
13 |
14 | type Props = {
15 | params: Promise<{
16 | level: string;
17 | }>;
18 | };
19 |
20 | /**
21 | * Convert URL slug to career level
22 | */
23 | function getCareerLevelFromSlug(slug: string): CareerLevel | null {
24 | const normalized = slug.toLowerCase();
25 | const match = Object.entries(CAREER_LEVEL_DISPLAY_NAMES).find(
26 | ([key]) => key.toLowerCase() === normalized
27 | );
28 | return match ? (match[0] as CareerLevel) : null;
29 | }
30 |
31 | // Generate metadata for SEO
32 | export async function generateMetadata({ params }: Props): Promise {
33 | // Await the entire params object first
34 | const resolvedParams = await params;
35 | const levelSlug = decodeURIComponent(resolvedParams.level).toLowerCase();
36 | const careerLevel = getCareerLevelFromSlug(levelSlug);
37 |
38 | if (!careerLevel || careerLevel === 'NotSpecified') {
39 | return {
40 | title: `Career Level Not Found | ${config.title}`,
41 | description: "The career level you're looking for doesn't exist.",
42 | };
43 | }
44 |
45 | const displayName = CAREER_LEVEL_DISPLAY_NAMES[careerLevel];
46 |
47 | return createMetadata({
48 | title: `${displayName} Jobs | ${config.title}`,
49 | description: `Browse jobs requiring ${displayName} experience. Find the perfect role that matches your career level.`,
50 | path: `/jobs/level/${levelSlug}`,
51 | });
52 | }
53 |
54 | export default async function CareerLevelPage({ params }: Props) {
55 | const jobs = await getJobs();
56 | // Await the entire params object first
57 | const resolvedParams = await params;
58 | const levelSlug = decodeURIComponent(resolvedParams.level).toLowerCase();
59 | const careerLevel = getCareerLevelFromSlug(levelSlug);
60 |
61 | if (!careerLevel || careerLevel === 'NotSpecified') {
62 | return notFound();
63 | }
64 |
65 | const displayName = CAREER_LEVEL_DISPLAY_NAMES[careerLevel];
66 |
67 | const filteredJobs = jobs.filter((job) =>
68 | job.career_level.includes(careerLevel)
69 | );
70 |
71 | if (filteredJobs.length === 0) {
72 | return notFound();
73 | }
74 |
75 | return (
76 | <>
77 |
85 | {/* Search Bar */}
86 |
87 |
90 |
91 |
92 |
93 | >
94 | );
95 | }
96 |
--------------------------------------------------------------------------------
/.cursor/rules/app-router-structure.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description:
3 | globs:
4 | alwaysApply: false
5 | ---
6 | # Next.js App Router Structure
7 |
8 | Bordful uses Next.js 15 with the App Router for file-based routing. Understanding this structure is crucial for navigation and adding new features.
9 |
10 | ## Core Layout
11 |
12 | - [app/layout.tsx](mdc:app/layout.tsx) - Root layout with navigation, footer, and global providers
13 | - [app/globals.css](mdc:app/globals.css) - Global styles and Tailwind CSS imports
14 | - [app/page.tsx](mdc:app/page.tsx) - Homepage with job listings
15 |
16 | ## Page Routes
17 |
18 | ### Static Pages
19 | - `app/about/` - About page
20 | - `app/contact/` - Contact form page
21 | - `app/faq/` - Frequently asked questions
22 | - `app/pricing/` - Pricing plans page
23 | - `app/terms/` - Terms of service
24 | - `app/privacy/` - Privacy policy
25 | - `app/changelog/` - Product changelog
26 |
27 | ### Job-Related Routes
28 | - `app/jobs/` - Main jobs listing page
29 | - `app/jobs/[category]/` - Generic category-based job filtering (currently unused)
30 | - `app/jobs/[slug]/` - Individual job detail pages
31 | - `app/jobs/language/[language]/` - Jobs filtered by programming language
32 | - `app/jobs/languages/` - All programming languages listing
33 | - `app/jobs/level/[level]/` - Jobs filtered by experience level
34 | - `app/jobs/levels/` - All experience levels listing
35 | - `app/jobs/location/[location]/` - Jobs filtered by location
36 | - `app/jobs/locations/` - All locations listing
37 | - `app/jobs/type/[type]/` - Jobs filtered by job type
38 | - `app/jobs/types/` - All job types listing
39 |
40 | ### Special Features
41 | - `app/job-alerts/` - Job alerts subscription page
42 |
43 | ## API Routes
44 |
45 | ### Core APIs
46 | - `app/api/subscribe/` - Email subscription endpoint for job alerts
47 | - `app/api/og/` - General Open Graph image generation
48 | - `app/api/og/jobs/[slug]/` - Open Graph image generation for job posts
49 |
50 | ## Feed Generation
51 |
52 | ### RSS/Atom/JSON Feeds
53 | - `app/feed.xml/` - RSS feed for job listings
54 | - `app/atom.xml/` - Atom feed for job listings
55 | - `app/feed.json/` - JSON feed for job listings
56 |
57 | ## SEO & Meta Files
58 |
59 | - [app/robots.ts](mdc:app/robots.ts) - Robots.txt generation
60 | - [app/sitemap.ts](mdc:app/sitemap.ts) - Dynamic sitemap generation
61 | - [app/not-found.tsx](mdc:app/not-found.tsx) - Custom 404 page
62 | - `app/favicon.ico` - Site favicon
63 |
64 | ## Dynamic Route Patterns
65 |
66 | ### Job Filtering Routes
67 | All job filtering routes follow the pattern:
68 | - `/jobs/[category]/[value]/` - Filter jobs by specific category value
69 | - `/jobs/[category]s/` - List all available values for a category
70 |
71 | ### Supported Categories
72 | - `language` - Programming languages (e.g., `/jobs/language/javascript/`)
73 | - `level` - Experience levels (e.g., `/jobs/level/senior/`)
74 | - `location` - Job locations (e.g., `/jobs/location/remote/`)
75 | - `type` - Job types (e.g., `/jobs/type/full-time/`)
76 |
77 | ## Route Generation Strategy
78 |
79 | Routes are dynamically generated based on Airtable data:
80 | 1. **Static Generation**: Most pages are statically generated at build time
81 | 2. **ISR (Incremental Static Regeneration)**: Job pages use ISR for fresh content
82 | 3. **Dynamic Imports**: Components are lazy-loaded where appropriate
83 |
84 | ## Navigation Patterns
85 |
86 | The navigation structure is defined in the configuration system and supports:
87 | - Dropdown menus
88 | - External links
89 | - Icon integration
90 | - Accessibility features
91 |
--------------------------------------------------------------------------------
/lib/constants/locations.ts:
--------------------------------------------------------------------------------
1 | import { type Country, countries } from './countries';
2 | import type { RemoteRegion, WorkplaceType } from './workplace';
3 |
4 | export type LocationType = 'remote' | Country;
5 |
6 | export type LocationCounts = {
7 | countries: Partial>;
8 | cities: Record;
9 | remote: number;
10 | };
11 |
12 | /**
13 | * Formats a location string for display
14 | * @param location Location string to format
15 | * @returns Formatted location string
16 | */
17 | export function formatLocationTitle(location: string): string {
18 | // Handle remote case
19 | if (location.toLowerCase() === 'remote') {
20 | return 'Remote';
21 | }
22 |
23 | // For countries, ensure we use the official name from our countries list
24 | const matchedCountry = countries.find(
25 | (country) => country.toLowerCase() === location.toLowerCase()
26 | );
27 |
28 | if (matchedCountry) {
29 | return matchedCountry; // Use the exact casing from our countries list
30 | }
31 |
32 | // For cities or other locations, use title case
33 | return location
34 | .toLowerCase()
35 | .split(' ')
36 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
37 | .join(' ');
38 | }
39 |
40 | /**
41 | * Creates a URL-friendly slug from a location string
42 | * @param location Location string to convert to slug
43 | * @returns URL-friendly slug
44 | */
45 | export function createLocationSlug(location: string): string {
46 | if (!location) {
47 | return '';
48 | }
49 |
50 | return location
51 | .toLowerCase()
52 | .replace(/[^\w\s-]/g, '') // Remove special characters
53 | .replace(/\s+/g, '-') // Replace spaces with hyphens
54 | .replace(/-+/g, '-'); // Replace multiple hyphens with single hyphen
55 | }
56 |
57 | /**
58 | * Converts a URL slug back to a country name
59 | * @param slug URL slug to convert
60 | * @returns Matched country or null if not found
61 | */
62 | export function getCountryFromSlug(slug: string): Country | null {
63 | if (!slug) {
64 | return null;
65 | }
66 |
67 | // Handle remote case
68 | if (slug.toLowerCase() === 'remote') {
69 | return null;
70 | }
71 |
72 | // Find matching country by comparing slugs
73 | const matchedCountry = countries.find(
74 | (country) => createLocationSlug(country) === slug.toLowerCase()
75 | );
76 |
77 | return matchedCountry || null;
78 | }
79 |
80 | /**
81 | * Formats a complete location string based on workplace settings
82 | */
83 | export function formatLocation({
84 | workplace_type,
85 | remote_region,
86 | workplace_city,
87 | workplace_country,
88 | }: {
89 | workplace_type: WorkplaceType;
90 | remote_region: RemoteRegion;
91 | workplace_city: string | null;
92 | workplace_country: string | null;
93 | }): string {
94 | // Handle remote work
95 | if (workplace_type === 'Remote') {
96 | return `Remote (${remote_region || 'Worldwide'})`;
97 | }
98 |
99 | // Build location string
100 | const locationParts = [
101 | workplace_city && formatLocationTitle(workplace_city),
102 | workplace_country && formatLocationTitle(workplace_country),
103 | ].filter(Boolean);
104 |
105 | const locationString =
106 | locationParts.length > 0 ? locationParts.join(', ') : 'Not specified';
107 |
108 | // Add hybrid indicator if applicable
109 | if (workplace_type === 'Hybrid') {
110 | return `${locationString} - Hybrid${
111 | remote_region ? ` (${remote_region})` : ''
112 | }`;
113 | }
114 |
115 | return locationString;
116 | }
117 |
--------------------------------------------------------------------------------
/app/job-alerts/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { redirect } from 'next/navigation';
3 | import { JobAlertsForm } from '@/components/job-alerts/JobAlertsForm';
4 | import { CompactJobCardList } from '@/components/jobs/CompactJobCardList';
5 | import { HeroSection } from '@/components/ui/hero-section';
6 | import { MetadataBreadcrumb } from '@/components/ui/metadata-breadcrumb';
7 | import config from '@/config';
8 | import { LATEST_JOBS_COUNT } from '@/lib/constants/defaults';
9 | import { getJobs } from '@/lib/db/airtable';
10 |
11 | // Add metadata for SEO
12 | export const metadata: Metadata = {
13 | title: 'Job Alerts | Get Notified of New Opportunities',
14 | description:
15 | config.jobAlerts.form?.description ||
16 | 'Subscribe to job alerts and get notified when new opportunities are posted.',
17 | keywords:
18 | 'job alerts, job notifications, career alerts, employment updates, job subscription',
19 | openGraph: {
20 | title: 'Job Alerts | Get Notified of New Opportunities',
21 | description:
22 | config.jobAlerts.form?.description ||
23 | 'Subscribe to job alerts and get notified when new opportunities are posted.',
24 | type: 'website',
25 | url: `${config.url}/job-alerts`,
26 | },
27 | twitter: {
28 | card: 'summary_large_image',
29 | title: 'Job Alerts | Get Notified of New Opportunities',
30 | description:
31 | config.jobAlerts.form?.description ||
32 | 'Subscribe to job alerts and get notified when new opportunities are posted.',
33 | },
34 | alternates: {
35 | canonical: `${config.url}/job-alerts`,
36 | languages: {
37 | en: `${config.url}/job-alerts`,
38 | 'x-default': `${config.url}/job-alerts`,
39 | },
40 | },
41 | };
42 |
43 | // Revalidate every 5 minutes
44 | export const revalidate = 300;
45 |
46 | export default async function JobAlertsPage() {
47 | // Redirect to home page if job alerts feature is disabled
48 | if (!config.jobAlerts?.enabled) {
49 | redirect('/');
50 | }
51 |
52 | // Fetch the latest jobs
53 | const allJobs = await getJobs();
54 | const latestJobs = allJobs.slice(0, LATEST_JOBS_COUNT); // Show latest jobs
55 |
56 | return (
57 |
58 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 | {/* Job alerts form */}
75 |
76 |
77 | {config.jobAlerts.form?.heading || 'Subscribe for Updates'}
78 |
79 |
80 | {config.jobAlerts.form?.description ||
81 | "Get notified when new jobs are posted. We'll also subscribe you to Bordful newsletter."}
82 |
83 |
84 |
85 |
86 | {/* Latest jobs */}
87 |
88 |
89 |
90 |
91 |
92 |
93 | );
94 | }
95 |
--------------------------------------------------------------------------------
/app/api/subscribe/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import config from '@/config';
3 | import { RATE_LIMIT_WINDOW_MS } from '@/lib/constants/defaults';
4 | import { emailProvider } from '@/lib/email';
5 |
6 | // Prevent caching
7 | export const dynamic = 'force-dynamic';
8 |
9 | // Simple in-memory rate limiter
10 | // In a production environment, you would use Redis or another distributed cache
11 | type RateLimitInfo = {
12 | count: number;
13 | resetTime: number;
14 | };
15 |
16 | // Store IP addresses and their request counts
17 | // This will be reset when the server restarts
18 | const rateLimitMap = new Map();
19 |
20 | // Rate limit configuration
21 | const RATE_LIMIT_MAX = 5; // Maximum requests per window
22 |
23 | // Email validation regex
24 | const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
25 |
26 | // Rate limiting function
27 | function isRateLimited(ip: string): boolean {
28 | const now = Date.now();
29 | const rateLimitInfo = rateLimitMap.get(ip);
30 |
31 | if (!rateLimitInfo) {
32 | // First request from this IP
33 | rateLimitMap.set(ip, {
34 | count: 1,
35 | resetTime: now + RATE_LIMIT_WINDOW_MS,
36 | });
37 | return false;
38 | }
39 |
40 | if (now > rateLimitInfo.resetTime) {
41 | // Reset window has passed
42 | rateLimitMap.set(ip, {
43 | count: 1,
44 | resetTime: now + RATE_LIMIT_WINDOW_MS,
45 | });
46 | return false;
47 | }
48 |
49 | // Increment count
50 | rateLimitInfo.count += 1;
51 | rateLimitMap.set(ip, rateLimitInfo);
52 |
53 | // Check if over limit
54 | return rateLimitInfo.count > RATE_LIMIT_MAX;
55 | }
56 |
57 | export async function POST(request: Request) {
58 | try {
59 | // Check if job alerts feature is enabled
60 | if (!config.jobAlerts?.enabled) {
61 | return NextResponse.json(
62 | { error: 'Job alerts feature is disabled' },
63 | { status: 404 }
64 | );
65 | }
66 |
67 | // Get client IP with fallback for development
68 | const clientIp =
69 | request.headers.get('x-forwarded-for')?.split(',')[0].trim() ||
70 | request.headers.get('x-real-ip') ||
71 | (process.env.NODE_ENV === 'development' ? '203.0.113.1' : 'unknown');
72 |
73 | // Check rate limit
74 | if (isRateLimited(clientIp)) {
75 | return NextResponse.json(
76 | { error: 'Too many requests. Please try again later.' },
77 | { status: 429 }
78 | );
79 | }
80 |
81 | // Get and validate form data
82 | const { name, email } = await request.json();
83 |
84 | // Validate email format with a more comprehensive check
85 | if (!(email && EMAIL_REGEX.test(email))) {
86 | return NextResponse.json(
87 | { error: 'Valid email address is required' },
88 | { status: 400 }
89 | );
90 | }
91 |
92 | // Validate name is provided and not empty
93 | if (!name || name.trim() === '') {
94 | return NextResponse.json({ error: 'Name is required' }, { status: 400 });
95 | }
96 |
97 | // Subscribe the user
98 | await emailProvider.subscribe({
99 | email,
100 | name,
101 | ip: clientIp,
102 | metadata: {
103 | source: 'website-form',
104 | userAgent: request.headers.get('user-agent'),
105 | referer: request.headers.get('referer'),
106 | origin: request.headers.get('origin'),
107 | },
108 | });
109 |
110 | return NextResponse.json({ success: true });
111 | } catch (error) {
112 | const errorMessage =
113 | error instanceof Error ? error.message : 'Unknown error';
114 |
115 | return NextResponse.json(
116 | {
117 | error: errorMessage,
118 | },
119 | { status: 500 }
120 | );
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/lib/utils/font-utils.ts:
--------------------------------------------------------------------------------
1 | import { FONT_URL_REGEX, WHITESPACE_REGEX } from '@/lib/constants/defaults';
2 |
3 | export type ImageResponseFont = {
4 | name: string;
5 | data: ArrayBuffer | Uint8Array;
6 | weight?: number;
7 | style?: string;
8 | };
9 |
10 | export type FontInfo = {
11 | familyName: string;
12 | nameToLoad: string;
13 | };
14 |
15 | /**
16 | * Get font family information based on font family string
17 | */
18 | export function getFontInfo(fontFamily: string): FontInfo {
19 | switch (fontFamily) {
20 | case 'inter':
21 | return {
22 | familyName: 'Inter',
23 | nameToLoad: 'Inter',
24 | };
25 | case 'ibm-plex-serif':
26 | return {
27 | familyName: 'IBM Plex Serif',
28 | nameToLoad: 'IBM Plex Serif',
29 | };
30 | default: // geist or others
31 | return {
32 | familyName: '',
33 | nameToLoad: '',
34 | };
35 | }
36 | }
37 |
38 | /**
39 | * Load font data from Google Fonts for a family subset
40 | */
41 | export async function loadGoogleFontData(
42 | fontFamily: string,
43 | text: string
44 | ): Promise {
45 | // Replace spaces for URL compatibility
46 | const fontNameForUrl = fontFamily.replace(WHITESPACE_REGEX, '+');
47 | // Fetch CSS for the family, subset by text, WITHOUT specifying weight
48 | const url = `https://fonts.googleapis.com/css2?family=${fontNameForUrl}&text=${encodeURIComponent(
49 | text
50 | )}`;
51 |
52 | try {
53 | const cssResponse = await fetch(url);
54 | if (!cssResponse.ok) {
55 | return null;
56 | }
57 | const css = await cssResponse.text();
58 |
59 | // Extract the first compatible (TTF/OTF) font URL
60 | const resource = css.match(FONT_URL_REGEX);
61 |
62 | if (!resource?.[1]) {
63 | return null;
64 | }
65 |
66 | // Fetch the actual font file
67 | const fontResponse = await fetch(resource[1]);
68 | if (!fontResponse.ok) {
69 | return null;
70 | }
71 |
72 | return await fontResponse.arrayBuffer();
73 | } catch (error: unknown) {
74 | console.error('Error loading Google Font:', error);
75 | return null;
76 | }
77 | }
78 |
79 | /**
80 | * Prepare fonts array for ImageResponse
81 | */
82 | export function prepareImageResponseFonts(
83 | fontFamily: string,
84 | fontData: ArrayBuffer | null,
85 | textToRender: string
86 | ): ImageResponseFont[] {
87 | const fonts: ImageResponseFont[] = [];
88 |
89 | const fontInfo = getFontInfo(fontFamily);
90 |
91 | // Load font data if needed
92 | if (fontInfo.nameToLoad && fontData) {
93 | fonts.push({
94 | name: fontInfo.familyName,
95 | data: fontData,
96 | weight: 800, // For title
97 | style: 'normal',
98 | });
99 | fonts.push({
100 | name: fontInfo.familyName,
101 | data: fontData,
102 | weight: 400, // For description
103 | style: 'normal',
104 | });
105 | } else {
106 | // Use system fonts as fallback
107 | fonts.push({
108 | name: 'system-ui',
109 | data: new ArrayBuffer(0), // Empty buffer for system fonts
110 | weight: 800,
111 | });
112 | fonts.push({
113 | name: 'system-ui',
114 | data: new ArrayBuffer(0),
115 | weight: 400,
116 | });
117 | }
118 |
119 | return fonts;
120 | }
121 |
122 | /**
123 | * Get font family CSS string for rendering
124 | */
125 | export function getFontFamilyCSS(
126 | fontFamily: string,
127 | fontInfo: FontInfo,
128 | fonts: ImageResponseFont[]
129 | ): string {
130 | if (fontInfo.familyName && fonts.length > 0) {
131 | return fontInfo.familyName;
132 | }
133 |
134 | if (fontFamily === 'ibm-plex-serif') {
135 | return 'IBM Plex Serif, serif';
136 | }
137 |
138 | return 'system-ui, -apple-system, sans-serif';
139 | }
140 |
--------------------------------------------------------------------------------
/lib/utils/metadata.ts:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import type { BreadcrumbList, ListItem, WithContext } from 'schema-dts';
3 | import config from '@/config';
4 |
5 | type OpenGraphType =
6 | | 'website'
7 | | 'article'
8 | | 'book'
9 | | 'profile'
10 | | 'music.song'
11 | | 'music.album'
12 | | 'music.playlist'
13 | | 'music.radio_station'
14 | | 'video.movie'
15 | | 'video.episode'
16 | | 'video.tv_show'
17 | | 'video.other';
18 |
19 | type MetadataParams = {
20 | title: string;
21 | description: string;
22 | path: string;
23 | openGraph?: {
24 | title?: string;
25 | description?: string;
26 | type?: OpenGraphType;
27 | images?: Array<{
28 | url: string;
29 | width: number;
30 | height: number;
31 | alt: string;
32 | }>;
33 | };
34 | };
35 |
36 | type BreadcrumbItem = {
37 | name: string;
38 | url: string;
39 | };
40 |
41 | /**
42 | * Generates consistent metadata with proper hreflang tags for any page
43 | * @param params Metadata parameters including title, description, and path
44 | * @returns Next.js Metadata object with proper hreflang tags
45 | */
46 | export function generateMetadata({
47 | title,
48 | description,
49 | path,
50 | openGraph,
51 | }: MetadataParams): Metadata {
52 | // Ensure path starts with a slash
53 | const normalizedPath = path.startsWith('/') ? path : `/${path}`;
54 |
55 | // Create full URLs for hreflang tags
56 | const pageUrl = `${config.url}${normalizedPath}`;
57 |
58 | // Build the Twitter metadata object
59 | const twitterMetadata: Record = {
60 | card: 'summary_large_image',
61 | title,
62 | description,
63 | };
64 |
65 | // Only add site handle if Twitter is enabled and URL is provided
66 | if (config.nav.twitter.show && config.nav.twitter.url) {
67 | const twitterUrl = config.nav.twitter.url;
68 | const twitterHandle = twitterUrl.split('/').pop();
69 | if (twitterHandle) {
70 | twitterMetadata.site = `@${twitterHandle}`;
71 | }
72 | }
73 |
74 | return {
75 | title,
76 | description,
77 | alternates: {
78 | canonical: normalizedPath,
79 | languages: {
80 | en: pageUrl,
81 | 'x-default': pageUrl,
82 | },
83 | },
84 | openGraph: {
85 | title: openGraph?.title || title,
86 | description: openGraph?.description || description,
87 | type: openGraph?.type || 'website',
88 | url: pageUrl,
89 | ...(openGraph?.images && { images: openGraph.images }),
90 | },
91 | twitter: twitterMetadata,
92 | };
93 | }
94 |
95 | /**
96 | * Generates schema.org breadcrumb markup for SEO
97 | * @param items Array of breadcrumb items with name and URL
98 | * @returns JSON string of schema.org breadcrumb markup
99 | */
100 | export function generateBreadcrumbSchema(items: BreadcrumbItem[]): string {
101 | type SchemaListItem = {
102 | '@type': string;
103 | position: number;
104 | name: string;
105 | item?: string;
106 | };
107 |
108 | // Create type-safe schema using schema-dts
109 | const breadcrumbSchema: WithContext = {
110 | '@context': 'https://schema.org',
111 | '@type': 'BreadcrumbList',
112 | itemListElement: items.map((item, index) => {
113 | const isLastItem = index === items.length - 1;
114 | const listItem: SchemaListItem = {
115 | '@type': 'ListItem',
116 | position: index + 1,
117 | name: item.name,
118 | };
119 |
120 | // For the last item, omit the item property entirely
121 | // For other items, use a simple URL string with the full URL
122 | if (!isLastItem) {
123 | listItem.item = `${config.url}${item.url}`;
124 | }
125 |
126 | return listItem as ListItem;
127 | }),
128 | };
129 |
130 | return JSON.stringify(breadcrumbSchema);
131 | }
132 |
--------------------------------------------------------------------------------
/app/jobs/location/[location]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { notFound } from 'next/navigation';
3 | import { JobsLayout } from '@/components/jobs/JobsLayout';
4 | import { HeroSection } from '@/components/ui/hero-section';
5 | import { JobSearchInput } from '@/components/ui/job-search-input';
6 | import config from '@/config';
7 | import { getCountryFromSlug } from '@/lib/constants/locations';
8 | import { getJobs } from '@/lib/db/airtable';
9 |
10 | // Revalidate page every 5 minutes
11 | export const revalidate = 300;
12 |
13 | type Props = {
14 | params: {
15 | location: string;
16 | };
17 | };
18 |
19 | export function generateMetadata({ params }: Props): Metadata {
20 | const locationSlug = decodeURIComponent(params.location).toLowerCase();
21 |
22 | // Handle remote case
23 | if (locationSlug === 'remote') {
24 | return {
25 | title: `Remote Jobs | ${config.title}`,
26 | description:
27 | 'Browse remote positions. Find the perfect remote role that matches your preferences.',
28 | alternates: {
29 | canonical: '/jobs/location/remote',
30 | },
31 | };
32 | }
33 |
34 | // Handle country case
35 | const countryName = getCountryFromSlug(locationSlug);
36 | if (!countryName) {
37 | return notFound();
38 | }
39 |
40 | return {
41 | title: `${countryName} Jobs | ${config.title}`,
42 | description: `Browse positions in ${countryName}. Find the perfect role that matches your location preferences.`,
43 | alternates: {
44 | canonical: `/jobs/location/${locationSlug}`,
45 | },
46 | };
47 | }
48 |
49 | export default async function JobLocationPage({ params }: Props) {
50 | const jobs = await getJobs();
51 | const locationSlug = decodeURIComponent(params.location).toLowerCase();
52 |
53 | // Handle remote jobs
54 | if (locationSlug === 'remote') {
55 | const filteredJobs = jobs.filter((job) => job.workplace_type === 'Remote');
56 | if (filteredJobs.length === 0) {
57 | return notFound();
58 | }
59 |
60 | return (
61 | <>
62 |
70 | {/* Search Bar */}
71 |
72 |
73 |
74 |
75 |
76 | >
77 | );
78 | }
79 |
80 | // Handle country-specific jobs
81 | const countryName = getCountryFromSlug(locationSlug);
82 | if (!countryName) {
83 | return notFound();
84 | }
85 |
86 | const filteredJobs = jobs.filter(
87 | (job) => job.workplace_country?.toLowerCase() === countryName.toLowerCase()
88 | );
89 |
90 | if (filteredJobs.length === 0) {
91 | return notFound();
92 | }
93 |
94 | return (
95 | <>
96 |
104 | {/* Search Bar */}
105 |
106 |
107 |
108 |
109 |
110 | >
111 | );
112 | }
113 |
--------------------------------------------------------------------------------
/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next';
2 | import { getJobs } from '@/lib/db/airtable';
3 | import { generateJobSlug } from '@/lib/utils/slugify';
4 |
5 | /**
6 | * Generate the sitemap for the website
7 | * This function runs at build time and creates a sitemap for all your pages
8 | */
9 | export default async function sitemap(): Promise {
10 | // Get the base URL from environment variable or default to localhost
11 | const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
12 |
13 | // Define your static routes
14 | const staticRoutes = [
15 | {
16 | url: baseUrl,
17 | lastModified: new Date(),
18 | changeFrequency: 'daily',
19 | priority: 1,
20 | },
21 | {
22 | url: `${baseUrl}/about`,
23 | lastModified: new Date(),
24 | changeFrequency: 'weekly',
25 | priority: 0.8,
26 | },
27 | {
28 | url: `${baseUrl}/jobs`,
29 | lastModified: new Date(),
30 | changeFrequency: 'daily',
31 | priority: 0.8,
32 | },
33 | {
34 | url: `${baseUrl}/privacy`,
35 | lastModified: new Date(),
36 | changeFrequency: 'monthly',
37 | priority: 0.5,
38 | },
39 | {
40 | url: `${baseUrl}/terms`,
41 | lastModified: new Date(),
42 | changeFrequency: 'monthly',
43 | priority: 0.5,
44 | },
45 | {
46 | url: `${baseUrl}/cookies`,
47 | lastModified: new Date(),
48 | changeFrequency: 'monthly',
49 | priority: 0.5,
50 | },
51 | {
52 | url: `${baseUrl}/changelog`,
53 | lastModified: new Date(),
54 | changeFrequency: 'weekly',
55 | priority: 0.6,
56 | },
57 | {
58 | url: `${baseUrl}/faq`,
59 | lastModified: new Date(),
60 | changeFrequency: 'weekly',
61 | priority: 0.7,
62 | },
63 | ] as MetadataRoute.Sitemap;
64 |
65 | try {
66 | // Fetch all active jobs
67 | const jobs = await getJobs();
68 |
69 | // Create sitemap entries for each job using descriptive slugs
70 | const jobRoutes = jobs
71 | .filter(
72 | (job) =>
73 | job.posted_date && !Number.isNaN(new Date(job.posted_date).getTime())
74 | )
75 | .map((job) => ({
76 | url: `${baseUrl}/jobs/${generateJobSlug(job.title, job.company)}`,
77 | lastModified: new Date(job.posted_date),
78 | changeFrequency: 'daily' as const,
79 | priority: job.featured ? 0.9 : 0.7,
80 | }));
81 |
82 | // Create sitemap entries for job category pages
83 | const uniqueTypes = [
84 | ...new Set(jobs.map((job) => job.type).filter(Boolean)),
85 | ];
86 | const typeRoutes = uniqueTypes.map((type) => ({
87 | url: `${baseUrl}/jobs/type/${type.toLowerCase().replace(/\s+/g, '-')}`,
88 | lastModified: new Date(),
89 | changeFrequency: 'daily' as const,
90 | priority: 0.6,
91 | }));
92 |
93 | // Create sitemap entries for career level pages
94 | const uniqueLevels = [
95 | ...new Set(jobs.flatMap((job) => job.career_level).filter(Boolean)),
96 | ];
97 | const levelRoutes = uniqueLevels.map((level) => ({
98 | url: `${baseUrl}/jobs/level/${level.toLowerCase()}`,
99 | lastModified: new Date(),
100 | changeFrequency: 'daily' as const,
101 | priority: 0.6,
102 | }));
103 |
104 | // Create sitemap entries for location pages
105 | const locations = jobs.reduce((acc, job) => {
106 | if (job.workplace_type === 'Remote' && !acc.includes('remote')) {
107 | acc.push('remote');
108 | }
109 | if (job.workplace_country && !acc.includes(job.workplace_country)) {
110 | acc.push(job.workplace_country);
111 | }
112 | return acc;
113 | }, [] as string[]);
114 |
115 | const locationRoutes = locations.map((location) => ({
116 | url: `${baseUrl}/jobs/location/${location
117 | .toLowerCase()
118 | .replace(/\s+/g, '-')}`,
119 | lastModified: new Date(),
120 | changeFrequency: 'daily' as const,
121 | priority: 0.6,
122 | }));
123 |
124 | // Combine all routes
125 | return [
126 | ...staticRoutes,
127 | ...jobRoutes,
128 | ...typeRoutes,
129 | ...levelRoutes,
130 | ...locationRoutes,
131 | ];
132 | } catch (_error) {
133 | // Return static routes if there's an error fetching jobs
134 | return staticRoutes;
135 | }
136 | }
137 |
--------------------------------------------------------------------------------
/public/bordful.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/jobs/types/page.tsx:
--------------------------------------------------------------------------------
1 | import { Briefcase } from 'lucide-react';
2 | import type { Metadata } from 'next';
3 | import Link from 'next/link';
4 | import { HeroSection } from '@/components/ui/hero-section';
5 | import { MetadataBreadcrumb } from '@/components/ui/metadata-breadcrumb';
6 | import config from '@/config';
7 | import {
8 | JOB_TYPE_DESCRIPTIONS,
9 | JOB_TYPE_DISPLAY_NAMES,
10 | type JobType,
11 | } from '@/lib/constants/job-types';
12 | import { getJobs } from '@/lib/db/airtable';
13 | import { generateMetadata } from '@/lib/utils/metadata';
14 |
15 | // Generate metadata for SEO
16 | export const metadata: Metadata = generateMetadata({
17 | title: `Browse Jobs by Type | ${config.title}`,
18 | description:
19 | 'Explore tech jobs by employment type. Find full-time, part-time, or contract positions that match your preferences.',
20 | path: '/jobs/types',
21 | });
22 |
23 | // Revalidate page every 5 minutes
24 | export const revalidate = 300;
25 |
26 | type TypeCardProps = {
27 | href: string;
28 | title: string;
29 | description: string;
30 | count: number;
31 | };
32 |
33 | function TypeCard({ href, title, description, count }: TypeCardProps) {
34 | return (
35 |
42 |
43 |
{title}
44 |
45 | {description}
46 |
47 |
48 | {count.toLocaleString()} {count === 1 ? 'position' : 'positions'}{' '}
49 | available
50 |
51 |
52 |
53 | );
54 | }
55 |
56 | export default async function JobTypesPage() {
57 | const jobs = await getJobs();
58 |
59 | // Aggregate job counts by type
60 | const typeCounts = jobs.reduce>>(
61 | (acc, job) => {
62 | if (job.type) {
63 | acc[job.type] = (acc[job.type] || 0) + 1;
64 | }
65 | return acc;
66 | },
67 | {}
68 | );
69 |
70 | // Sort types by job count
71 | const sortedTypes = Object.entries(typeCounts)
72 | .sort((a, b) => b[1] - a[1])
73 | .map(([type, count]) => ({
74 | type: type as JobType,
75 | title: JOB_TYPE_DISPLAY_NAMES[type as JobType],
76 | description: JOB_TYPE_DESCRIPTIONS[type as JobType],
77 | count,
78 | }));
79 |
80 | return (
81 | <>
82 |
88 |
89 |
90 |
91 | {/* Breadcrumbs */}
92 |
93 |
102 |
103 |
104 |
105 |
106 |
110 |
111 | Available Job Types
112 |
113 |
114 |
115 | {sortedTypes.map(({ type, title, description, count }) => (
116 |
123 | ))}
124 |
125 |
126 |
127 |
128 | >
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/components/jobs/JobCard.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUpRight, Sparkles } from 'lucide-react';
2 | import Link from 'next/link';
3 | import { Button } from '@/components/ui/button';
4 | import { JobBadge } from '@/components/ui/job-badge';
5 | import config from '@/config';
6 | import { formatSalary, type Job } from '@/lib/db/airtable';
7 | import { resolveColor } from '@/lib/utils/colors';
8 | import { formatDate } from '@/lib/utils/formatDate';
9 | import { generateJobSlug } from '@/lib/utils/slugify';
10 |
11 | export function JobCard({ job }: { job: Job }) {
12 | const { fullDate, relativeTime } = formatDate(job.posted_date);
13 | const showSalary =
14 | job.salary && (job.salary.min !== null || job.salary.max !== null);
15 |
16 | // Format location based on workplace type
17 | const location =
18 | job.workplace_type === 'Remote'
19 | ? job.remote_region
20 | ? `Remote (${job.remote_region})`
21 | : null
22 | : job.workplace_type === 'Hybrid'
23 | ? [
24 | job.workplace_city,
25 | job.workplace_country,
26 | job.remote_region ? `Hybrid (${job.remote_region})` : null,
27 | ]
28 | .filter(Boolean)
29 | .join(', ') || null
30 | : [job.workplace_city, job.workplace_country]
31 | .filter(Boolean)
32 | .join(', ') || null;
33 |
34 | // Check if job was posted within the last 48 hours
35 | const isNew = () => {
36 | const now = new Date();
37 | const postedDate = new Date(job.posted_date);
38 | const diffInHours = Math.floor(
39 | (now.getTime() - postedDate.getTime()) / (1000 * 60 * 60)
40 | );
41 | return diffInHours <= 48;
42 | };
43 |
44 | return (
45 |
46 |
54 |
55 |
56 |
57 |
58 | {job.title}
59 |
60 | {isNew() && New }
61 |
62 | {job.featured && (
63 |
}
66 | type="featured"
67 | >
68 | Featured
69 |
70 | )}
71 |
72 |
{job.company}
73 |
74 | {job.type}
75 | {showSalary && (
76 | <>
77 | •
78 |
79 | {formatSalary(job.salary, true)}
80 |
81 | >
82 | )}
83 | {location && (
84 | <>
85 | •
86 | {location}
87 | >
88 | )}
89 | •
90 |
91 | {fullDate} ({relativeTime})
92 |
93 |
94 |
95 |
96 | {job.apply_url && (
97 |
116 | )}
117 |
118 | );
119 | }
120 |
--------------------------------------------------------------------------------