diff --git a/package.json b/package.json index c4922d0b..3612cbda 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "firebase-admin": "^12.5.0", "js-yaml": "^4.1.0", "lucide-react": "^0.436.0", + "pdf-lib": "^1.17.1", "query-string": "^9.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 355080fe..6ac13081 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: lucide-react: specifier: ^0.436.0 version: 0.436.0(react@18.3.1) + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 query-string: specifier: ^9.1.0 version: 9.1.0 @@ -140,7 +143,7 @@ importers: version: 5.62.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.5.4) '@vitejs/plugin-react': specifier: ^5.0.4 - version: 5.0.4(vite@6.4.0(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1)) + version: 5.0.4(vite@7.1.10(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1)) '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4) @@ -1707,6 +1710,12 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4578,6 +4587,7 @@ packages: libsql@0.5.22: resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==} + cpu: [x64, arm64, wasm32, arm] os: [darwin, linux, win32] lilconfig@2.1.0: @@ -5212,6 +5222,9 @@ packages: pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5289,6 +5302,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -8015,7 +8031,7 @@ snapshots: '@firebase/component@0.6.8': dependencies: '@firebase/util': 1.9.7 - tslib: 2.7.0 + tslib: 2.8.1 '@firebase/database-compat@1.0.7': dependencies: @@ -8120,7 +8136,7 @@ snapshots: '@firebase/logger@0.4.2': dependencies: - tslib: 2.7.0 + tslib: 2.8.1 '@firebase/messaging-compat@0.2.10(@firebase/app-compat@0.2.39)(@firebase/app@0.10.9)': dependencies: @@ -8683,6 +8699,14 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@pkgjs/parseargs@0.11.0': optional: true @@ -9373,7 +9397,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@5.0.4(vite@6.4.0(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1))': + '@vitejs/plugin-react@5.0.4(vite@7.1.10(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1))': dependencies: '@babel/core': 7.28.4 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.4) @@ -9381,7 +9405,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.38 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.0(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1) + vite: 7.1.10(@types/node@22.5.0)(jiti@2.6.1)(tsx@4.19.1)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -13105,6 +13129,8 @@ snapshots: pako@0.2.9: {} + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -13187,6 +13213,13 @@ snapshots: pathval@2.0.1: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + pend@1.2.0: {} picocolors@1.0.1: {} diff --git a/src/assets/geeksblabla-logo.png b/src/assets/geeksblabla-logo.png new file mode 100644 index 00000000..9505d48e Binary files /dev/null and b/src/assets/geeksblabla-logo.png differ diff --git a/src/assets/stateOfDev-logo.png b/src/assets/stateOfDev-logo.png new file mode 100644 index 00000000..dc639692 Binary files /dev/null and b/src/assets/stateOfDev-logo.png differ diff --git a/src/lib/pdf/user-report.test.ts b/src/lib/pdf/user-report.test.ts new file mode 100644 index 00000000..eb1c4e7b --- /dev/null +++ b/src/lib/pdf/user-report.test.ts @@ -0,0 +1,141 @@ +import type { PDFFont } from "pdf-lib"; +import type { UserReportPayload } from "./user-report"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + generateUserReportPdf, + sanitizeForPdf, + wrapText + +} from "./user-report"; + +/** + * Small fake font object – only widthOfTextAtSize is needed by wrapText. + */ +const fakeFont: PDFFont = { + widthOfTextAtSize: (text: string, size: number) => + text.length * size * 0.5 +} as unknown as PDFFont; + +describe("sanitizeForPdf", () => { + it("keeps regular ASCII text as-is", () => { + expect(sanitizeForPdf("Hello World! 123")).toBe("Hello World! 123"); + }); + + it("strips emojis and non-WinAnsi characters", () => { + const input = "Hello 😊 World 🌍"; + const output = sanitizeForPdf(input); + + // We expect emojis to be removed but basic letters/spaces kept + expect(output).toBe("Hello World "); + }); + + it("returns empty string for empty input", () => { + expect(sanitizeForPdf("")).toBe(""); + }); +}); + +describe("wrapText", () => { + it("wraps long text into multiple lines based on max width", () => { + const text = "one two three four five six seven eight"; + const lines = wrapText(text, 40, fakeFont, 10); + + expect(lines.length).toBeGreaterThan(1); + const joined = lines.join(" "); + expect(joined).toContain("one"); + expect(joined).toContain("eight"); + }); + + it("returns a single line when text fits in max width", () => { + const text = "short text"; + const lines = wrapText(text, 1000, fakeFont, 10); + + expect(lines.length).toBe(1); + expect(lines[0]).toBe("short text"); + }); +}); + +describe("generateUserReportPdf", () => { + let originalDocument: Document | undefined; + let originalURL: typeof URL | undefined; + + let createElementMock: ReturnType; + let appendChildMock: ReturnType; + let createObjectURLMock: ReturnType; + let revokeObjectURLMock: ReturnType; + + beforeEach(() => { + // Save originals to restore later + originalDocument = globalThis.document; + originalURL = globalThis.URL; + + const fakeLink = { + href: "", + download: "", + click: vi.fn(), + remove: vi.fn() + } as unknown as HTMLAnchorElement; + + createElementMock = vi.fn(() => fakeLink); + appendChildMock = vi.fn(); + createObjectURLMock = vi.fn(() => "blob:fake"); + revokeObjectURLMock = vi.fn(); + + globalThis.document = { + createElement: createElementMock, + body: { + appendChild: appendChildMock + } + } as unknown as Document; + + globalThis.URL = { + createObjectURL: createObjectURLMock, + revokeObjectURL: revokeObjectURLMock + } as unknown as typeof URL; + }); + + afterEach(() => { + vi.restoreAllMocks(); + + if (originalDocument) { + globalThis.document = originalDocument; + } + + if (originalURL) { + globalThis.URL = originalURL; + } + }); + + it("generates a PDF and triggers a download without throwing", async () => { + const payload: UserReportPayload = { + userId: "test-user", + submittedAt: "2025-01-01T00:00:00Z", + sections: [ + { + id: "profile", + name: "Profile", + items: [ + { + id: "profile-q-0", + label: "What is your gender?", + answer: "Male" + }, + { + id: "profile-q-1", + label: "What is your age?", + answer: "25 to 34 years" + } + ] + } + ] + }; + + await expect(generateUserReportPdf(payload)).resolves.toBeUndefined(); + + // Assert that our DOM/URL mocks were actually used + expect(createElementMock).toHaveBeenCalledWith("a"); + expect(appendChildMock).toHaveBeenCalled(); + expect(createObjectURLMock).toHaveBeenCalled(); + expect(revokeObjectURLMock).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/pdf/user-report.ts b/src/lib/pdf/user-report.ts new file mode 100644 index 00000000..071d100b --- /dev/null +++ b/src/lib/pdf/user-report.ts @@ -0,0 +1,396 @@ +import type { PDFFont } from "pdf-lib"; +import { PDFDocument, rgb, StandardFonts } from "pdf-lib"; + +import geeksLogo from "@/assets/geeksblabla-logo.png"; +import stateOfDevLogo from "@/assets/stateOfDev-logo.png"; + +export interface UserReportItem { + id: string; + label: string; + answer: string; +} + +export interface UserReportSection { + id: string; + name?: string; + description?: string; + items: UserReportItem[]; +} + +export interface UserReportPayload { + userId: string; + submittedAt: string | null; + sections: UserReportSection[]; +} + +/** + * Strip characters that WinAnsi / built-in fonts can’t encode (emojis etc.) + * to avoid runtime errors like “WinAnsi cannot encode ...”. + */ +export function sanitizeForPdf(text: string): string { + if (!text) + return ""; + return Array.from(text) + .map((ch) => { + const code = ch.codePointAt(0) ?? 0; + if (code < 32 || code > 255) + return ""; + return ch; + }) + .join(""); +} + +/** + * Wrap text into multiple lines that fit a given width. + */ +export function wrapText( + text: string, + maxWidth: number, + font: PDFFont, + fontSize: number +): string[] { + const clean = sanitizeForPdf(text); + const words = clean.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; + + for (const word of words) { + const testLine = current ? `${current} ${word}` : word; + const width = font.widthOfTextAtSize(testLine, fontSize); + + if (width > maxWidth && current) { + lines.push(current); + current = word; + } + else { + current = testLine; + } + } + + if (current) + lines.push(current); + return lines; +} + +/** + * Generate and trigger download of a per-user survey report PDF. + */ +export async function generateUserReportPdf( + payload: UserReportPayload +): Promise { + const pdfDoc = await PDFDocument.create(); + + // ---- Fonts ---------------------------------------------------------------- + // Monospace dev vibe: Courier everywhere. + const bodyFont = await pdfDoc.embedFont(StandardFonts.Courier); + const headingFont = await pdfDoc.embedFont(StandardFonts.CourierBold); + const titleFont = headingFont; // big bold Courier for title + + // ---- Colors & layout ------------------------------------------------------ + const brandBlue = rgb(0.18, 0.29, 0.66); + const softGrey = rgb(0.55, 0.55, 0.55); + const borderGrey = rgb(0.82, 0.82, 0.84); + const rowDivider = rgb(0.9, 0.9, 0.92); + const textDark = rgb(0.12, 0.12, 0.12); + + const marginX = 56; + const marginY = 56; + const lineGap = 4; + + let page = pdfDoc.addPage(); + let { width, height } = page.getSize(); + let cursorY = height - marginY; + const contentWidth = width - marginX * 2; + + const ensureSpace = (needed: number) => { + if (cursorY - needed < marginY) { + page = pdfDoc.addPage(); + ({ width, height } = page.getSize()); + cursorY = height - marginY; + } + }; + + const drawLine = ( + text: string, + size: number, + opts?: { font?: PDFFont; color?: ReturnType } + ) => { + const safe = sanitizeForPdf(text); + const usedFont = opts?.font ?? bodyFont; + const color = opts?.color ?? textDark; + const textHeight = size; + + ensureSpace(textHeight + lineGap); + + page.drawText(safe, { + x: marginX, + y: cursorY - textHeight, + size, + font: usedFont, + color + }); + + cursorY -= textHeight + lineGap; + }; + + // ---- 1. Logos + title block ---------------------------------------------- + + try { + const [leftBytes, rightBytes] = await Promise.all([ + fetch(stateOfDevLogo.src).then(async res => res.arrayBuffer()), + fetch(geeksLogo.src).then(async res => res.arrayBuffer()) + ]); + + const leftImg = await pdfDoc.embedPng(leftBytes); + const rightImg = await pdfDoc.embedPng(rightBytes); + + const logoHeight = 15; + const leftScale = logoHeight / leftImg.height; + const rightScale = (logoHeight + 7) / rightImg.height; + + page.drawImage(leftImg, { + x: marginX, + y: cursorY - logoHeight, + width: leftImg.width * leftScale, + height: logoHeight + }); + + page.drawImage(rightImg, { + x: width - marginX - rightImg.width * rightScale, + y: cursorY - logoHeight, + width: rightImg.width * rightScale, + height: logoHeight + 7 + }); + + cursorY -= logoHeight + 18; + } + catch { + cursorY -= 12; + } + + // Title: terminal-style banner, centered + const title = "=== SURVEY REPORT ==="; + const titleSize = 16; + const titleWidth = titleFont.widthOfTextAtSize(title, titleSize); + const titleX = marginX + (contentWidth - titleWidth) / 2; + const titleY = cursorY - titleSize; + + page.drawText(sanitizeForPdf(title), { + x: titleX, + y: titleY, + size: titleSize, + font: titleFont, + color: textDark + }); + + // Short underline (40% width) + const underlineWidth = contentWidth * 0; + const underlineX = marginX + (contentWidth - underlineWidth) / 2; + + page.drawRectangle({ + x: underlineX, + y: titleY - 6, + width: underlineWidth, + height: 1, + color: brandBlue + }); + + cursorY = titleY - 18; + + // Subtitle in softer grey + const subtitle + = "This document contains the answers you submitted to the State of Dev in Morocco survey. It is intended for your personal reference."; + + const subtitleLines = wrapText(subtitle, contentWidth, bodyFont, 10); + for (const line of subtitleLines) { + drawLine(line, 10, { font: bodyFont, color: softGrey }); + } + + cursorY -= 8; + + // ---- 2. Metadata “card” (compact) ---------------------------------------- + + const cardTop = cursorY; + const cardPaddingX = 10; + const cardPaddingY = 8; + const labelSize = 10; + const valueSize = 10; + const rowHeight = labelSize + 4; + + const metaRows: Array<[string, string]> = [ + ["Submitted", payload.submittedAt ?? "N/A"], + ["User ID", payload.userId], + ["Website", "https://stateofdev.ma"] + ]; + + const cardHeight = cardPaddingY * 2 + metaRows.length * rowHeight; + + ensureSpace(cardHeight + 12); + + page.drawRectangle({ + x: marginX, + y: cursorY - cardHeight, + width: contentWidth, + height: cardHeight, + borderColor: borderGrey, + borderWidth: 0.8, + color: rgb(1, 1, 1) + }); + + let metaCursorY = cursorY - cardPaddingY - labelSize; + + metaRows.forEach(([label, value]) => { + const labelText = `${label}:`; + + page.drawText(sanitizeForPdf(labelText), { + x: marginX + cardPaddingX, + y: metaCursorY, + size: labelSize, + font: headingFont, + color: textDark + }); + + const valueX + = marginX + + cardPaddingX + + headingFont.widthOfTextAtSize(labelText, labelSize) + + 6; + + page.drawText(sanitizeForPdf(value), { + x: valueX, + y: metaCursorY, + size: valueSize, + font: bodyFont, + color: softGrey + }); + + metaCursorY -= rowHeight; + }); + + cursorY = cardTop - cardHeight - 18; + + // ---- 3. Sections as blocks (minimal + terminal flair) -------------------- + + const rowGap = 10; + const qaGap = 2; + const questionColor = textDark; + const answerColor = rgb(0.25, 0.25, 0.25); + + for (const section of payload.sections) { + cursorY -= 16; + ensureSpace(90); + + // Section header bar (left) with terminal-style label + const barHeight = 18; + // const barWidth = contentWidth * 0.35; + const barWidth = contentWidth; + const barY = cursorY - barHeight; + + page.drawRectangle({ + x: marginX, + y: barY, + width: barWidth, + height: barHeight, + color: brandBlue + }); + + const rawLabel = section.name ?? section.id ?? ""; + const sectionLabel = `> ${rawLabel.toUpperCase()}`; + + page.drawText(sanitizeForPdf(sectionLabel), { + x: marginX + 8, + y: barY + 4, + size: 11, + font: headingFont, + color: rgb(1, 1, 1) + }); + + cursorY = barY - 12; + + if (section.description) { + const descLines = wrapText( + section.description, + contentWidth, + bodyFont, + 9.5 + ); + for (const line of descLines) { + drawLine(line, 9.5, { font: bodyFont, color: softGrey }); + } + cursorY -= 4; + } + + for (const item of section.items) { + const qLines = wrapText(item.label, contentWidth, headingFont, 10.5); + for (const line of qLines) { + drawLine(line, 10.5, { font: headingFont, color: questionColor }); + } + + cursorY -= qaGap; + + const answerText = item.answer || "Not answered"; + const aLines = wrapText(` ${answerText}`, contentWidth, bodyFont, 10); + for (const line of aLines) { + drawLine(line, 10, { font: bodyFont, color: answerColor }); + } + + const dividerHeight = 0.5; + ensureSpace(dividerHeight + rowGap); + page.drawRectangle({ + x: marginX, + y: cursorY - dividerHeight - 2, + width: contentWidth, + height: dividerHeight, + color: rowDivider + }); + + cursorY -= rowGap; + } + } + + // ---- 4. Footer on every page --------------------------------------------- + + const pages = pdfDoc.getPages(); + const totalPages = pages.length; + + pages.forEach((p, index) => { + const footerY = marginY - 30; + const footerCenterX = marginX + (p.getSize().width - marginX * 2) / 2; + + const signature = "StateOfDev.ma · Geeksblabla"; + const sigWidth = bodyFont.widthOfTextAtSize(signature, 9); + + p.drawText(sanitizeForPdf(signature), { + x: footerCenterX - sigWidth / 2, + y: footerY, + size: 9, + font: bodyFont, + color: softGrey + }); + + const pageText = `Page ${index + 1} / ${totalPages}`; + const pageWidth = bodyFont.widthOfTextAtSize(pageText, 9); + + p.drawText(pageText, { + x: p.getSize().width - marginX - pageWidth, + y: footerY, + size: 9, + font: bodyFont, + color: softGrey + }); + }); + + // ---- 5. Export & download ------------------------------------------------- + const pdfBytes = await pdfDoc.save(); + const byteArray = new Uint8Array(pdfBytes); + const blob = new Blob([byteArray], { type: "application/pdf" }); + const url = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.href = url; + a.download = "stateofdev-my-survey-report.pdf"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/src/pages/my-responses.astro b/src/pages/my-responses.astro new file mode 100644 index 00000000..c528aa85 --- /dev/null +++ b/src/pages/my-responses.astro @@ -0,0 +1,263 @@ +--- +import { getAuth } from "firebase-admin/auth"; +import { getFirestore } from "firebase-admin/firestore"; +import BaseLayout from "@/components/layout.astro"; +import { getActiveApp } from "@/lib/firebase/server"; +import { validateSurveyFile } from "@/lib/validators/survey-schema"; +import myResponsesScriptUrl from "@/scripts/my-responses.client?url"; +import profileQuestionsRaw from "@/survey/1-profile.yml"; +import learningQuestionsRaw from "@/survey/2-learning-and-education.yml"; +import workQuestionsRaw from "@/survey/3-work.yml"; +import aiQuestionsRaw from "@/survey/4-ai.yml"; +import techQuestionsRaw from "@/survey/5-tech.yml"; +import communityQuestionsRaw from "@/survey/6-community.yml"; + +export const prerender = false; + +// --------------------------------------------------------------------------- +// 1. Authenticate user based on the existing anonymous session cookie +// --------------------------------------------------------------------------- + +const app = getActiveApp(); +const auth = getAuth(app); +const db = getFirestore(app); + +if (!Astro.cookies.has("__session")) { + return Astro.redirect("/before-start"); +} + +const sessionCookie = Astro.cookies.get("__session")?.value; +if (!sessionCookie) { + return Astro.redirect("/before-start"); +} + +const decoded = await auth.verifySessionCookie(sessionCookie); +const user = await auth.getUser(decoded.uid); +if (!user) { + return Astro.redirect("/before-start"); +} + +const userId = user.uid; + +// --------------------------------------------------------------------------- +// 2. Load this user's answers from Firestore +// --------------------------------------------------------------------------- + +const RESULTS_COLLECTION = "results"; + +const docRef = db.collection(RESULTS_COLLECTION).doc(userId); +const snapshot = await docRef.get(); + +if (!snapshot.exists) { + return Astro.redirect("/survey"); +} + +const rawData = snapshot.data() as Record; + +const METADATA_KEYS = new Set([ + "creationTime", + "lastSignInTime", + "lastUpdated", + "userId" +]); + +const answers = Object.fromEntries( + Object.entries(rawData).filter(([key]) => !METADATA_KEYS.has(key)) +) as Record; + +// --------------------------------------------------------------------------- +// 3. Load survey definition from YAML +// --------------------------------------------------------------------------- + +const sectionsRaw = [ + validateSurveyFile(profileQuestionsRaw, "1-profile.yml"), + validateSurveyFile(learningQuestionsRaw, "2-learning-and-education.yml"), + validateSurveyFile(workQuestionsRaw, "3-work.yml"), + validateSurveyFile(aiQuestionsRaw, "4-ai.yml"), + validateSurveyFile(techQuestionsRaw, "5-tech.yml"), + validateSurveyFile(communityQuestionsRaw, "6-community.yml") +]; + +// --------------------------------------------------------------------------- +// 4. Helpers: map stored indices -> human-readable labels +// --------------------------------------------------------------------------- + +interface QuestionDefinition { + label: string; + choices?: string[]; + multiple?: boolean; +} + +function normalizeIndices(value: unknown): number[] { + if (Array.isArray(value)) { + return value + .map(x => (typeof x === "number" ? x : Number(x))) + .filter(x => Number.isFinite(x)); + } + + if (typeof value === "number") { + return [value]; + } + + return []; +} + +function formatAnswer( + questionKey: string, + question: QuestionDefinition +): string { + const value = answers[questionKey]; + + if (value === null || typeof value === "undefined") { + return "Not answered"; + } + + const { choices, multiple } = question; + + if (!choices || !Array.isArray(choices)) { + return String(value); + } + + if (multiple) { + const indices = normalizeIndices(value); + const labels = indices + .map(i => choices[i]) + .filter((x): x is string => Boolean(x)); + + const otherKey = `${questionKey}-other`; + const otherValue = answers[otherKey]; + + if (typeof otherValue === "string" && otherValue.trim()) { + labels.push(`Other: ${otherValue.trim()}`); + } + + return labels.length ? labels.join(", ") : "Not answered"; + } + + const indices = normalizeIndices(value); + const idx = indices[0]; + + if (typeof idx === "number" && typeof choices[idx] === "string") { + let base = choices[idx]; + + const otherKey = `${questionKey}-other`; + const otherValue = answers[otherKey]; + + if (typeof otherValue === "string" && otherValue.trim()) { + base += ` – Other: ${otherValue.trim()}`; + } + + return base; + } + + return String(value); +} + +// --------------------------------------------------------------------------- +// 5. Build a view model used by both UI and PDF generator +// --------------------------------------------------------------------------- + +export interface ViewQuestionItem { + id: string; + label: string; + answer: string; +} + +export interface ViewSection { + id: string; + name: string; + description?: string; + items: ViewQuestionItem[]; +} + +const viewSections: ViewSection[] = sectionsRaw.map((section: any) => ({ + id: section.label, + name: section.name, + description: section.description, + items: section.questions.map((q: QuestionDefinition, index: number) => { + const questionId = `${section.label}-q-${index}`; + + return { + id: questionId, + label: q.label, + answer: formatAnswer(questionId, q) + }; + }) +})); + +const submittedAt + = (rawData.lastUpdated as string | undefined) + ?? (rawData.creationTime as string | undefined) + ?? null; +--- + + +
+ +
+

Your survey report

+

+ This page shows the answers you submitted to the State of Dev in Morocco + survey. You can download a PDF copy for your own records. +

+ +
+ + + +
+
+ + +
+ { + viewSections.map(section => ( +
+

{section.name}

+ {section.description && ( +

+ {section.description} +

+ )} + +
+ {section.items.map((item: ViewQuestionItem) => ( +
+
{item.label}
+
+ {item.answer} +
+
+ ))} +
+
+ )) + } +
+ + +
+
+ + + +
+
diff --git a/src/pages/thanks.astro b/src/pages/thanks.astro index 871002c9..722643d8 100644 --- a/src/pages/thanks.astro +++ b/src/pages/thanks.astro @@ -133,6 +133,20 @@ const posts: Post[] = [

+
+

Get a copy of your answers

+

+ You can view your survey responses and download a personal PDF + report. +

+ + View & download my report + +
+
{posts.map(post => )}
@@ -142,35 +156,35 @@ const posts: Post[] = [ diff --git a/src/scripts/my-responses.client.ts b/src/scripts/my-responses.client.ts new file mode 100644 index 00000000..0fca529c --- /dev/null +++ b/src/scripts/my-responses.client.ts @@ -0,0 +1,56 @@ +import type { UserReportPayload } from "@/lib/pdf/user-report"; +import { + generateUserReportPdf + +} from "@/lib/pdf/user-report"; + +function setupDownloadButton(): void { + const button = document.getElementById("download-pdf"); + const dataEl = document.getElementById("my-responses-data"); + const errorEl = document.getElementById("pdf-error"); + + if (!button || !dataEl) { + console.warn("[my-responses] Missing button or data element", { + button, + dataEl + }); + return; + } + + let payload: UserReportPayload | null = null; + + try { + const raw = dataEl.getAttribute("data-payload") || "{}"; + payload = JSON.parse(raw) as UserReportPayload; + } + catch (error) { + console.error("[my-responses] Failed to parse PDF payload", error); + if (errorEl) { + errorEl.classList.remove("hidden"); + } + return; + } + + button.addEventListener("click", () => { + if (!payload) { + return; + } + + if (errorEl) { + errorEl.classList.add("hidden"); + } + + void generateUserReportPdf(payload).catch((error) => { + console.error("[my-responses] Failed to generate PDF report", error); + if (errorEl) { + errorEl.classList.remove("hidden"); + } + }); + }); +} + +if (typeof window !== "undefined") { + window.addEventListener("DOMContentLoaded", () => { + setupDownloadButton(); + }); +}