Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: CI

on:
pull_request:
push:
branches: [main, master]

jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 11.15.1

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --config.strict-peer-dependencies=false

- name: Lint
run: pnpm lint

- name: Typecheck
run: pnpm typecheck

- name: Test
run: pnpm test

- name: Build
run: pnpm build
55 changes: 47 additions & 8 deletions app/(dashboard)/documents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,21 @@ export default function DocumentsPage() {
// After successful payment redirect: show document upload
useEffect(() => {
if (typeof window === "undefined") return
const params = new URLSearchParams(window.location.search)
if (params.get("payment") === "success") {
setHasCredit(true)
setShowPaymentModal(false)
setShowUpload(true)
window.history.replaceState({}, "", "/documents")

const url = new URL(window.location.href)
if (url.searchParams.get("payment") === "success") {
url.searchParams.delete("payment")

const cleanUrl = `${url.pathname}${url.searchParams.toString() ? `?${url.searchParams.toString()}` : ""}${url.hash}`
window.history.replaceState({}, "", cleanUrl)

const timer = window.setTimeout(() => {
setHasCredit(true)
setShowPaymentModal(false)
setShowUpload(true)
}, 0)

return () => window.clearTimeout(timer)
}
}, [])

Expand Down Expand Up @@ -63,12 +72,42 @@ export default function DocumentsPage() {
}

useEffect(() => {
fetchDocuments()
const timer = window.setTimeout(() => {
void (async () => {
try {
const response = await fetch("/api/documents")
if (response.ok) {
const data = await response.json()
setDocuments(data)
}
} catch (error) {
console.error("[v0] Failed to fetch documents:", error)
} finally {
setIsLoading(false)
}
})()
}, 0)
return () => window.clearTimeout(timer)
}, [])

useEffect(() => {
if (showUpload || showPaymentModal) return
fetchCredits()
const timer = window.setTimeout(() => {
void (async () => {
try {
const res = await fetch("/api/payments/credits")
if (res.ok) {
const data = await res.json()
setHasCredit(data.hasCredit)
} else {
setHasCredit(false)
}
} catch {
setHasCredit(false)
}
})()
}, 0)
return () => window.clearTimeout(timer)
}, [showUpload, showPaymentModal])

const handleDelete = async (doc: Document) => {
Expand Down
5 changes: 3 additions & 2 deletions app/p/[userId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Link from "next/link"
import { getPublicUserProfile } from "@/lib/db"
import { notFound } from "next/navigation"
import { Badge } from "@/components/ui/badge"
Expand Down Expand Up @@ -182,9 +183,9 @@ export default async function PublicProfilePage({ params }: PageProps) {
<footer className="border-t border-border pt-6 text-center">
<p className="text-xs text-muted-foreground">
Powered by{" "}
<a href="/" className="font-semibold text-foreground underline decoration-border hover:decoration-primary underline-offset-4">
<Link href="/" className="font-semibold text-foreground underline decoration-border hover:decoration-primary underline-offset-4">
SkillSync
</a>
</Link>
{" "}— evidence-based skill tracking for African students.
</p>
</footer>
Expand Down
7 changes: 5 additions & 2 deletions app/payment-callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@
import { useEffect, useState } from "react"

export default function PaymentCallbackPage() {
const [status, setStatus] = useState<"verifying" | "success" | "failed">("verifying")
const [status, setStatus] = useState<"verifying" | "success" | "failed">(() => {
if (typeof window === "undefined") return "verifying"
const params = new URLSearchParams(window.location.search)
return params.get("reference") ? "verifying" : "failed"
})

useEffect(() => {
const params = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
const reference = params.get("reference")
if (!reference) {
setStatus("failed")
return
}

Expand Down
10 changes: 6 additions & 4 deletions components/career/career-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,15 @@ export function CareerPicker({
if (value) return // Already selected; don't search
const trimmed = query.trim()
if (trimmed.length < 2) {
setSuggestions([])
setLoading(false)
return
const timer = window.setTimeout(() => {
setSuggestions([])
setLoading(false)
}, 0)
return () => window.clearTimeout(timer)
}
const reqId = ++requestIdRef.current
setLoading(true)
const t = setTimeout(async () => {
setLoading(true)
try {
const res = await fetch(`/api/onet/careers?q=${encodeURIComponent(trimmed)}`)
if (!res.ok) {
Expand Down
6 changes: 4 additions & 2 deletions components/documents/payment-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ export function PaymentModal({ open, onClose, onSuccess }: PaymentModalProps) {

// Reset state every time the modal opens
useEffect(() => {
if (open) {
if (!open) return
const timer = window.setTimeout(() => {
setPhase("form")
setError(null)
setStatusMessage("")
setReference(null)
setSubmitting(false)
}
}, 0)
return () => window.clearTimeout(timer)
}, [open])

// Polling loop while waiting for STK push to settle
Expand Down
11 changes: 7 additions & 4 deletions components/landing/hero.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Image from "next/image"
import Link from "next/link"
import { ArrowUpRight } from "lucide-react"
import { TrustedBy } from "@/components/landing/trusted-by"
Expand Down Expand Up @@ -56,10 +57,11 @@ export function Hero() {

{/* Skill Growth card */}
<div className="col-span-2 relative rounded-[20px] text-white p-5 flex items-start justify-between min-h-[200px] overflow-hidden">
<img
<Image
src="/images/hero/skill-growth.jpeg"
alt=""
className="absolute inset-0 size-full object-cover"
fill
className="absolute inset-0 object-cover"
/>
<span className="font-jakarta font-bold text-lg leading-tight self-end relative z-10">
Skill
Expand All @@ -71,10 +73,11 @@ export function Hero() {

{/* Wide chart card — grows to align its bottom with the hero buttons */}
<div className="flex-1 relative rounded-[20px] bg-ink text-ink-foreground p-6 sm:p-7 flex items-end justify-between gap-6 min-h-[150px]">
<img
<Image
src="/images/hero/skill-readiness-climb.jpeg"
alt=""
className="absolute inset-0 size-full object-cover rounded-[20px]"
fill
className="absolute inset-0 object-cover rounded-[20px]"
/>
<div className="space-y-3 min-w-0 relative z-10">
<div className="flex items-center gap-3">
Expand Down
7 changes: 5 additions & 2 deletions components/landing/reveal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function Reveal({ children }: { children: React.ReactNode }) {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)")
if (!desktop.matches || reduce.matches) return

setActive(true)
const timer = window.setTimeout(() => setActive(true), 0)

const io = new IntersectionObserver(
([entry]) => {
Expand All @@ -38,7 +38,10 @@ export function Reveal({ children }: { children: React.ReactNode }) {
{ threshold: 0, rootMargin: "-12% 0px -12% 0px" },
)
io.observe(el)
return () => io.disconnect()
return () => {
io.disconnect()
window.clearTimeout(timer)
}
}, [])

const transform =
Expand Down
15 changes: 10 additions & 5 deletions components/landing/services-intro.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client"

import Image from "next/image"
import { Play, Plus, X } from "lucide-react"
import { useState, useEffect, useCallback } from "react"

Expand Down Expand Up @@ -39,10 +40,11 @@ export function ServicesIntro() {
<div className="grid lg:grid-cols-12 gap-5 sm:gap-6 items-stretch">
{/* Left — dark stat card */}
<div className="lg:col-span-4 relative overflow-hidden rounded-[30px] bg-ink text-ink-foreground p-8 sm:p-9 flex flex-col min-h-[340px] sm:min-h-[382px]">
<img
<Image
src="/images/service-intro/students-skills-bg.jpeg"
alt=""
className="absolute inset-0 size-full object-cover"
fill
className="absolute inset-0 object-cover"
/>
<div
aria-hidden
Expand All @@ -56,10 +58,12 @@ export function ServicesIntro() {
<div className="flex items-center shrink-0">
<div className="flex items-center">
{[0, 1, 2, 3].map((i) => (
<img
<Image
key={i}
src={`/images/avatars/student-${i}.jpeg`}
alt=""
width={40}
height={40}
className="size-8 sm:size-10 rounded-full ring-2 ring-black/30 object-cover -ml-2 first:ml-0"
/>
))}
Expand Down Expand Up @@ -87,10 +91,11 @@ export function ServicesIntro() {
<div className="lg:col-span-8 relative rounded-[30px] bg-[#c7c8ca] dark:bg-[#26262b] min-h-[300px] sm:min-h-[382px] flex items-center justify-center px-8 group cursor-pointer w-full">
{/* Image clipped to rounded corners */}
<div className="absolute inset-0 overflow-hidden rounded-[30px]">
<img
<Image
src={YT_THUMB}
alt=""
className="absolute inset-0 size-full object-cover"
fill
className="absolute inset-0 object-cover"
/>
<div className="absolute inset-0 bg-black/25 dark:bg-black/45" />
</div>
Expand Down
4 changes: 2 additions & 2 deletions components/skills/record-on-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,8 @@ export function RecordOnBase({ skills }: RecordOnBaseProps) {

useEffect(() => {
if (!linkedAddress) {
setVerifiedOnChain(null)
return
const timer = window.setTimeout(() => setVerifiedOnChain(null), 0)
return () => window.clearTimeout(timer)
}
const fetchVerified = async () => {
try {
Expand Down
3 changes: 2 additions & 1 deletion components/theme-toggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ export function ThemeToggle() {

// useEffect only runs on the client, so now we can safely show the UI
React.useEffect(() => {
setMounted(true)
const timer = window.setTimeout(() => setMounted(true), 0)
return () => window.clearTimeout(timer)
}, [])

if (!mounted) {
Expand Down
22 changes: 22 additions & 0 deletions lib/password-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest"
import { validatePasswordStrength } from "./password-validation"

describe("validatePasswordStrength", () => {
it("accepts a strong password", () => {
const result = validatePasswordStrength("StrongPass123!")

expect(result.isValid).toBe(true)
expect(result.strength).toBe("strong")
expect(result.errors).toEqual([])
})

it("rejects a weak password", () => {
const result = validatePasswordStrength("password")

expect(result.isValid).toBe(false)
expect(result.strength).toBe("weak")
expect(result.errors).toContain("Password must contain at least one uppercase letter")
expect(result.errors).toContain("Password must contain at least one number")
expect(result.errors).toContain("Password must contain at least one special character (!@#$%^&*...)")
})
})
9 changes: 9 additions & 0 deletions lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest"
import { cn } from "./utils"

describe("cn", () => {
it("merges class names and resolves Tailwind conflicts", () => {
expect(cn("px-2", "px-4", "text-sm")).toBe("px-4 text-sm")
expect(cn("bg-red-500", "bg-blue-500")).toBe("bg-blue-500")
})
})
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"scripts": {
"build": "next build",
"dev": "next dev",
"lint": "eslint .",
"lint": "eslint --config eslint.config.cjs .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"start": "next start",
"db:migrate": "node scripts/run-migrations.mjs",
"onet:import": "node scripts/onet-import.mjs",
Expand Down Expand Up @@ -74,15 +76,19 @@
"zod": "3.25.76"
},
"devDependencies": {
"@eslint/eslintrc": "^2.1.4",
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22",
"@types/nodemailer": "^7.0.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9.22.0",
"eslint-config-next": "^16.2.12",
"pg": "^8.20.0",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
"typescript": "^5",
"vitest": "^4.1.10"
}
}
Loading