diff --git a/public/kyc.css b/public/kyc.css new file mode 100644 index 0000000..03c0bb3 --- /dev/null +++ b/public/kyc.css @@ -0,0 +1,400 @@ +.kyc-wizard { + --gray-100: 210, 40%, 96%; + --gray-200: 214, 32%, 91%; + --gray-300: 213, 27%, 84%; + --gray-400: 215, 20%, 65%; + --gray-500: 215, 16%, 47%; + --gray-600: 215, 19%, 35%; + --gray-700: 215, 25%, 27%; + --gray-800: 217, 33%, 17%; + --gray-900: 222, 47%, 11%; + --accent-500: 239, 84%, 67%; + --accent-600: 243, 75%, 59%; + --accent-700: 245, 58%, 51%; + --green-400: 158, 64%, 52%; + --green-500: 160, 84%, 39%; + --red-500: 0, 84%, 60%; + + font-family: inherit; + width: 100%; + max-width: 560px; + margin: 0 auto; + color: hsl(var(--gray-900)); +} + +/* ── Step indicator ── */ +.kyc-steps { + display: flex; + align-items: center; + margin-bottom: 2rem; +} + +.kyc-step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; +} + +.kyc-step__dot { + width: 2rem; + height: 2rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.8rem; + font-weight: 600; + border: 2px solid hsl(var(--gray-300)); + background: white; + color: hsl(var(--gray-500)); + transition: background 0.2s, border-color 0.2s, color 0.2s; +} + +.kyc-step--active .kyc-step__dot { + border-color: hsl(var(--accent-600)); + background: hsl(var(--accent-600)); + color: white; +} + +.kyc-step--done .kyc-step__dot { + border-color: hsl(var(--green-500)); + background: hsl(var(--green-500)); + color: white; +} + +.kyc-step__label { + font-size: 0.7rem; + color: hsl(var(--gray-500)); + white-space: nowrap; +} + +.kyc-step--active .kyc-step__label { + color: hsl(var(--accent-600)); + font-weight: 600; +} + +.kyc-step--done .kyc-step__label { + color: hsl(var(--green-500)); +} + +.kyc-step__line { + flex: 1; + height: 2px; + background: hsl(var(--gray-200)); + margin: 0 0.5rem; + margin-bottom: 1.1rem; +} + +/* ── Body ── */ +.kyc-body { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.kyc-body--center { + align-items: center; + text-align: center; + padding: 2rem 0; +} + +.kyc-title { + font-size: 1.25rem; + font-weight: 700; + margin: 0; + color: hsl(var(--gray-900)); +} + +.kyc-subtitle { + font-size: 0.875rem; + color: hsl(var(--gray-500)); + margin: 0; +} + +/* ── Document type selection ── */ +.kyc-doc-types { + display: grid; + gap: 0.75rem; +} + +@media (min-width: 480px) { + .kyc-doc-types { + grid-template-columns: repeat(3, 1fr); + } +} + +.kyc-doc-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + padding: 1.25rem 1rem; + border-radius: 0.75rem; + border: 2px solid hsl(var(--gray-200)); + background: white; + cursor: pointer; + transition: border-color 0.15s, background 0.15s, transform 0.1s; +} + +.kyc-doc-btn:hover { + border-color: hsl(var(--accent-500)); + background: hsla(var(--accent-500), 0.04); + transform: translateY(-1px); +} + +.kyc-doc-icon { + font-size: 2rem; + line-height: 1; +} + +.kyc-doc-label { + font-size: 0.9rem; + font-weight: 600; + color: hsl(var(--gray-800)); +} + +.kyc-doc-sides { + font-size: 0.75rem; + color: hsl(var(--gray-400)); +} + +/* ── Upload slots ── */ +.kyc-upload-slots { + display: grid; + gap: 1rem; +} + +@media (min-width: 480px) { + .kyc-upload-slots { + grid-template-columns: repeat(2, 1fr); + } +} + +.kyc-upload-slot { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.kyc-upload-label { + font-size: 0.8rem; + font-weight: 600; + color: hsl(var(--gray-600)); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.kyc-upload-area { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1.5rem 1rem; + border-radius: 0.75rem; + border: 2px dashed hsl(var(--gray-300)); + background: hsl(var(--gray-100)); + cursor: pointer; + font-size: 0.85rem; + color: hsl(var(--gray-500)); + transition: border-color 0.15s, background 0.15s; + min-height: 120px; +} + +.kyc-upload-area:hover, +.kyc-upload-area--drag { + border-color: hsl(var(--accent-500)); + background: hsla(var(--accent-500), 0.04); + color: hsl(var(--accent-600)); +} + +.kyc-upload-icon { + font-size: 1.5rem; +} + +.kyc-file-input { + display: none; +} + +.kyc-upload-preview { + position: relative; + border-radius: 0.75rem; + overflow: hidden; + border: 2px solid hsl(var(--gray-200)); + min-height: 120px; + display: flex; + align-items: center; + justify-content: center; + background: hsl(var(--gray-100)); +} + +.kyc-upload-img { + width: 100%; + height: 120px; + object-fit: cover; + display: block; +} + +.kyc-upload-pdf { + font-size: 0.85rem; + color: hsl(var(--gray-600)); + padding: 1rem; + text-align: center; + word-break: break-all; +} + +.kyc-upload-remove { + position: absolute; + top: 0.4rem; + right: 0.4rem; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + border: none; + background: hsla(var(--gray-900), 0.6); + color: white; + font-size: 0.7rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +/* ── Confirm step ── */ +.kyc-confirm-doc { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background: hsl(var(--gray-100)); + border: 1px solid hsl(var(--gray-200)); +} + +.kyc-confirm-icon { + font-size: 1.5rem; +} + +.kyc-confirm-type { + font-weight: 600; + color: hsl(var(--gray-800)); +} + +.kyc-confirm-files { + display: grid; + gap: 0.75rem; +} + +@media (min-width: 480px) { + .kyc-confirm-files { + grid-template-columns: repeat(2, 1fr); + } +} + +.kyc-confirm-file { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.kyc-confirm-side { + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--gray-500)); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.kyc-confirm-img { + width: 100%; + height: 120px; + object-fit: cover; + border-radius: 0.5rem; + border: 1px solid hsl(var(--gray-200)); +} + +.kyc-confirm-filename { + font-size: 0.85rem; + color: hsl(var(--gray-600)); + word-break: break-all; +} + +/* ── Done step ── */ +.kyc-done-icon { + font-size: 3rem; + line-height: 1; +} + +/* ── Actions ── */ +.kyc-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin-top: 0.5rem; +} + +.kyc-btn { + padding: 0.55rem 1.25rem; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + border: none; + transition: background 0.15s, opacity 0.15s; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.kyc-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.kyc-btn--primary { + background: hsl(var(--accent-600)); + color: white; +} + +.kyc-btn--primary:not(:disabled):hover { + background: hsl(var(--accent-700)); +} + +.kyc-btn--ghost { + background: hsl(var(--gray-100)); + color: hsl(var(--gray-700)); + border: 1px solid hsl(var(--gray-200)); +} + +.kyc-btn--ghost:not(:disabled):hover { + background: hsl(var(--gray-200)); +} + +/* ── Error ── */ +.kyc-error { + font-size: 0.85rem; + color: hsl(var(--red-500)); + margin: 0; + padding: 0.6rem 0.9rem; + border-radius: 0.5rem; + background: hsla(var(--red-500), 0.08); + border: 1px solid hsla(var(--red-500), 0.2); +} + +/* ── Spinner ── */ +@keyframes kyc-spin { + to { transform: rotate(360deg); } +} + +.kyc-spinner { + display: inline-block; + width: 0.9rem; + height: 0.9rem; + border: 2px solid rgba(255, 255, 255, 0.4); + border-top-color: white; + border-radius: 50%; + animation: kyc-spin 0.6s linear infinite; +} diff --git a/src/index.ts b/src/index.ts index a4d329f..d113eff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,10 @@ import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; import superjson from "superjson"; import { type API } from "types/api"; +import { initKyc } from "./kyc"; + +export { initKyc }; +export type { KycDocType, KycFile, KycSubmission } from "./kyc"; export type AlgoraInput = inferRouterInputs; export type AlgoraOutput = inferRouterOutputs; @@ -40,6 +44,9 @@ const parseParams = (el: Element) => { return { org, limit, status }; }; +const esc = (s: string) => + s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + async function init(selector = "[data-bounty-org]") { try { const container = document.querySelector(selector); @@ -74,15 +81,15 @@ async function init(selector = "[data-bounty-org]") { bounties.forEach((bounty) => { container.innerHTML += `
-
${bounty.reward_formatted}
-
${bounty.task.repo_name}#${bounty.task.number}
-
${bounty.task.title}
+
${esc(bounty.reward_formatted ?? "")}
+
${esc(bounty.task.repo_name)}#${bounty.task.number}
+
${esc(bounty.task.title)}
`; @@ -94,12 +101,33 @@ async function init(selector = "[data-bounty-org]") { // Export for IIFE bundle if (typeof window !== "undefined") { - (window as any).Algora = { algora, init }; + (window as any).Algora = { algora, init, initKyc }; + + // Skip auto-init in sandboxed iframes without allow-same-origin + // (location.origin is "null" in that context) + if (location.origin === "null") { + const onMessage = (e: MessageEvent) => { + // Always verify the message comes from the direct parent window. + // In sandboxed iframes, e.origin is "null" so we cannot rely on origin + // alone — e.source is the only safe identity check available. + if (e.source !== window.parent) return; - // Auto-initialize when script loads - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => init()); + if (e.data?.type === "algora:init") { + const selector = typeof e.data.selector === "string" ? e.data.selector : undefined; + void init(selector); + } else if (e.data?.type === "algora:init-kyc") { + const selector = typeof e.data.selector === "string" ? e.data.selector : undefined; + initKyc(selector); + } + }; + window.addEventListener("message", onMessage); + // Signal to the host that the SDK is ready to receive algora:init. + // Must use "*" — sandboxed iframe has no origin to target specifically. + try { window.parent.postMessage({ type: "algora:ready" }, "*"); } catch (_) { /* no-op if parent is unreachable */ } + } else if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { init(); initKyc(); }); } else { init(); + initKyc(); } } diff --git a/src/kyc.ts b/src/kyc.ts new file mode 100644 index 0000000..14f9dcf --- /dev/null +++ b/src/kyc.ts @@ -0,0 +1,389 @@ +const esc = (s: string) => + s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + +export type KycDocType = "passport" | "national_id" | "drivers_license"; + +export interface KycFile { + side: "front" | "back"; + file: File; + dataUrl: string; +} + +export interface KycSubmission { + docType: KycDocType; + files: KycFile[]; +} + +type KycStep = "select" | "upload" | "confirm" | "done"; + +interface KycState { + step: KycStep; + docType: KycDocType | null; + files: KycFile[]; + submitting: boolean; + error: string | null; +} + +const DOC_TYPES: { value: KycDocType; label: string; sides: ("front" | "back")[] }[] = [ + { value: "passport", label: "Passport", sides: ["front"] }, + { value: "national_id", label: "National ID", sides: ["front", "back"] }, + { value: "drivers_license", label: "Driver's License", sides: ["front", "back"] }, +]; + +const SIDE_LABEL: Record<"front" | "back", string> = { + front: "Front side", + back: "Back side", +}; + +function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error("Failed to read file")); + reader.readAsDataURL(file); + }); +} + +function renderStepIndicator(step: KycStep): string { + const steps: { key: KycStep; label: string }[] = [ + { key: "select", label: "Document Type" }, + { key: "upload", label: "Upload" }, + { key: "confirm", label: "Confirm" }, + ]; + const current = steps.findIndex((s) => s.key === step); + return ` +
+ ${steps + .map( + (s, i) => ` +
+
${i < current ? "✓" : i + 1}
+ ${esc(s.label)} +
+ ${i < steps.length - 1 ? '
' : ""} + `, + ) + .join("")} +
+ `; +} + +function renderSelectStep(): string { + return ` +
+

Select document type

+

Choose the type of identity document you will upload.

+
+ ${DOC_TYPES.map( + (d) => ` + + `, + ).join("")} +
+
+ `; +} + +function docIcon(type: KycDocType): string { + switch (type) { + case "passport": + return "🛂"; + case "national_id": + return "🪪"; + case "drivers_license": + return "🚗"; + } +} + +function renderUploadStep(state: KycState): string { + const doc = DOC_TYPES.find((d) => d.value === state.docType)!; + return ` +
+

Upload ${esc(doc.label)}

+

Upload a clear photo or scan. Max 10 MB per file. JPG, PNG or PDF.

+
+ ${doc.sides + .map((side) => { + const existing = state.files.find((f) => f.side === side); + return ` +
+
${esc(SIDE_LABEL[side])}
+ ${ + existing + ? `
+ ${existing.file.type === "application/pdf" ? `
📄 ${esc(existing.file.name)}
` : `${esc(SIDE_LABEL[side])}`} + +
` + : `` + } +
+ `; + }) + .join("")} +
+ ${state.error ? `

${esc(state.error)}

` : ""} +
+ + +
+
+ `; +} + +function renderConfirmStep(state: KycState): string { + const doc = DOC_TYPES.find((d) => d.value === state.docType)!; + return ` +
+

Review & submit

+

Please confirm your documents before submitting.

+
+ ${docIcon(doc.value)} + ${esc(doc.label)} +
+
+ ${state.files + .map( + (f) => ` +
+ ${esc(SIDE_LABEL[f.side])} + ${f.file.type === "application/pdf" ? `📄 ${esc(f.file.name)}` : `${esc(SIDE_LABEL[f.side])}`} +
+ `, + ) + .join("")} +
+ ${state.error ? `

${esc(state.error)}

` : ""} +
+ + +
+
+ `; +} + +function renderDoneStep(): string { + return ` +
+
+

Submitted successfully

+

Your documents are under review. We'll notify you once verification is complete.

+
+ `; +} + +function render(container: Element, state: KycState): void { + const stepContent = + state.step === "select" + ? renderSelectStep() + : state.step === "upload" + ? renderUploadStep(state) + : state.step === "confirm" + ? renderConfirmStep(state) + : renderDoneStep(); + + container.innerHTML = ` +
+ ${state.step !== "done" ? renderStepIndicator(state.step) : ""} + ${stepContent} +
+ `; +} + +function attachListeners( + container: Element, + state: KycState, + onSubmit: (submission: KycSubmission) => Promise, +): void { + // Step: select — doc type buttons + container.querySelectorAll(".kyc-doc-btn").forEach((btn) => { + btn.addEventListener("click", () => { + state.docType = btn.dataset["docType"] as KycDocType; + state.step = "upload"; + state.files = []; + state.error = null; + render(container, state); + attachListeners(container, state, onSubmit); + }); + }); + + // Step: upload — file inputs + container.querySelectorAll(".kyc-file-input").forEach((input) => { + input.addEventListener("change", () => { + void handleFileChange(input, container, state, onSubmit); + }); + }); + + // Step: upload — drag-and-drop + container.querySelectorAll(".kyc-upload-area").forEach((area) => { + area.addEventListener("dragover", (e) => { + e.preventDefault(); + area.classList.add("kyc-upload-area--drag"); + }); + area.addEventListener("dragleave", () => { + area.classList.remove("kyc-upload-area--drag"); + }); + area.addEventListener("drop", (e) => { + e.preventDefault(); + area.classList.remove("kyc-upload-area--drag"); + const side = (area.closest(".kyc-upload-slot")?.dataset["side"] ?? "front") as + | "front" + | "back"; + const file = e.dataTransfer?.files[0]; + if (file) void handleFile(file, side, container, state, onSubmit); + }); + }); + + // Step: upload — remove buttons + container.querySelectorAll(".kyc-upload-remove").forEach((btn) => { + btn.addEventListener("click", () => { + const side = btn.dataset["side"] as "front" | "back"; + state.files = state.files.filter((f) => f.side !== side); + state.error = null; + render(container, state); + attachListeners(container, state, onSubmit); + }); + }); + + // Back button + const backBtn = container.querySelector('[data-action="back"]'); + backBtn?.addEventListener("click", () => { + state.step = state.step === "upload" ? "select" : "upload"; + state.error = null; + render(container, state); + attachListeners(container, state, onSubmit); + }); + + // Continue button (upload → confirm) + const nextBtn = container.querySelector('[data-action="next"]'); + nextBtn?.addEventListener("click", () => { + state.step = "confirm"; + state.error = null; + render(container, state); + attachListeners(container, state, onSubmit); + }); + + // Submit button + const submitBtn = container.querySelector('[data-action="submit"]'); + submitBtn?.addEventListener("click", () => { + void handleSubmit(container, state, onSubmit); + }); +} + +async function handleFileChange( + input: HTMLInputElement, + container: Element, + state: KycState, + onSubmit: (submission: KycSubmission) => Promise, +): Promise { + const file = input.files?.[0]; + if (!file) return; + const side = input.dataset["side"] as "front" | "back"; + await handleFile(file, side, container, state, onSubmit); +} + +async function handleFile( + file: File, + side: "front" | "back", + container: Element, + state: KycState, + onSubmit: (submission: KycSubmission) => Promise, +): Promise { + const ALLOWED = ["image/jpeg", "image/png", "application/pdf"]; + const MAX_BYTES = 10 * 1024 * 1024; + + if (!ALLOWED.includes(file.type)) { + state.error = "Only JPG, PNG or PDF files are allowed."; + render(container, state); + attachListeners(container, state, onSubmit); + return; + } + if (file.size > MAX_BYTES) { + state.error = "File exceeds the 10 MB limit."; + render(container, state); + attachListeners(container, state, onSubmit); + return; + } + + const dataUrl = await readFileAsDataUrl(file); + state.files = state.files.filter((f) => f.side !== side); + state.files.push({ side, file, dataUrl }); + state.error = null; + render(container, state); + attachListeners(container, state, onSubmit); +} + +async function handleSubmit( + container: Element, + state: KycState, + onSubmit: (submission: KycSubmission) => Promise, +): Promise { + if (!state.docType) return; + state.submitting = true; + state.error = null; + render(container, state); + attachListeners(container, state, onSubmit); + + try { + await onSubmit({ docType: state.docType, files: state.files }); + state.step = "done"; + } catch (err) { + state.error = err instanceof Error ? err.message : "Submission failed. Please try again."; + state.submitting = false; + } + + render(container, state); + attachListeners(container, state, onSubmit); +} + +export function initKyc( + selector = "[data-kyc]", + onSubmit: (submission: KycSubmission) => Promise = defaultSubmit, +): void { + const container = document.querySelector(selector); + if (!container) return; + + const state: KycState = { + step: "select", + docType: null, + files: [], + submitting: false, + error: null, + }; + + render(container, state); + attachListeners(container, state, onSubmit); +} + +async function defaultSubmit(submission: KycSubmission): Promise { + const form = new FormData(); + form.append("doc_type", submission.docType); + submission.files.forEach((f) => form.append(`file_${f.side}`, f.file, f.file.name)); + + const endpoint = + (typeof document !== "undefined" && + document.querySelector("[data-kyc]")?.dataset["kycEndpoint"]) || + "/api/kyc/upload"; + + const res = await fetch(endpoint, { method: "POST", body: form }); + if (!res.ok) { + const msg = await res.text().catch(() => res.statusText); + throw new Error(msg || `Upload failed (${res.status})`); + } +}