diff --git a/job-board/README.md b/job-board/README.md new file mode 100644 index 0000000..c4ff55a --- /dev/null +++ b/job-board/README.md @@ -0,0 +1,117 @@ +# JobBoard + +A dynamic job board component that fetches and displays open positions from the Greenhouse API with filtering, pagination, and responsive layouts. + +## Getting Started + +Install dependencies: +```bash +npm install +``` + +Share the component to your Webflow workspace: +```bash +npx webflow library share +``` + +For local development: +```bash +npm run dev +``` + +## Designer Properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| ID | Id | — | HTML ID attribute for targeting and accessibility | +| Board Token | Text | — | Greenhouse board token for API authentication (from boards-api.greenhouse.io) | +| Heading | TextNode | Open Positions | Main heading displayed at the top of the job board | +| Subheading | Text | Join our team and help us build the future | Subheading text below the main heading | +| Layout | Variant | 3-column | Grid layout columns for job cards on desktop (2-column, 3-column, 4-column) | +| Card Style | Variant | elevated | Visual style of job cards (elevated, outlined, minimal) | +| Show Filters | Boolean | true | Show or hide the department and location filter dropdowns | +| Filter Department Label | Text | Department | Label text for the department filter dropdown | +| Filter Location Label | Text | Location | Label text for the location filter dropdown | +| Filter All Departments Text | Text | All Departments | Default option text for all departments in filter | +| Filter All Locations Text | Text | All Locations | Default option text for all locations in filter | +| Jobs Per Page | Number | 12 | Number of jobs to display per page or load batch | +| Pagination Type | Variant | load-more | How to handle multiple pages of jobs (load-more, pagination, show-all) | +| Load More Button Text | Text | Load More Jobs | Text for the Load More button | +| Previous Button Text | Text | Previous | Text for the previous page button (pagination mode) | +| Next Button Text | Text | Next | Text for the next page button (pagination mode) | +| Apply Button Text | Text | Apply Now | Text for the apply button on each job card | +| Department Label Text | Text | Department: | Label prefix for department on job cards | +| Location Label Text | Text | Location: | Label prefix for location on job cards | +| Show Department On Card | Boolean | true | Display department information on job cards | +| Show Location On Card | Boolean | true | Display location information on job cards | +| Open In New Tab | Boolean | true | Open job application links in a new browser tab | +| Loading Text | Text | Loading open positions... | Text displayed during initial data loading | +| Show Loading Spinner | Boolean | true | Show animated spinner during loading | +| Error Heading | Text | Unable to Load Jobs | Heading text for error state | +| Error Message | Text | We're having trouble loading our open positions. Please try again later or contact us directly. | Error message text when API call fails | +| Retry Button Text | Text | Try Again | Text for retry button in error state | +| Show Retry Button | Boolean | true | Show retry button in error state | +| Empty State Heading | Text | No Positions Available | Heading text when no jobs are found | +| Empty State Message | Text | There are no open positions matching your criteria at this time. Check back soon or adjust your filters. | Message text when no jobs match current filters | +| Results Count Text | Text | {count} positions found | Text template for showing job count (use {count} as placeholder) | +| Show Results Count | Boolean | true | Display the total number of jobs found | +| Cache Timeout | Number | 5 | API response cache duration in minutes (0 to disable) | + +## Styling + +This component automatically adapts to your Webflow site's design system through site variables and inherited properties. + +### Site Variables + +To match your site's design system, define these CSS variables in your Webflow project settings. The component will use the fallback values shown below until you configure them. + +| Site Variable | What It Controls | Fallback | +|---------------|------------------|----------| +| --background-primary | Main background color for cards and containers | #ffffff | +| --background-secondary | Hover states and filter backgrounds | #f5f5f5 | +| --text-primary | Main text color for headings and body text | #1a1a1a | +| --text-secondary | Secondary text for labels and meta information | #737373 | +| --border-color | Borders, dividers, and card outlines | #e5e5e5 | +| --accent-color | Primary action buttons and selected states | #1a1a1a | +| --accent-text-color | Text color on accent backgrounds | #ffffff | +| --border-radius | Corner rounding for all elements | 8px | + +### Inherited Properties + +The component inherits these CSS properties from its parent element: +- `font-family` — Typography style +- `color` — Text color +- `line-height` — Text spacing + +## Extending in Code + +### Custom Job Card Rendering + +Add custom metadata or badges to job cards by extending the card rendering logic: + +```typescript +// Add a "Featured" badge for specific departments +if (job.departments[0]?.name === 'Engineering') { + cardElement.insertAdjacentHTML('afterbegin', + 'Featured' + ); +} +``` + +### Advanced Filtering + +Implement custom filter logic beyond department and location: + +```typescript +// Filter by job type (full-time, part-time, etc.) +const filteredJobs = jobs.filter(job => { + const matchesDepartment = selectedDepartment === 'all' || + job.departments.some(d => d.name === selectedDepartment); + const matchesType = job.metadata?.employment_type === selectedType; + return matchesDepartment && matchesType; +}); +``` + +## Dependencies + +No external dependencies. \ No newline at end of file diff --git a/job-board/index.html b/job-board/index.html new file mode 100644 index 0000000..450da84 --- /dev/null +++ b/job-board/index.html @@ -0,0 +1,17 @@ + + + + + + JobBoard + + + +
+ + + diff --git a/job-board/package.json b/job-board/package.json new file mode 100644 index 0000000..877c808 --- /dev/null +++ b/job-board/package.json @@ -0,0 +1,25 @@ +{ + "name": "job-board", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.1", + "react-dom": "^19.1.1" + }, + "devDependencies": { + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "@webflow/data-types": "^1.0.1", + "@webflow/react": "^1.0.1", + "@webflow/webflow-cli": "^1.8.44", + "typescript": "~5.8.3", + "vite": "^7.1.7" + } +} \ No newline at end of file diff --git a/job-board/src/components/JobBoard/JobBoard.css b/job-board/src/components/JobBoard/JobBoard.css new file mode 100644 index 0000000..1b68cfd --- /dev/null +++ b/job-board/src/components/JobBoard/JobBoard.css @@ -0,0 +1,396 @@ +/* + * Webflow Site Variables Used: + * - --background-primary: Main background color for cards and containers + * - --background-secondary: Hover states and filter backgrounds + * - --text-primary: Main text color for headings and body text + * - --text-secondary: Secondary text for labels and meta information + * - --border-color: Borders, dividers, and card outlines + * - --accent-color: Primary action buttons and selected states + * - --accent-text-color: Text color on accent backgrounds + * - --border-radius: Corner rounding for all elements + */ + +/* Box sizing reset */ +.wf-jobboard *, +.wf-jobboard *::before, +.wf-jobboard *::after { + box-sizing: border-box; +} + +/* Root element - inherit Webflow typography */ +.wf-jobboard { + font-family: inherit; + color: inherit; + line-height: inherit; + --wf-jobboard-columns: 3; +} + +/* Header */ +.wf-jobboard-header { + margin-bottom: 32px; +} + +.wf-jobboard-heading { + font-size: 32px; + font-weight: 700; + margin: 0 0 8px 0; + color: var(--text-primary, #1a1a1a); +} + +.wf-jobboard-subheading { + font-size: 16px; + margin: 0; + color: var(--text-secondary, #737373); +} + +/* Filters */ +.wf-jobboard-filters { + display: flex; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.wf-jobboard-filter { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 200px; +} + +.wf-jobboard-filter-label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary, #1a1a1a); +} + +.wf-jobboard-filter-select { + padding: 10px 12px; + border: 1px solid var(--border-color, #e5e5e5); + border-radius: var(--border-radius, 8px); + background: var(--background-primary, #ffffff); + color: var(--text-primary, #1a1a1a); + font-size: 14px; + cursor: pointer; + transition: border-color 0.2s, background-color 0.2s; +} + +.wf-jobboard-filter-select:hover { + background: var(--background-secondary, #f5f5f5); +} + +.wf-jobboard-filter-select:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +/* Results count */ +.wf-jobboard-results-count { + margin-bottom: 24px; + font-size: 14px; + color: var(--text-secondary, #737373); +} + +/* Grid */ +.wf-jobboard-grid { + display: grid; + grid-template-columns: repeat(var(--wf-jobboard-columns), 1fr); + gap: 24px; + margin-bottom: 32px; +} + +@media (max-width: 1024px) { + .wf-jobboard-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .wf-jobboard-grid { + grid-template-columns: 1fr; + } +} + +/* Card base styles */ +.wf-jobboard-card { + display: flex; + flex-direction: column; + padding: 24px; + border-radius: var(--border-radius, 8px); + transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; +} + +/* Card style: elevated */ +.wf-jobboard-card-elevated { + background: var(--background-primary, #ffffff); + border: 1px solid var(--border-color, #e5e5e5); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.wf-jobboard-card-elevated:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +/* Card style: outlined */ +.wf-jobboard-card-outlined { + background: var(--background-primary, #ffffff); + border: 2px solid var(--border-color, #e5e5e5); +} + +.wf-jobboard-card-outlined:hover { + border-color: var(--accent-color, #1a1a1a); +} + +/* Card style: minimal */ +.wf-jobboard-card-minimal { + background: var(--background-secondary, #f5f5f5); + border: 1px solid transparent; +} + +.wf-jobboard-card-minimal:hover { + background: var(--background-primary, #ffffff); + border-color: var(--border-color, #e5e5e5); +} + +/* Card title */ +.wf-jobboard-card-title { + font-size: 20px; + font-weight: 600; + margin: 0 0 16px 0; + color: var(--text-primary, #1a1a1a); +} + +/* Card meta */ +.wf-jobboard-card-meta { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; + flex-grow: 1; +} + +.wf-jobboard-card-meta-item { + display: flex; + gap: 6px; + font-size: 14px; +} + +.wf-jobboard-card-meta-label { + font-weight: 500; + color: var(--text-secondary, #737373); +} + +.wf-jobboard-card-meta-value { + color: var(--text-primary, #1a1a1a); +} + +/* Card button */ +.wf-jobboard-card-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + background: var(--accent-color, #1a1a1a); + color: var(--accent-text-color, #ffffff); + border: none; + border-radius: var(--border-radius, 8px); + font-size: 14px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: opacity 0.2s, transform 0.2s; + align-self: flex-start; +} + +.wf-jobboard-card-button:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.wf-jobboard-card-button:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-jobboard-card-button:active { + transform: translateY(0); +} + +/* Pagination */ +.wf-jobboard-pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + margin-top: 32px; +} + +.wf-jobboard-load-more { + padding: 12px 24px; + background: var(--accent-color, #1a1a1a); + color: var(--accent-text-color, #ffffff); + border: none; + border-radius: var(--border-radius, 8px); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s, transform 0.2s; +} + +.wf-jobboard-load-more:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.wf-jobboard-load-more:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-jobboard-load-more:active { + transform: translateY(0); +} + +.wf-jobboard-pagination-button { + padding: 10px 20px; + background: var(--background-primary, #ffffff); + color: var(--text-primary, #1a1a1a); + border: 1px solid var(--border-color, #e5e5e5); + border-radius: var(--border-radius, 8px); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s; +} + +.wf-jobboard-pagination-button:hover { + background: var(--background-secondary, #f5f5f5); +} + +.wf-jobboard-pagination-button:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-jobboard-pagination-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.wf-jobboard-pagination-button:disabled:hover { + background: var(--background-primary, #ffffff); +} + +.wf-jobboard-pagination-info { + font-size: 14px; + color: var(--text-secondary, #737373); +} + +/* Loading state */ +.wf-jobboard-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + gap: 16px; +} + +.wf-jobboard-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-color, #e5e5e5); + border-top-color: var(--accent-color, #1a1a1a); + border-radius: 50%; + animation: wf-jobboard-spin 0.8s linear infinite; +} + +@keyframes wf-jobboard-spin { + to { + transform: rotate(360deg); + } +} + +.wf-jobboard-loading-text { + font-size: 16px; + color: var(--text-secondary, #737373); + margin: 0; +} + +/* Error state */ +.wf-jobboard-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; + gap: 16px; +} + +.wf-jobboard-error-heading { + font-size: 24px; + font-weight: 600; + margin: 0; + color: var(--text-primary, #1a1a1a); +} + +.wf-jobboard-error-message { + font-size: 16px; + margin: 0; + color: var(--text-secondary, #737373); + max-width: 500px; +} + +.wf-jobboard-retry-button { + padding: 12px 24px; + background: var(--accent-color, #1a1a1a); + color: var(--accent-text-color, #ffffff); + border: none; + border-radius: var(--border-radius, 8px); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: opacity 0.2s, transform 0.2s; + margin-top: 8px; +} + +.wf-jobboard-retry-button:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +.wf-jobboard-retry-button:focus-visible { + outline: 2px solid var(--accent-color, #1a1a1a); + outline-offset: 2px; +} + +.wf-jobboard-retry-button:active { + transform: translateY(0); +} + +/* Empty state */ +.wf-jobboard-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 64px 24px; + text-align: center; + gap: 16px; +} + +.wf-jobboard-empty-heading { + font-size: 24px; + font-weight: 600; + margin: 0; + color: var(--text-primary, #1a1a1a); +} + +.wf-jobboard-empty-message { + font-size: 16px; + margin: 0; + color: var(--text-secondary, #737373); + max-width: 500px; +} \ No newline at end of file diff --git a/job-board/src/components/JobBoard/JobBoard.tsx b/job-board/src/components/JobBoard/JobBoard.tsx new file mode 100644 index 0000000..9d3627e --- /dev/null +++ b/job-board/src/components/JobBoard/JobBoard.tsx @@ -0,0 +1,451 @@ +import { useState, useEffect, useMemo } from "react"; + +export interface JobBoardProps { + id?: string; + boardToken?: string; + heading?: React.ReactNode; + subheading?: string; + layout?: "2-column" | "3-column" | "4-column"; + cardStyle?: "elevated" | "outlined" | "minimal"; + showFilters?: boolean; + filterDepartmentLabel?: string; + filterLocationLabel?: string; + filterAllDepartmentsText?: string; + filterAllLocationsText?: string; + jobsPerPage?: number; + paginationType?: "load-more" | "pagination" | "show-all"; + loadMoreButtonText?: string; + previousButtonText?: string; + nextButtonText?: string; + applyButtonText?: string; + departmentLabelText?: string; + locationLabelText?: string; + showDepartmentOnCard?: boolean; + showLocationOnCard?: boolean; + openInNewTab?: boolean; + loadingText?: string; + showLoadingSpinner?: boolean; + errorHeading?: string; + errorMessage?: string; + retryButtonText?: string; + showRetryButton?: boolean; + emptyStateHeading?: string; + emptyStateMessage?: string; + resultsCountText?: string; + showResultsCount?: boolean; + cacheTimeout?: number; +} + +interface GreenhouseJob { + id: number; + title: string; + absolute_url: string; + location?: { + name: string; + }; + departments?: Array<{ + id: number; + name: string; + }>; + offices?: Array<{ + id: number; + name: string; + location?: string; + }>; + content?: string; + updated_at: string; + requisition_id?: string; + metadata?: Array<{ + id: number; + name: string; + value: string; + }>; +} + +interface GreenhouseResponse { + jobs: GreenhouseJob[]; +} + +const layoutColumns = { + "2-column": "2", + "3-column": "3", + "4-column": "4", +}; + +export default function JobBoard({ + id, + boardToken = "", + heading = "Open Positions", + subheading = "Join our team and help us build the future", + layout = "3-column", + cardStyle = "elevated", + showFilters = true, + filterDepartmentLabel = "Department", + filterLocationLabel = "Location", + filterAllDepartmentsText = "All Departments", + filterAllLocationsText = "All Locations", + jobsPerPage = 12, + paginationType = "load-more", + loadMoreButtonText = "Load More Jobs", + previousButtonText = "Previous", + nextButtonText = "Next", + applyButtonText = "Apply Now", + departmentLabelText = "Department:", + locationLabelText = "Location:", + showDepartmentOnCard = true, + showLocationOnCard = true, + openInNewTab = true, + loadingText = "Loading open positions...", + showLoadingSpinner = true, + errorHeading = "Unable to Load Jobs", + errorMessage = "We're having trouble loading our open positions. Please try again later or contact us directly.", + retryButtonText = "Try Again", + showRetryButton = true, + emptyStateHeading = "No Positions Available", + emptyStateMessage = "There are no open positions matching your criteria at this time. Check back soon or adjust your filters.", + resultsCountText = "{count} positions found", + showResultsCount = true, + cacheTimeout = 5, +}: JobBoardProps) { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedDepartment, setSelectedDepartment] = useState(""); + const [selectedLocation, setSelectedLocation] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + + const fetchJobs = async () => { + if (!boardToken) { + setError("Board token is required"); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const response = await fetch( + `https://boards-api.greenhouse.io/v1/boards/${boardToken}/jobs?content=true` + ); + + if (!response.ok) { + throw new Error(`Failed to fetch jobs: ${response.status}`); + } + + const data: GreenhouseResponse = await response.json(); + setJobs(data.jobs || []); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load jobs"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchJobs(); + }, [boardToken]); + + const departments = useMemo(() => { + const deptSet = new Set(); + jobs.forEach((job) => { + (job.departments || []).forEach((dept) => { + deptSet.add(dept.name); + }); + }); + return Array.from(deptSet).sort(); + }, [jobs]); + + const locations = useMemo(() => { + const locSet = new Set(); + jobs.forEach((job) => { + if (job.location?.name) { + locSet.add(job.location.name); + } + (job.offices || []).forEach((office) => { + if (office.name) { + locSet.add(office.name); + } + }); + }); + return Array.from(locSet).sort(); + }, [jobs]); + + const filteredJobs = useMemo(() => { + return jobs.filter((job) => { + if (selectedDepartment) { + const jobDepts = (job.departments || []).map((d) => d.name); + if (!jobDepts.includes(selectedDepartment)) { + return false; + } + } + + if (selectedLocation) { + const jobLocs: string[] = []; + if (job.location?.name) { + jobLocs.push(job.location.name); + } + (job.offices || []).forEach((office) => { + if (office.name) { + jobLocs.push(office.name); + } + }); + if (!jobLocs.includes(selectedLocation)) { + return false; + } + } + + return true; + }); + }, [jobs, selectedDepartment, selectedLocation]); + + const paginatedJobs = useMemo(() => { + if (paginationType === "show-all") { + return filteredJobs; + } + if (paginationType === "load-more") { + return filteredJobs.slice(0, currentPage * jobsPerPage); + } + const start = (currentPage - 1) * jobsPerPage; + return filteredJobs.slice(start, start + jobsPerPage); + }, [filteredJobs, currentPage, jobsPerPage, paginationType]); + + const totalPages = Math.ceil(filteredJobs.length / jobsPerPage); + const hasMore = paginatedJobs.length < filteredJobs.length; + + const handleLoadMore = () => { + setCurrentPage((prev) => prev + 1); + }; + + const handlePreviousPage = () => { + setCurrentPage((prev) => Math.max(1, prev - 1)); + }; + + const handleNextPage = () => { + setCurrentPage((prev) => Math.min(totalPages, prev + 1)); + }; + + const handleRetry = () => { + fetchJobs(); + }; + + const handleDepartmentChange = (e: React.ChangeEvent) => { + setSelectedDepartment(e.target.value); + setCurrentPage(1); + }; + + const handleLocationChange = (e: React.ChangeEvent) => { + setSelectedLocation(e.target.value); + setCurrentPage(1); + }; + + const getJobDepartment = (job: GreenhouseJob): string => { + return (job.departments || [])[0]?.name ?? ""; + }; + + const getJobLocation = (job: GreenhouseJob): string => { + if (job.location?.name) { + return job.location.name; + } + return (job.offices || [])[0]?.name ?? ""; + }; + + const columns = layoutColumns[layout]; + + if (loading) { + return ( +
+
+ {showLoadingSpinner && ( + + )} +

{loadingText}

+
+
+ ); + } + + if (error) { + return ( +
+
+

{errorHeading}

+

{errorMessage}

+ {showRetryButton && ( + + )} +
+
+ ); + } + + if (jobs.length === 0) { + return ( +
+
+

{emptyStateHeading}

+

{emptyStateMessage}

+
+
+ ); + } + + return ( +
+
+

{heading}

+ {subheading && ( +

{subheading}

+ )} +
+ + {showFilters && (departments.length > 0 || locations.length > 0) && ( +
+ {departments.length > 0 && ( +
+ + +
+ )} + + {locations.length > 0 && ( +
+ + +
+ )} +
+ )} + + {showResultsCount && ( +
+ {resultsCountText.replace("{count}", filteredJobs.length.toString())} +
+ )} + + {filteredJobs.length === 0 ? ( +
+

{emptyStateHeading}

+

{emptyStateMessage}

+
+ ) : ( + <> +
+ {paginatedJobs.map((job) => ( +
+

{job.title}

+ +
+ {showDepartmentOnCard && getJobDepartment(job) && ( +
+ + {departmentLabelText} + + + {getJobDepartment(job)} + +
+ )} + + {showLocationOnCard && getJobLocation(job) && ( +
+ + {locationLabelText} + + + {getJobLocation(job)} + +
+ )} +
+ + + {applyButtonText} + +
+ ))} +
+ + {paginationType === "load-more" && hasMore && ( +
+ +
+ )} + + {paginationType === "pagination" && totalPages > 1 && ( +
+ + + Page {currentPage} of {totalPages} + + +
+ )} + + )} +
+ ); +} \ No newline at end of file diff --git a/job-board/src/components/JobBoard/JobBoard.webflow.tsx b/job-board/src/components/JobBoard/JobBoard.webflow.tsx new file mode 100644 index 0000000..7b97bfb --- /dev/null +++ b/job-board/src/components/JobBoard/JobBoard.webflow.tsx @@ -0,0 +1,216 @@ +import JobBoard from "./JobBoard"; +import { props } from "@webflow/data-types"; +import { declareComponent } from "@webflow/react"; +import "./JobBoard.css"; + +export default declareComponent(JobBoard, { + name: "JobBoard", + description: "A dynamic job board component that fetches and displays open positions from the Greenhouse API. Features a grid layout of job cards, each showing the job title, department, location, and an apply link. Includes interactive department and location filter dropdowns at the top, a loading spinner during data fetch, an error state for failed API calls, and an empty state when no jobs match filters. Supports pagination with a 'Load More' button for large job lists. Cards display in a multi-column grid on desktop and stack vertically on mobile devices. The component connects to any Greenhouse account via a board token prop and handles all API communication, error handling, and responsive layout transitions automatically.", + group: "Data Display", + options: { + ssr: false, + applyTagSelectors: true + }, + props: { + id: props.Id({ + name: "Element ID", + group: "Settings", + tooltip: "HTML ID attribute for targeting and accessibility" + }), + boardToken: props.Text({ + name: "Board Token", + defaultValue: "", + group: "Settings", + tooltip: "Greenhouse board token for API authentication (from boards-api.greenhouse.io)" + }), + heading: props.TextNode({ + name: "Heading", + defaultValue: "Open Positions", + group: "Content", + tooltip: "Main heading displayed at the top of the job board" + }), + subheading: props.Text({ + name: "Subheading", + defaultValue: "Join our team and help us build the future", + group: "Content", + tooltip: "Subheading text below the main heading" + }), + layout: props.Variant({ + name: "Layout", + options: ["2-column", "3-column", "4-column"], + defaultValue: "3-column", + group: "Style", + tooltip: "Grid layout columns for job cards on desktop" + }), + cardStyle: props.Variant({ + name: "Card Style", + options: ["elevated", "outlined", "minimal"], + defaultValue: "elevated", + group: "Style", + tooltip: "Visual style of job cards" + }), + showFilters: props.Boolean({ + name: "Show Filters", + defaultValue: true, + group: "Display", + tooltip: "Show or hide the department and location filter dropdowns" + }), + filterDepartmentLabel: props.Text({ + name: "Department Filter Label", + defaultValue: "Department", + group: "Filters", + tooltip: "Label text for the department filter dropdown" + }), + filterLocationLabel: props.Text({ + name: "Location Filter Label", + defaultValue: "Location", + group: "Filters", + tooltip: "Label text for the location filter dropdown" + }), + filterAllDepartmentsText: props.Text({ + name: "All Departments Text", + defaultValue: "All Departments", + group: "Filters", + tooltip: "Default option text for all departments in filter" + }), + filterAllLocationsText: props.Text({ + name: "All Locations Text", + defaultValue: "All Locations", + group: "Filters", + tooltip: "Default option text for all locations in filter" + }), + jobsPerPage: props.Number({ + name: "Jobs Per Page", + defaultValue: 12, + group: "Behavior", + tooltip: "Number of jobs to display per page or load batch" + }), + paginationType: props.Variant({ + name: "Pagination Type", + options: ["load-more", "pagination", "show-all"], + defaultValue: "load-more", + group: "Behavior", + tooltip: "How to handle multiple pages of jobs" + }), + loadMoreButtonText: props.Text({ + name: "Load More Button Text", + defaultValue: "Load More Jobs", + group: "Pagination", + tooltip: "Text for the Load More button" + }), + previousButtonText: props.Text({ + name: "Previous Button Text", + defaultValue: "Previous", + group: "Pagination", + tooltip: "Text for the previous page button (pagination mode)" + }), + nextButtonText: props.Text({ + name: "Next Button Text", + defaultValue: "Next", + group: "Pagination", + tooltip: "Text for the next page button (pagination mode)" + }), + applyButtonText: props.Text({ + name: "Apply Button Text", + defaultValue: "Apply Now", + group: "Job Cards", + tooltip: "Text for the apply button on each job card" + }), + departmentLabelText: props.Text({ + name: "Department Label", + defaultValue: "Department:", + group: "Job Cards", + tooltip: "Label prefix for department on job cards" + }), + locationLabelText: props.Text({ + name: "Location Label", + defaultValue: "Location:", + group: "Job Cards", + tooltip: "Label prefix for location on job cards" + }), + showDepartmentOnCard: props.Boolean({ + name: "Show Department", + defaultValue: true, + group: "Job Cards", + tooltip: "Display department information on job cards" + }), + showLocationOnCard: props.Boolean({ + name: "Show Location", + defaultValue: true, + group: "Job Cards", + tooltip: "Display location information on job cards" + }), + openInNewTab: props.Boolean({ + name: "Open in New Tab", + defaultValue: true, + group: "Behavior", + tooltip: "Open job application links in a new browser tab" + }), + loadingText: props.Text({ + name: "Loading Text", + defaultValue: "Loading open positions...", + group: "Loading State", + tooltip: "Text displayed during initial data loading" + }), + showLoadingSpinner: props.Boolean({ + name: "Show Loading Spinner", + defaultValue: true, + group: "Loading State", + tooltip: "Show animated spinner during loading" + }), + errorHeading: props.Text({ + name: "Error Heading", + defaultValue: "Unable to Load Jobs", + group: "Error State", + tooltip: "Heading text for error state" + }), + errorMessage: props.Text({ + name: "Error Message", + defaultValue: "We're having trouble loading our open positions. Please try again later or contact us directly.", + group: "Error State", + tooltip: "Error message text when API call fails" + }), + retryButtonText: props.Text({ + name: "Retry Button Text", + defaultValue: "Try Again", + group: "Error State", + tooltip: "Text for retry button in error state" + }), + showRetryButton: props.Boolean({ + name: "Show Retry Button", + defaultValue: true, + group: "Error State", + tooltip: "Show retry button in error state" + }), + emptyStateHeading: props.Text({ + name: "Empty State Heading", + defaultValue: "No Positions Available", + group: "Empty State", + tooltip: "Heading text when no jobs are found" + }), + emptyStateMessage: props.Text({ + name: "Empty State Message", + defaultValue: "There are no open positions matching your criteria at this time. Check back soon or adjust your filters.", + group: "Empty State", + tooltip: "Message text when no jobs match current filters" + }), + resultsCountText: props.Text({ + name: "Results Count Text", + defaultValue: "{count} positions found", + group: "Content", + tooltip: "Text template for showing job count (use {count} as placeholder)" + }), + showResultsCount: props.Boolean({ + name: "Show Results Count", + defaultValue: true, + group: "Display", + tooltip: "Display the total number of jobs found" + }), + cacheTimeout: props.Number({ + name: "Cache Timeout", + defaultValue: 5, + group: "Behavior", + tooltip: "API response cache duration in minutes (0 to disable)" + }), + }, +}); \ No newline at end of file diff --git a/job-board/src/components/JobBoard/JobBoardSimple.webflow.tsx b/job-board/src/components/JobBoard/JobBoardSimple.webflow.tsx new file mode 100644 index 0000000..883da6f --- /dev/null +++ b/job-board/src/components/JobBoard/JobBoardSimple.webflow.tsx @@ -0,0 +1,99 @@ +import JobBoard from "./JobBoard"; +import { props } from "@webflow/data-types"; +import { declareComponent } from "@webflow/react"; +import "./JobBoard.css"; + +export default declareComponent(JobBoard, { + name: "JobBoard (Simple)", + description: "A dynamic job board component that fetches and displays open positions from the Greenhouse API. Features a grid layout of job cards, each showing the job title, department, location, and an apply link. Includes interactive department and location filter dropdowns at the top, a loading spinner during data fetch, an error state for failed API calls, and an empty state when no jobs match filters. Supports pagination with a 'Load More' button for large job lists. Cards display in a multi-column grid on desktop and stack vertically on mobile devices. The component connects to any Greenhouse account via a board token prop and handles all API communication, error handling, and responsive layout transitions automatically.", + group: "Data Display", + options: { + ssr: false, + applyTagSelectors: true + }, + props: { + id: props.Id({ + name: "Element ID", + group: "Settings", + tooltip: "HTML ID attribute for targeting and accessibility" + }), + boardToken: props.Text({ + name: "Board Token", + defaultValue: "", + group: "Settings", + tooltip: "Greenhouse board token for API authentication (from boards-api.greenhouse.io)" + }), + heading: props.TextNode({ + name: "Heading", + defaultValue: "Open Positions", + group: "Content", + tooltip: "Main heading displayed at the top of the job board" + }), + showFilters: props.Boolean({ + name: "Show Filters", + defaultValue: true, + group: "Display", + tooltip: "Show or hide the department and location filter dropdowns" + }), + applyButtonText: props.Text({ + name: "Apply Button Text", + defaultValue: "Apply Now", + group: "Job Cards", + tooltip: "Text for the apply button on each job card" + }), + showDepartmentOnCard: props.Boolean({ + name: "Show Department", + defaultValue: true, + group: "Job Cards", + tooltip: "Display department information on job cards" + }), + showLocationOnCard: props.Boolean({ + name: "Show Location", + defaultValue: true, + group: "Job Cards", + tooltip: "Display location information on job cards" + }), + openInNewTab: props.Boolean({ + name: "Open in New Tab", + defaultValue: true, + group: "Behavior", + tooltip: "Open job application links in a new browser tab" + }), + loadingText: props.Text({ + name: "Loading Text", + defaultValue: "Loading open positions...", + group: "Loading State", + tooltip: "Text displayed during initial data loading" + }), + errorHeading: props.Text({ + name: "Error Heading", + defaultValue: "Unable to Load Jobs", + group: "Error State", + tooltip: "Heading text for error state" + }), + errorMessage: props.Text({ + name: "Error Message", + defaultValue: "We're having trouble loading our open positions. Please try again later or contact us directly.", + group: "Error State", + tooltip: "Error message text when API call fails" + }), + emptyStateHeading: props.Text({ + name: "Empty State Heading", + defaultValue: "No Positions Available", + group: "Empty State", + tooltip: "Heading text when no jobs are found" + }), + emptyStateMessage: props.Text({ + name: "Empty State Message", + defaultValue: "There are no open positions matching your criteria at this time. Check back soon or adjust your filters.", + group: "Empty State", + tooltip: "Message text when no jobs match current filters" + }), + showResultsCount: props.Boolean({ + name: "Show Results Count", + defaultValue: true, + group: "Display", + tooltip: "Display the total number of jobs found" + }), + }, +}); \ No newline at end of file diff --git a/job-board/src/main.tsx b/job-board/src/main.tsx new file mode 100644 index 0000000..526fb12 --- /dev/null +++ b/job-board/src/main.tsx @@ -0,0 +1,411 @@ +import { StrictMode, useState } from "react" +import { createRoot } from "react-dom/client" +import JobBoard from "./components/JobBoard/JobBoard" +import "./components/JobBoard/JobBoard.css" + +type ThemeVars = { + '--background-primary': string + '--background-secondary': string + '--text-primary': string + '--text-secondary': string + '--border-color': string + '--accent-color': string + '--accent-text-color': string + '--border-radius': string +} + +const themes: Record = { + light: { + '--background-primary': '#ffffff', + '--background-secondary': '#f5f5f5', + '--text-primary': '#1a1a1a', + '--text-secondary': '#737373', + '--border-color': '#e5e5e5', + '--accent-color': '#2563eb', + '--accent-text-color': '#ffffff', + '--border-radius': '8px' + }, + dark: { + '--background-primary': '#0a0a0a', + '--background-secondary': '#1a1a1a', + '--text-primary': '#fafafa', + '--text-secondary': '#a3a3a3', + '--border-color': '#2a2a2a', + '--accent-color': '#3b82f6', + '--accent-text-color': '#ffffff', + '--border-radius': '8px' + }, + brand: { + '--background-primary': '#fef7f0', + '--background-secondary': '#fde8d0', + '--text-primary': '#1c1917', + '--text-secondary': '#78716c', + '--border-color': '#e7e5e4', + '--accent-color': '#ea580c', + '--accent-text-color': '#ffffff', + '--border-radius': '12px' + } +} + +function App() { + const [activeTheme, setActiveTheme] = useState<'light' | 'dark' | 'brand' | 'custom'>('light') + const [customVars, setCustomVars] = useState(themes.light) + const [boardToken, setBoardToken] = useState('acme') + + const handleThemeChange = (theme: 'light' | 'dark' | 'brand' | 'custom') => { + setActiveTheme(theme) + if (theme !== 'custom') { + setCustomVars(themes[theme]) + } + } + + const handleCustomVarChange = (varName: keyof ThemeVars, value: string) => { + setCustomVars(prev => ({ + ...prev, + [varName]: value + })) + } + + const currentVars = activeTheme === 'custom' ? customVars : themes[activeTheme] + const pageBackground = activeTheme === 'dark' ? '#000000' : activeTheme === 'brand' ? '#fef2e8' : '#fafafa' + + return ( +
+
+
+

+ JobBoard Component Preview +

+

+ Local development environment with theme preview system +

+ +
+ + + + +
+ + {activeTheme === 'custom' && ( +
+ {Object.entries(customVars).map(([key, value]) => ( +
+ + handleCustomVarChange(key as keyof ThemeVars, e.target.value)} + style={{ + padding: key === '--border-radius' ? '6px 10px' : '4px', + border: `1px solid ${currentVars['--border-color']}`, + borderRadius: '4px', + fontSize: '14px', + width: '100%', + cursor: 'pointer' + }} + /> +
+ ))} +
+ )} + +
+

+ API Configuration +

+
+ + setBoardToken(e.target.value)} + placeholder="Enter your Greenhouse board token (e.g. acme)" + style={{ + padding: '8px 12px', + border: `1px solid ${currentVars['--border-color']}`, + borderRadius: '6px', + fontSize: '14px', + width: '100%', + maxWidth: '400px', + background: currentVars['--background-primary'], + color: currentVars['--text-primary'] + }} + /> + + This is the public board token from your Greenhouse account URL + +
+
+
+ +
+
+

+ Default Configuration +

+ +
+ +
+

+ Variation: 2-Column Outlined Cards with Pagination +

+ +
+ +
+

+ Variation: 4-Column Minimal Cards, No Filters +

+ +
+ +
+

+ Variation: Compact View with Custom Labels +

+ +
+
+
+
+ ) +} + +createRoot(document.getElementById("root")!).render( + + + +) \ No newline at end of file diff --git a/job-board/src/vite-env.d.ts b/job-board/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/job-board/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/job-board/tsconfig.app.json b/job-board/tsconfig.app.json new file mode 100644 index 0000000..d775f2a --- /dev/null +++ b/job-board/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsBuildInfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/job-board/tsconfig.json b/job-board/tsconfig.json new file mode 100644 index 0000000..65f670c --- /dev/null +++ b/job-board/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} \ No newline at end of file diff --git a/job-board/tsconfig.node.json b/job-board/tsconfig.node.json new file mode 100644 index 0000000..c4a9a48 --- /dev/null +++ b/job-board/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsBuildInfo", + "target": "ES2023", + "lib": [ + "ES2023" + ], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "vite.config.ts" + ] +} \ No newline at end of file diff --git a/job-board/vite.config.ts b/job-board/vite.config.ts new file mode 100644 index 0000000..c7a4f78 --- /dev/null +++ b/job-board/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); \ No newline at end of file diff --git a/job-board/webflow.json b/job-board/webflow.json new file mode 100644 index 0000000..bc62647 --- /dev/null +++ b/job-board/webflow.json @@ -0,0 +1,10 @@ +{ + "library": { + "name": "JobBoard", + "components": [ + "./src/**/*.webflow.@(js|jsx|mjs|ts|tsx)" + ], + "description": "A dynamic job board component that fetches and displays open positions from the Greenhouse API. Features a grid layout of job cards, each showing the job title, department, location, and an apply link. Includes interactive department and location filter dropdowns at the top, a loading spinner during data fetch, an error state for failed API calls, and an empty state when no jobs match filters. Supports pagination with a 'Load More' button for large job lists. Cards display in a multi-column grid on desktop and stack vertically on mobile devices. The component connects to any Greenhouse account via a board token prop and handles all API communication, error handling, and responsive layout transitions automatically.", + "id": "job-board" + } +} \ No newline at end of file