From af949e4c00729ede597c6773d2b6a7e115f01c1c Mon Sep 17 00:00:00 2001 From: tette Date: Thu, 30 Apr 2026 23:46:04 +0000 Subject: [PATCH 1/7] db: restructure career description into proper columns Replace single description TEXT column with structured fields: - about_role, responsibilities[], nice_to_have[], what_we_offer[], how_to_apply, duration (nullable) Includes data migration to parse existing JSON descriptions. --- .../migration.sql | 51 +++++++++++++++++++ prisma/schema.prisma | 31 ++++++----- 2 files changed, 69 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260430000000_restructure_career_description/migration.sql diff --git a/prisma/migrations/20260430000000_restructure_career_description/migration.sql b/prisma/migrations/20260430000000_restructure_career_description/migration.sql new file mode 100644 index 0000000..1a247e7 --- /dev/null +++ b/prisma/migrations/20260430000000_restructure_career_description/migration.sql @@ -0,0 +1,51 @@ +-- Migration: restructure_career_description +-- Replaces the single `description` TEXT column with proper structured columns. +-- Includes a data migration that parses existing JSON descriptions into the new columns. + +-- Step 1: Add new columns (nullable first so existing rows don't fail) +ALTER TABLE "careers" + ADD COLUMN "about_role" TEXT NOT NULL DEFAULT '', + ADD COLUMN "responsibilities" TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN "nice_to_have" TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN "what_we_offer" TEXT[] NOT NULL DEFAULT '{}', + ADD COLUMN "how_to_apply" TEXT NOT NULL DEFAULT '', + ADD COLUMN "duration" TEXT; + +-- Step 2: Data migration — parse existing JSON descriptions into the new columns. +-- Rows that contain valid JSON with an "aboutRole" key are migrated field by field. +-- Rows with legacy plain text are moved into about_role as-is. +UPDATE "careers" +SET + "about_role" = CASE + WHEN description ~ '^\s*\{' AND description::jsonb ? 'aboutRole' + THEN (description::jsonb ->> 'aboutRole') + ELSE description + END, + "responsibilities" = CASE + WHEN description ~ '^\s*\{' AND description::jsonb ? 'responsibilities' + THEN ARRAY(SELECT jsonb_array_elements_text(description::jsonb -> 'responsibilities')) + ELSE '{}' + END, + "nice_to_have" = CASE + WHEN description ~ '^\s*\{' AND description::jsonb ? 'niceToHave' + THEN ARRAY(SELECT jsonb_array_elements_text(description::jsonb -> 'niceToHave')) + ELSE '{}' + END, + "what_we_offer" = CASE + WHEN description ~ '^\s*\{' AND description::jsonb ? 'whatWeOffer' + THEN ARRAY(SELECT jsonb_array_elements_text(description::jsonb -> 'whatWeOffer')) + ELSE '{}' + END, + "how_to_apply" = CASE + WHEN description ~ '^\s*\{' AND description::jsonb ? 'howToApply' + THEN (description::jsonb ->> 'howToApply') + ELSE '' + END, + "duration" = CASE + WHEN description ~ '^\s*\{' AND description::jsonb ? 'duration' + THEN NULLIF(description::jsonb ->> 'duration', '') + ELSE NULL + END; + +-- Step 3: Drop the old description column +ALTER TABLE "careers" DROP COLUMN "description"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4ce75e2..2ac8056 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -163,19 +163,24 @@ model NewsletterDeliveryLog { } model Career { - id Int @id @default(autoincrement()) - title String - company String @default("Codetopia") - type String - location String - description String @db.Text - requirements String[] - link String? - expiryDate DateTime @map("expiry_date") - isFeatured Boolean @default(false) @map("is_featured") - status String @default("open") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") + id Int @id @default(autoincrement()) + title String + company String @default("Codetopia") + type String + location String + aboutRole String @default("") @map("about_role") @db.Text + responsibilities String[] @map("responsibilities") + niceToHave String[] @map("nice_to_have") + whatWeOffer String[] @map("what_we_offer") + howToApply String @default("") @map("how_to_apply") @db.Text + duration String? + requirements String[] + link String? + expiryDate DateTime @map("expiry_date") + isFeatured Boolean @default(false) @map("is_featured") + status String @default("open") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") @@map("careers") } From 7fdfee767be8e183ec47351ee120d222b0afe6d2 Mon Sep 17 00:00:00 2001 From: tette Date: Thu, 30 Apr 2026 23:46:12 +0000 Subject: [PATCH 2/7] feat(careers): update Career type to use flat structured fields Remove JSON-based CareerDescription type and parsing helpers. Career interface now has direct fields: aboutRole, responsibilities, niceToHave, whatWeOffer, howToApply, duration. --- src/lib/careers.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/lib/careers.ts b/src/lib/careers.ts index af445fc..e1ff5a0 100644 --- a/src/lib/careers.ts +++ b/src/lib/careers.ts @@ -6,7 +6,12 @@ export interface Career { company: string; type: string; location: string; - description: string; + aboutRole: string; + responsibilities: string[]; + niceToHave: string[]; + whatWeOffer: string[]; + howToApply: string; + duration: string | null; requirements: string[]; link: string | null; expiryDate: string | Date; @@ -19,10 +24,14 @@ export interface Career { export function getCareerStatus(career: Career): CareerStatus { const now = new Date(); const expiry = new Date(career.expiryDate); - - if (career.status === "closed" || expiry < now) { - return "closed"; - } - + if (career.status === "closed" || expiry < now) return "closed"; return "open"; } + +/** + * Get a plain-text preview snippet from a career (for list views). + */ +export function getDescriptionPreview(career: Career, maxLen = 160): string { + const text = career.aboutRole || ""; + return text.length > maxLen ? `${text.slice(0, maxLen)}…` : text; +} From c8298fbcd99910a6243cbacc45cadae2f6848774 Mon Sep 17 00:00:00 2001 From: tette Date: Thu, 30 Apr 2026 23:46:20 +0000 Subject: [PATCH 3/7] feat(api): update careers API routes for structured fields POST and PUT now read/write aboutRole, responsibilities, niceToHave, whatWeOffer, howToApply, duration directly instead of description JSON. Validation updated to require aboutRole instead of description. --- src/app/api/admin/careers/[id]/route.ts | 11 +++++++++-- src/app/api/admin/careers/route.ts | 11 +++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/app/api/admin/careers/[id]/route.ts b/src/app/api/admin/careers/[id]/route.ts index d5287fb..6446c52 100644 --- a/src/app/api/admin/careers/[id]/route.ts +++ b/src/app/api/admin/careers/[id]/route.ts @@ -64,7 +64,7 @@ export async function PUT( "title", "type", "location", - "description", + "aboutRole", "requirements", "expiryDate", ]); @@ -77,7 +77,14 @@ export async function PUT( company: data.company?.trim() || "Codetopia", type: data.type.trim(), location: data.location.trim(), - description: data.description.trim(), + aboutRole: data.aboutRole?.trim() ?? "", + responsibilities: Array.isArray(data.responsibilities) + ? data.responsibilities + : [], + niceToHave: Array.isArray(data.niceToHave) ? data.niceToHave : [], + whatWeOffer: Array.isArray(data.whatWeOffer) ? data.whatWeOffer : [], + howToApply: data.howToApply?.trim() ?? "", + duration: data.duration?.trim() || null, requirements: Array.isArray(data.requirements) ? data.requirements : data.requirements.split("\n").filter((r: string) => r.trim()), diff --git a/src/app/api/admin/careers/route.ts b/src/app/api/admin/careers/route.ts index 6532f99..965b90c 100644 --- a/src/app/api/admin/careers/route.ts +++ b/src/app/api/admin/careers/route.ts @@ -40,7 +40,7 @@ export async function POST(request: Request) { "title", "type", "location", - "description", + "aboutRole", "requirements", "expiryDate", ]); @@ -52,7 +52,14 @@ export async function POST(request: Request) { company: data.company?.trim() || "Codetopia", type: data.type.trim(), location: data.location.trim(), - description: data.description.trim(), + aboutRole: data.aboutRole?.trim() ?? "", + responsibilities: Array.isArray(data.responsibilities) + ? data.responsibilities + : [], + niceToHave: Array.isArray(data.niceToHave) ? data.niceToHave : [], + whatWeOffer: Array.isArray(data.whatWeOffer) ? data.whatWeOffer : [], + howToApply: data.howToApply?.trim() ?? "", + duration: data.duration?.trim() || null, requirements: Array.isArray(data.requirements) ? data.requirements : data.requirements.split("\n").filter((r: string) => r.trim()), From afad10212f6fe7a52ab72a44a85f20dedb5b3c5e Mon Sep 17 00:00:00 2001 From: tette Date: Thu, 30 Apr 2026 23:46:28 +0000 Subject: [PATCH 4/7] feat(admin/careers): add EmploymentTypeCombobox and BulletListEditor components EmploymentTypeCombobox: dropdown with preset types + custom entry option. BulletListEditor: reusable numbered bullet list with add/edit/remove. --- .../admin/careers/BulletListEditor.tsx | 93 +++++++++++ .../admin/careers/EmploymentTypeCombobox.tsx | 156 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 src/components/admin/careers/BulletListEditor.tsx create mode 100644 src/components/admin/careers/EmploymentTypeCombobox.tsx diff --git a/src/components/admin/careers/BulletListEditor.tsx b/src/components/admin/careers/BulletListEditor.tsx new file mode 100644 index 0000000..923ff86 --- /dev/null +++ b/src/components/admin/careers/BulletListEditor.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Plus, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; + +interface BulletListEditorProps { + items: string[]; + onChange: (items: string[]) => void; + placeholder?: string; + addLabel?: string; +} + +const inputCls = + "rounded-xl border border-grey-100 bg-grey-50/50 h-11 px-4 text-xs font-medium text-black placeholder:text-grey-300 focus:border-black focus:bg-white transition-all outline-none ring-0 font-mono"; + +export function BulletListEditor({ + items, + onChange, + placeholder = "Add item...", + addLabel = "Add", +}: BulletListEditorProps) { + const [draft, setDraft] = useState(""); + + const add = () => { + const trimmed = draft.trim(); + if (!trimmed) return; + onChange([...items, trimmed]); + setDraft(""); + }; + + const remove = (idx: number) => { + onChange(items.filter((_, i) => i !== idx)); + }; + + const update = (idx: number, value: string) => { + onChange(items.map((item, i) => (i === idx ? value : item))); + }; + + return ( +
+ {/* Existing items */} + {items.length > 0 && ( +
    + {items.map((item, idx) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: ordered list +
  • + + {String(idx + 1).padStart(2, "0")} + + update(idx, e.target.value)} + className={`${inputCls} flex-1`} + /> + +
  • + ))} +
+ )} + + {/* Add new */} +
+ setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }} + placeholder={placeholder} + className={`${inputCls} flex-1`} + /> + +
+
+ ); +} diff --git a/src/components/admin/careers/EmploymentTypeCombobox.tsx b/src/components/admin/careers/EmploymentTypeCombobox.tsx new file mode 100644 index 0000000..e02e560 --- /dev/null +++ b/src/components/admin/careers/EmploymentTypeCombobox.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { ChevronDown, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; + +const PRESET_TYPES = ["Full-time", "Part-time", "Internship", "Volunteer"]; + +const inputCls = + "rounded-xl border border-grey-100 bg-grey-50/50 h-11 px-4 text-xs font-medium text-black placeholder:text-grey-300 focus:border-black focus:bg-white transition-all outline-none ring-0 font-mono w-full"; + +interface EmploymentTypeComboboxProps { + value: string; + onChange: (value: string) => void; + required?: boolean; +} + +export function EmploymentTypeCombobox({ + value, + onChange, + required, +}: EmploymentTypeComboboxProps) { + const isCustom = value !== "" && !PRESET_TYPES.includes(value); + const [open, setOpen] = useState(false); + const [customMode, setCustomMode] = useState(isCustom); + const [customInput, setCustomInput] = useState(isCustom ? value : ""); + const containerRef = useRef(null); + const customInputRef = useRef(null); + + // Sync if parent resets value (e.g. form reset) + useEffect(() => { + const nowCustom = value !== "" && !PRESET_TYPES.includes(value); + setCustomMode(nowCustom); + if (nowCustom) setCustomInput(value); + else if (value === "") setCustomInput(""); + }, [value]); + + // Close dropdown on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + // Focus custom input when switching to custom mode + useEffect(() => { + if (customMode) customInputRef.current?.focus(); + }, [customMode]); + + const selectPreset = (type: string) => { + setCustomMode(false); + setCustomInput(""); + onChange(type); + setOpen(false); + }; + + const enterCustomMode = () => { + setCustomMode(true); + setCustomInput(""); + onChange(""); + setOpen(false); + }; + + const clearCustom = () => { + setCustomMode(false); + setCustomInput(""); + onChange(""); + }; + + if (customMode) { + return ( +
+ { + setCustomInput(e.target.value); + onChange(e.target.value); + }} + placeholder="e.g. Contract, Freelance..." + className={inputCls} + /> + +
+ ); + } + + return ( +
+ {/* Hidden native input for required validation */} + {}} + className="absolute inset-0 opacity-0 pointer-events-none" + aria-hidden="true" + /> + + + + {open && ( +
+ {PRESET_TYPES.map((type) => ( + + ))} +
+ +
+
+ )} +
+ ); +} From f3d8c24e9e6e4be46212419739ac62e5c6ac079f Mon Sep 17 00:00:00 2001 From: tette Date: Thu, 30 Apr 2026 23:46:44 +0000 Subject: [PATCH 5/7] feat(careers): update detail page to render structured sections Replace dangerouslySetInnerHTML markdown rendering with typed sections: About the Role, Responsibilities, Requirements, Nice to Have, What We Offer, How to Apply. Duration shown in hero meta row and sidebar summary card. --- src/app/(site)/careers/[id]/page.tsx | 116 ++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 12 deletions(-) diff --git a/src/app/(site)/careers/[id]/page.tsx b/src/app/(site)/careers/[id]/page.tsx index baa5fd2..05bb8eb 100644 --- a/src/app/(site)/careers/[id]/page.tsx +++ b/src/app/(site)/careers/[id]/page.tsx @@ -5,11 +5,13 @@ import { Briefcase, Building2, Calendar, + CheckCircle2, Clock, MapPin, + Sparkles, Star, + Timer, } from "lucide-react"; -import { marked } from "marked"; import Link from "next/link"; import { notFound } from "next/navigation"; import { prisma } from "@/../prisma/prisma"; @@ -38,10 +40,7 @@ export default async function CareerDetailPage({ const daysLeft = differenceInDays(expiry, now); const isUrgent = daysLeft <= 7; - const descriptionHtml = await marked.parse(career.description, { - gfm: true, - breaks: true, - }); + const desc = career; return (
@@ -74,6 +73,12 @@ export default async function CareerDetailPage({ {career.type} + {desc.duration && ( + + + {desc.duration} + + )} {career.location} + {career.duration && ( + + + {career.duration} + + )} Posted {format(new Date(career.createdAt), "MMM d, yyyy")} @@ -152,18 +163,40 @@ export default async function CareerDetailPage({ 01 / About the Role -
+

+ {desc.aboutRole} +

+ {/* Responsibilities */} + {desc.responsibilities.length > 0 && ( +
+ + 02 / Responsibilities + +
    + {desc.responsibilities.map((item, i) => ( +
  • + + {String(i + 1).padStart(2, "0")} + + + {item} + +
  • + ))} +
+
+ )} + {/* Requirements */} {career.requirements.length > 0 && (
- 02 / Requirements + 03 / Requirements & Skills
    @@ -183,11 +216,61 @@ export default async function CareerDetailPage({
)} + + {/* Nice to Have */} + {desc.niceToHave.length > 0 && ( +
+ + 04 / Nice to Have + +
    + {desc.niceToHave.map((item) => ( +
  • + + + {item} + +
  • + ))} +
+
+ )} + + {/* What We Offer */} + {desc.whatWeOffer.length > 0 && ( +
+ + 05 / What We Offer + +
    + {desc.whatWeOffer.map((item) => ( +
  • + + + {item} + +
  • + ))} +
+
+ )} + + {/* How to Apply */} + {desc.howToApply && ( +
+ + 06 / How to Apply + +

+ {desc.howToApply} +

+
+ )}
{/* Right: sticky sidebar */}