Justin

David

All work
Live2025·Multi-month

JacinthForge

An e-learning platform connecting instructors with apprentices. I built the frontend end-to-end: a four-step course wizard, a media pipeline, a quiz builder, and a certificate generator that runs entirely in the browser.

JacinthForge screenshot

Role

Frontend Engineer

Duration

Multi-month

Core stack

Next.js · React · TypeScript

Context

Overview

JacinthForge is an LMS that lets instructors publish courses and apprentices enroll, consume content, take assessments, and receive verifiable certificates. I owned the frontend from UI through API integration, and contributed to product decisions, UX improvements, and production troubleshooting.

The Brief

Build the frontend for a three-role platform — apprentice, instructor, admin. Instructors need to create courses across four dependent steps, with arbitrary nesting inside the curriculum. Every lecture can hold video, text, a document, or a quiz. Every course ends in a certificate that needs to be downloadable, shareable, and verifiable by URL.

Constraints

  • Course data is deeply nested: sections contain lectures, lectures contain content of three different types, quizzes contain questions with options, and the whole thing needs to save incrementally
  • Users lose work: tabs close, laptops die, someone opens the same course in two tabs
  • File uploads need a lecture ID that doesn't exist yet — you upload attachments to a lecture you're still creating
  • Certificates need to be downloadable as PDF, shareable as images, and verifiable by ID — with no backend rendering service

01

Course creation as a multi-step wizard

The course editor is four steps that depend on each other: Basic Info → Advanced Info → Curriculum → Publish. Each step hydrates from a Zustand store, edits through its own React Hook Form instance, and writes back on submit.

The interesting problem was structural changes inside the Curriculum step. Adding a section or a lecture needs to persist to the server immediately — otherwise the user closes the tab, comes back, and finds their outline gone. But a section that exists only in the browser has no ID yet, and the file upload inside a lecture needs a lecture ID to attach to.

I solved it with temp IDs. Every new section gets temp-section-{uuid}, every new lecture gets temp-lecture-{uuid}. When the server responds with the real IDs, the store swaps them in and RHF resets. The UI never waits on the network for a structural change; it rolls back on failure instead.

typescript
// src/components/dashboard/instructor/create-course/curriculum-step/CurriculumCourseFormWrapper.tsx (excerpt)
const dataForBackend = {
  sections: form.getValues().sections.map((section) => ({
    ...section,
    id: section.id?.startsWith("temp-") ? "" : section.id,
    lectures: section.lectures.map((lecture) => ({
      ...lecture,
      id: lecture.id?.startsWith("temp-") ? "" : lecture.id,
    })),
  })),
};

const updatedSections = await handleAutoSaveCurriculum(dataForBackend);

// Swap temp IDs for real ones
if (updatedSections) {
  form.setValue(`sections.${newIndex}`, updatedSections[newIndex], {
    shouldValidate: true,
  });
}
Temp IDs are stripped before the server write, and reconciled on the response.
  • Uploads queue when the parent lecture ID doesn't exist yet, and fire automatically once the ID arrives. The user sees a spinner, not an error.
  • A local draft system lives in localStorage under course_draft::{id}, with a 24-hour TTL. On page load, the local draft's timestamp is compared against the server's. Local newer → show a modal. Server newer → silently sync.
  • Every step has its own RHF instance and hydration key. Adding a new step is adding a new file, not editing a shared wizard.

02

Certificates rendered in the browser

Certificates needed to be downloadable as PDFs, shareable as images on mobile, and verifiable through a public URL. The backend wasn't set up for image rendering, and I wanted the certificate to be a React component so designers could iterate on it in JSX.

The approach: render the certificate in an off-screen container, wait for every image inside to load and for the browser to paint, capture the DOM with html-to-image, then ship it through jsPDF for PDF export and the Web Share API for mobile sharing.

The hard parts were all timing. Safari captures before fonts and images have rendered unless you wait — I use a double requestAnimationFrame plus a 50ms buffer to guarantee the paint is done. html-to-image doesn't wait for <img> elements, so I query every image inside the container and await their load or error events before capturing. The QR code is generated asynchronously as a data URL, which means the whole capture path is stateful — the image isn't ready until the QR is.

typescript
// src/hooks/useCertificate.ts (excerpt)
await new Promise<void>((resolve) => {
  requestAnimationFrame(() => {
    requestAnimationFrame(() => {
      setTimeout(resolve, 50); // extra buffer for Safari
    });
  });
});

const dataUrl = await toPng(containerRef.current, {
  cacheBust: true,
  backgroundColor: "#ffffff",
  pixelRatio: 2,
  skipFonts: true,
});
The paint-wait: four lines that say I have fought Safari and won.
  • One hook — useCertificate — powers the download button, the share sheet, and the public verification page. The abstraction held across all three consumers.
  • The certificate is a React component a designer can edit. No backend rendering service, no server round-trip, no format drift between PDF and PNG.
  • Every capture path (PNG, PDF, share, preview) goes through the same wait-then-capture pipeline, so behaviour is identical no matter which button the user clicks.

Impact

PageSpeed

96

Frontend performance score

Course creation

4 steps

Down from a single monolithic form

Certificate generation

0 backend calls

Entirely client-side

Lost-work incidents

0

Post draft system launch

Stack

Framework

  • Next.js
  • React
  • TypeScript

Forms

  • React Hook Form
  • Zod

State

  • Zustand

Editor

  • TipTap

Uploads

  • S3 presigned URLs
  • react-dropzone

Certificate

  • html-to-image
  • jsPDF
  • QR code generation

What I'd do differently

The temp ID reconciliation is the part I'd rewrite. It's fragile if the server ever returns entities in a different order — the index-based matching would silently attach the wrong real ID to the wrong temp one. A proper ID mapping returned by the API would be more robust. The certificate pipeline is the part I'm proudest of: it's a real abstraction that solved the same problem three different ways without a single branch inside the hook.

Next case study

justindavid.dev

Design system • Case-study architecture