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.
+
+
+
+
+
+
+ Something went wrong while generating the PDF. Please retry or check
+ the console.
+
+
+
+
+
+
+ {
+ 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[] = [
+
+
{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();
+ });
+}