From 1402b6e2fab7f7463b0f137b5623f456aa1cc7e3 Mon Sep 17 00:00:00 2001
From: Linda-riziki
Date: Thu, 30 Jul 2026 18:10:06 +0300
Subject: [PATCH 1/2] Finalize footer and newsletter changes
---
app/api/newsletter/route.ts | 67 ++++++++
app/privacy/page.tsx | 220 +++++++++++++++++++++++++
components/landing/landing-footer.tsx | 110 ++++++-------
components/landing/newsletter-form.tsx | 155 +++++++++++++++++
scripts/013_newsletter_subscribers.sql | 29 ++++
scripts/run-migrations.mjs | 5 +
6 files changed, 530 insertions(+), 56 deletions(-)
create mode 100644 app/api/newsletter/route.ts
create mode 100644 app/privacy/page.tsx
create mode 100644 components/landing/newsletter-form.tsx
create mode 100644 scripts/013_newsletter_subscribers.sql
diff --git a/app/api/newsletter/route.ts b/app/api/newsletter/route.ts
new file mode 100644
index 0000000..222a1c0
--- /dev/null
+++ b/app/api/newsletter/route.ts
@@ -0,0 +1,67 @@
+import { createClient } from "@/lib/supabase/server"
+import { NextResponse } from "next/server"
+import { HTTP, apiError, isUniqueViolation, readJsonBody } from "@/lib/api-errors"
+
+// Same shape used for the payer email in /api/payments/initiate — kept
+// consistent rather than introducing a second notion of "valid email".
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+const MAX_EMAIL_LENGTH = 254 // RFC 5321 practical limit
+
+/**
+ * Newsletter sign-up from the landing footer.
+ *
+ * Stores the address in `newsletter_subscribers` (scripts/013). No third-party
+ * marketing provider is involved, so nothing leaves our own database — see
+ * app/privacy/page.tsx, which is linked at the point of collection.
+ */
+export async function POST(request: Request) {
+ try {
+ const parsed = await readJsonBody<{ email?: unknown }>(request)
+ if (!parsed.ok) return parsed.response
+
+ const raw = parsed.data.email
+ if (typeof raw !== "string" || !raw.trim()) {
+ return apiError("Please enter your email address.", HTTP.BAD_REQUEST)
+ }
+
+ const email = raw.trim().toLowerCase()
+
+ if (email.length > MAX_EMAIL_LENGTH) {
+ return apiError("That email address is too long.", HTTP.BAD_REQUEST)
+ }
+ // Validated on the server as well as in the browser: the client check is a
+ // courtesy, this one is the actual guarantee.
+ if (!EMAIL_RE.test(email)) {
+ return apiError("That doesn't look like a valid email address.", HTTP.BAD_REQUEST)
+ }
+
+ const supabase = await createClient()
+ // No `.select()` — RLS deliberately grants insert only, so reading the row
+ // back would fail.
+ const { error } = await supabase
+ .from("newsletter_subscribers")
+ .insert({ email, source: "landing-footer" })
+
+ if (error) {
+ // Already on the list. A real outcome, reported honestly rather than
+ // dressed up as a fresh subscription.
+ if (isUniqueViolation(error)) {
+ return NextResponse.json({ subscribed: true, alreadySubscribed: true })
+ }
+ console.error("[newsletter] Insert failed:", error)
+ return apiError(
+ "Couldn't save your subscription. Please try again.",
+ HTTP.INTERNAL_SERVER_ERROR,
+ )
+ }
+
+ return NextResponse.json({ subscribed: true, alreadySubscribed: false }, { status: 201 })
+ } catch (err) {
+ console.error("[newsletter] Unexpected error:", err)
+ return apiError(
+ "Couldn't save your subscription. Please try again.",
+ HTTP.INTERNAL_SERVER_ERROR,
+ )
+ }
+}
diff --git a/app/privacy/page.tsx b/app/privacy/page.tsx
new file mode 100644
index 0000000..961f411
--- /dev/null
+++ b/app/privacy/page.tsx
@@ -0,0 +1,220 @@
+import Link from "next/link"
+import type { Metadata } from "next"
+
+export const metadata: Metadata = {
+ title: "Privacy Policy",
+ description:
+ "What data SkillSync collects, which services process it, how long it is kept, and how to have it removed.",
+}
+
+const CONTACT_EMAIL = "hello@skillssync.xyz"
+
+/**
+ * Public privacy notice. Linked from the landing footer and from the newsletter
+ * form at the point of collection.
+ *
+ * Everything stated here is drawn from what the application actually does — the
+ * tables in scripts/, the processors in lib/, and the retention rules in
+ * docs/coursework-retention-policy.md. It intentionally makes no commitments
+ * that the code does not already implement.
+ */
+export default function PrivacyPolicyPage() {
+ return (
+
+
+
+
Legal
+
+ Privacy Policy
+
+
+ This notice describes what SkillSync collects, who processes it, how long
+ it is kept, and how to have it removed. Questions can go to{" "}
+
+ {CONTACT_EMAIL}
+
+ .
+
+
+
+
+
+
What we collect
+
+
+ Account details. Your email
+ address, and a password if you do not sign in with Google. Authentication
+ is handled by Supabase.
+
+
+ Your goals. The career goal,
+ education level, programme, year of study, priorities and courses you enter
+ during onboarding.
+
+
+ Documents you upload. The
+ coursework, transcripts, project reports and similar files you choose to
+ submit for analysis.
+
+
+ Skills extracted from them.{" "}
+ Skill names, categories, confidence scores and short quoted excerpts from
+ your documents, plus the readiness scores and skill gaps calculated from
+ them.
+
+
+ Payment records. For each paid
+ upload we store a payment reference, the amount and whether it succeeded.
+ We never see or store your card details or M-PESA PIN.
+
+
+ A wallet address, only if you link
+ one. Optional, and only used for the blockchain feature described
+ below.
+
+
+ A newsletter email address, only if
+ you subscribe. Nothing else is collected with it.
+
+ Google (Gemini) — reads the
+ documents you upload in order to extract skills from them. This is the one
+ place a document leaves our own infrastructure, and it happens only when
+ you upload a file for analysis.
+
+
+ Paystack — takes the M-PESA
+ payment for each upload.
+
+
+ Vercel — hosts the site and
+ provides page-view analytics, which are collected without cookies.
+
+
+ Our email provider — sends
+ account emails such as sign-in notifications.
+
+
+
+ We do not sell your data, and we do not use a third-party marketing platform
+ for the newsletter — subscriber addresses stay in our own database.
+
+
+
+
+
+ Who can see your profile
+
+
+ Your documents and skills are private by default. Uploaded files are kept in
+ private storage and are never reachable by a public link. If you switch on
+ profile sharing, a page showing your career goal, extracted skill names and
+ readiness summary becomes viewable by anyone with the link — your uploaded
+ files never are. You can switch sharing off again at any time from your
+ profile.
+
+
+
+
+
+ The blockchain feature is permanent
+
+
+ If you choose to record your skills on Base, a one-way fingerprint (hash) of
+ your skill list and your wallet address are written to a public blockchain.
+ Your skills, documents and email are not. Because a blockchain cannot be
+ edited, that record cannot be deleted by us or by you. This feature is
+ entirely optional.
+
+
+
+
+
How long we keep it
+
+
+ Uploaded files: up to 24 months
+ from upload, then deleted.
+
+
+ Extracted skills and readiness
+ history: kept until you delete them or your account.
+
+
+ Newsletter address: kept until
+ you unsubscribe.
+
+
+
+ Deleting a document removes the stored file immediately, along with the skills
+ that were extracted from it.
+
+
+
+
+
+ Removing your data
+
+
+ You can delete any uploaded document from the Documents page, and turn off
+ profile sharing from the Profile page. To unsubscribe from the newsletter, or
+ to have your account and its data deleted, email{" "}
+
+ {CONTACT_EMAIL}
+ {" "}
+ and we will action it. The blockchain record described above is the one
+ exception that cannot be removed.
+
+
+
+
+
Cookies
+
+ SkillSync uses one cookie, and it is strictly necessary:
+
+
+
+ Session cookie (set by
+ Supabase, named sb-…-auth-token)
+ — keeps you signed in. Without it you would be logged out on every page
+ load. It is removed when you sign out.
+
+
+
+ We set no advertising or tracking cookies. Page-view analytics are collected
+ without cookies. Your light/dark theme choice is saved in your browser’s
+ local storage, not in a cookie, and never leaves your device.
+
+
+
+
+
+
+
+ Back to home
+
+
+
+
+ )
+}
diff --git a/components/landing/landing-footer.tsx b/components/landing/landing-footer.tsx
index 041f35e..d6e551c 100644
--- a/components/landing/landing-footer.tsx
+++ b/components/landing/landing-footer.tsx
@@ -1,8 +1,19 @@
import Link from "next/link"
import Image from "next/image"
-import { Github, Linkedin, Twitter, ArrowRight } from "lucide-react"
+import { Github } from "lucide-react"
+import { NewsletterForm } from "@/components/landing/newsletter-form"
-const COLUMNS = [
+const REPO_URL = "https://github.com/CodeWithEugene/SkillsSync"
+
+// Every href below resolves to something real: an existing section on this page,
+// an existing route, a mailto, or the public repository. About Us, Blog, Careers,
+// Guides, Status and Terms of Use previously pointed at "#" — there is no page or
+// official URL for any of them, so they were removed rather than left as dead
+// links. Re-add each one only when its destination actually exists.
+const COLUMNS: Array<{
+ title: string
+ links: Array<{ label: string; href: string; external?: boolean }>
+}> = [
{
title: "Product",
links: [
@@ -15,37 +26,35 @@ const COLUMNS = [
},
{
title: "Company",
- links: [
- { label: "About Us", href: "#" },
- { label: "Blog", href: "#" },
- { label: "Careers", href: "#" },
- { label: "Contact", href: "mailto:hello@skillssync.xyz" },
- ],
+ links: [{ label: "Contact", href: "mailto:hello@skillssync.xyz" }],
},
{
title: "Resources",
links: [
- { label: "Documentation", href: "#" },
- { label: "Guides", href: "#" },
+ { label: "Documentation", href: `${REPO_URL}#readme`, external: true },
+ { label: "Source Code", href: REPO_URL, external: true },
{ label: "Support", href: "mailto:hello@skillssync.xyz" },
- { label: "Status", href: "#" },
],
},
{
title: "Legal",
links: [
- { label: "Privacy Policy", href: "#" },
- { label: "Terms of Use", href: "#" },
- { label: "Cookie Policy", href: "#" },
+ { label: "Privacy Policy", href: "/privacy" },
+ { label: "Cookie Policy", href: "/privacy#cookies" },
],
},
]
-const SOCIALS = [
- { Icon: Twitter, href: "https://twitter.com", label: "Twitter" },
- { Icon: Linkedin, href: "https://linkedin.com", label: "LinkedIn" },
- { Icon: Github, href: "https://github.com", label: "GitHub" },
-]
+// Only accounts that can actually be verified as belonging to this project. The
+// repository is confirmed public; no official SkillSync Twitter/X or LinkedIn
+// account exists, so those icons were removed instead of pointing at a platform
+// home page.
+const SOCIALS = [{ Icon: Github, href: REPO_URL, label: "SkillSync on GitHub" }]
+
+// Shared so keyboard focus is visible on the dark background for every link in
+// the columns, not just on hover.
+const LINK_CLASS =
+ "inline-block text-sm text-white/60 hover:text-white transition-colors rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black"
export function LandingFooter({ year }: { year: number }) {
return (
@@ -77,30 +86,8 @@ export function LandingFooter({ year }: { year: number }) {
made for the African graduate.
- {/* Newsletter */}
-
-
- Stay In The Loop
-
-
-
+ {/* Newsletter — client island so the footer stays a server component */}
+
{/* Link columns */}
@@ -109,16 +96,27 @@ export function LandingFooter({ year }: { year: number }) {
))}
@@ -139,10 +137,10 @@ export function LandingFooter({ year }: { year: number }) {
href={href}
target="_blank"
rel="noopener noreferrer"
- aria-label={label}
- className="inline-flex items-center justify-center size-9 rounded-full border border-white/20 text-white/60 hover:text-white hover:border-white/40 transition-colors"
+ aria-label={`${label} (opens in a new tab)`}
+ className="inline-flex items-center justify-center size-9 rounded-full border border-white/20 text-white/60 hover:text-white hover:border-white/40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-black"
>
-
+
))}
diff --git a/components/landing/newsletter-form.tsx b/components/landing/newsletter-form.tsx
new file mode 100644
index 0000000..406abca
--- /dev/null
+++ b/components/landing/newsletter-form.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import Link from "next/link"
+import { useState } from "react"
+import { ArrowRight, Loader2 } from "lucide-react"
+
+// Mirrors the check in app/api/newsletter/route.ts so the user gets instant
+// feedback; the server remains the authority.
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+
+type Status =
+ | { state: "idle" }
+ | { state: "submitting" }
+ | { state: "success"; message: string }
+ | { state: "error"; message: string }
+
+export function NewsletterForm() {
+ const [email, setEmail] = useState("")
+ const [status, setStatus] = useState({ state: "idle" })
+
+ const isSubmitting = status.state === "submitting"
+ const isInvalid = status.state === "error"
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault()
+
+ // Guard against a second submit while one is in flight.
+ if (isSubmitting) return
+
+ const trimmed = email.trim()
+ if (!trimmed) {
+ setStatus({ state: "error", message: "Please enter your email address." })
+ return
+ }
+ if (!EMAIL_RE.test(trimmed)) {
+ setStatus({
+ state: "error",
+ message: "That doesn't look like a valid email address.",
+ })
+ return
+ }
+
+ setStatus({ state: "submitting" })
+
+ try {
+ const res = await fetch("/api/newsletter", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email: trimmed }),
+ })
+
+ const data = await res.json().catch(() => ({}))
+
+ if (!res.ok) {
+ setStatus({
+ state: "error",
+ message:
+ (data as { error?: string }).error ??
+ "Couldn't subscribe you just now. Please try again.",
+ })
+ return
+ }
+
+ setEmail("")
+ setStatus({
+ state: "success",
+ message: (data as { alreadySubscribed?: boolean }).alreadySubscribed
+ ? "You're already on the list — nothing more to do."
+ : "You're subscribed. Watch your inbox.",
+ })
+ } catch {
+ // Offline, DNS failure, request aborted — never reported as success.
+ setStatus({
+ state: "error",
+ message: "Network error. Check your connection and try again.",
+ })
+ }
+ }
+
+ return (
+
+
Stay In The Loop
+
+
+ {/* Consent notice at the point of collection, linking to the notice that
+ explains what we do with the address. */}
+
+ By subscribing you agree to receive occasional emails about SkillSync. We
+ store only your address and you can unsubscribe at any time — see our{" "}
+
+ Privacy Policy
+
+ .
+
+
+ {/* Announced to screen readers as it changes. Always rendered so the live
+ region exists before the first message lands. */}
+
+ )
+}
diff --git a/scripts/013_newsletter_subscribers.sql b/scripts/013_newsletter_subscribers.sql
new file mode 100644
index 0000000..6d61dd5
--- /dev/null
+++ b/scripts/013_newsletter_subscribers.sql
@@ -0,0 +1,29 @@
+-- 013: Newsletter subscribers
+--
+-- Backs the landing-footer newsletter form. Before this, the form had no
+-- handler and submitting it did nothing.
+--
+-- Safe to re-run.
+
+CREATE TABLE IF NOT EXISTS public.newsletter_subscribers (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ email TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'landing-footer',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- One row per address, case-insensitively. The API turns the resulting unique
+-- violation into a plain "you're already subscribed" reply.
+CREATE UNIQUE INDEX IF NOT EXISTS idx_newsletter_subscribers_email
+ ON public.newsletter_subscribers (lower(email));
+
+ALTER TABLE public.newsletter_subscribers ENABLE ROW LEVEL SECURITY;
+
+-- Anyone (signed in or not) may subscribe, because the form is on the public
+-- landing page. Deliberately no SELECT/UPDATE/DELETE policy: the list is only
+-- readable with the service role, so one visitor cannot enumerate or edit
+-- another person's address.
+DROP POLICY IF EXISTS "Anyone can subscribe to the newsletter" ON public.newsletter_subscribers;
+CREATE POLICY "Anyone can subscribe to the newsletter"
+ ON public.newsletter_subscribers FOR INSERT TO anon, authenticated
+ WITH CHECK (true);
diff --git a/scripts/run-migrations.mjs b/scripts/run-migrations.mjs
index be7ec27..268d8c6 100644
--- a/scripts/run-migrations.mjs
+++ b/scripts/run-migrations.mjs
@@ -31,6 +31,11 @@ const migrations = [
"008_onchain_attestations.sql",
"009_upload_payments.sql",
"011_user_soc_code.sql",
+<<<<<<< HEAD
+=======
+ "012_private_coursework_storage.sql",
+ "013_newsletter_subscribers.sql",
+>>>>>>> 53ee469 (Finalize footer and newsletter changes)
]
const connectionString =
From a67bbb3b9dcbcbea5977e6cd70b01f71dd94c3f3 Mon Sep 17 00:00:00 2001
From: Linda-riziki
Date: Mon, 3 Aug 2026 17:24:50 +0300
Subject: [PATCH 2/2] Add shared API error utilities
---
lib/api-errors.ts | 162 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 162 insertions(+)
create mode 100644 lib/api-errors.ts
diff --git a/lib/api-errors.ts b/lib/api-errors.ts
new file mode 100644
index 0000000..82c0f0a
--- /dev/null
+++ b/lib/api-errors.ts
@@ -0,0 +1,162 @@
+/**
+ * Shared helpers so every API route reports the *same* HTTP status code for the
+ * same kind of failure.
+ *
+ * Why this matters: the status code is the machine-readable half of an error.
+ * The message tells the user what went wrong; the code tells the browser, the
+ * payment provider, and our own logs *what kind* of thing went wrong — whether
+ * to retry, whether to re-authenticate, whether it's our fault or theirs.
+ *
+ * Usage:
+ * return apiError("Course not found.", HTTP.NOT_FOUND)
+ */
+import { NextResponse } from "next/server"
+
+export const HTTP = {
+ /** Malformed or invalid input from the caller. */
+ BAD_REQUEST: 400,
+ /** Not signed in, or the session expired. */
+ UNAUTHORIZED: 401,
+ /** Signed in, but this action needs a payment first. */
+ PAYMENT_REQUIRED: 402,
+ /** Signed in, but not allowed to touch this resource. */
+ FORBIDDEN: 403,
+ /** The thing being asked for doesn't exist. */
+ NOT_FOUND: 404,
+ /** The request clashes with current state (duplicate, already done). */
+ CONFLICT: 409,
+ /** Body/file is bigger than we accept. */
+ PAYLOAD_TOO_LARGE: 413,
+ /** File type we can't process. */
+ UNSUPPORTED_MEDIA_TYPE: 415,
+ /** Too many requests — caller should back off. */
+ TOO_MANY_REQUESTS: 429,
+ /** Our bug. Never the caller's fault. */
+ INTERNAL_SERVER_ERROR: 500,
+ /** An upstream service (AI, payments, RPC) answered, but badly. */
+ BAD_GATEWAY: 502,
+ /** An upstream service is unreachable, or we're not configured for it. */
+ SERVICE_UNAVAILABLE: 503,
+} as const
+
+/** Standard error response shape: `{ error: "message" }` plus a status code. */
+export function apiError(
+ message: string,
+ status: number,
+ extra?: Record,
+) {
+ return NextResponse.json({ error: message, ...extra }, { status })
+}
+
+/**
+ * Read a JSON body without throwing. A malformed body is the caller's mistake
+ * (400), not a server crash (500) — which is what an unguarded
+ * `await request.json()` produces.
+ */
+export async function readJsonBody>(
+ request: Request,
+): Promise<{ ok: true; data: T } | { ok: false; response: NextResponse }> {
+ let raw: unknown
+ try {
+ raw = await request.json()
+ } catch {
+ return {
+ ok: false,
+ response: apiError("That request wasn't valid JSON.", HTTP.BAD_REQUEST),
+ }
+ }
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
+ return {
+ ok: false,
+ response: apiError("Request body must be a JSON object.", HTTP.BAD_REQUEST),
+ }
+ }
+ return { ok: true, data: raw as T }
+}
+
+// ── Postgres / PostgREST error identification ────────────────────────────────
+// Supabase surfaces the underlying database error code, which tells us whether
+// a failed query was the caller's fault (404/400/409) or ours (500).
+
+function errorCode(err: unknown): string | undefined {
+ if (err && typeof err === "object" && "code" in err) {
+ const code = (err as { code?: unknown }).code
+ if (typeof code === "string") return code
+ }
+ return undefined
+}
+
+/** PostgREST: `.single()` matched zero rows → the row doesn't exist. → 404 */
+export function isNoRowsError(err: unknown): boolean {
+ return errorCode(err) === "PGRST116"
+}
+
+/** Postgres 23505: unique constraint violated → the row already exists. → 409 */
+export function isUniqueViolation(err: unknown): boolean {
+ return errorCode(err) === "23505"
+}
+
+/** Postgres 23514: CHECK constraint violated → a value isn't allowed. → 400 */
+export function isCheckViolation(err: unknown): boolean {
+ return errorCode(err) === "23514"
+}
+
+/** Postgres 22P02: malformed literal (e.g. "abc" for a uuid column). → 400 */
+export function isInvalidInputError(err: unknown): boolean {
+ return errorCode(err) === "22P02"
+}
+
+// ── Input format guards ──────────────────────────────────────────────────────
+
+export const UUID_RE =
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
+export const SOC_CODE_RE = /^[0-9]{2}-[0-9]{4}\.[0-9]{2}$/
+/** 0x + 64 hex — both an Ethereum tx hash and a bytes32 profile hash. */
+export const HASH_32_RE = /^0x[0-9a-fA-F]{64}$/
+export const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/
+
+export function isUuid(value: unknown): boolean {
+ return typeof value === "string" && UUID_RE.test(value)
+}
+
+// ── Upstream (AI / RPC / payments) failure classification ────────────────────
+
+/**
+ * Decide the right status for a thrown upstream error.
+ *
+ * 429 — we hit a rate limit or quota; the caller should wait and retry.
+ * 503 — we couldn't reach the service at all, or it isn't configured.
+ * 502 — we reached it and it answered with something unusable.
+ *
+ * All three say "not the caller's fault, and not a bug in our code" — which is
+ * exactly what a blanket 500 fails to communicate.
+ */
+export function upstreamStatus(err: unknown): number {
+ const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase()
+
+ if (
+ message.includes("rate limit") ||
+ message.includes("quota") ||
+ message.includes("too many requests") ||
+ message.includes("429") ||
+ message.includes("resource_exhausted")
+ ) {
+ return HTTP.TOO_MANY_REQUESTS
+ }
+
+ if (
+ message.includes("is not set") ||
+ message.includes("missing") ||
+ message.includes("api key") ||
+ message.includes("enotfound") ||
+ message.includes("econnrefused") ||
+ message.includes("etimedout") ||
+ message.includes("timeout") ||
+ message.includes("fetch failed") ||
+ message.includes("network")
+ ) {
+ return HTTP.SERVICE_UNAVAILABLE
+ }
+
+ return HTTP.BAD_GATEWAY
+}