diff --git a/next.config.mjs b/next.config.mjs index c9ddb84d..fdf681b1 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import("next").NextConfig} */ const nextConfig = { + transpilePackages: ["driver.js"], images: { remotePatterns: [ { diff --git a/package-lock.json b/package-lock.json index ce0b55ff..80555633 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@upstash/redis": "^1.38.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "driver.js": "^1.4.0", "html-to-image": "^1.11.13", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.7", @@ -2289,6 +2290,12 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index fd2359d8..0737870a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "clsx": "^2.1.1", "date-fns": "^3.6.0", "html-to-image": "^1.11.13", + "driver.js": "^1.4.0", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.7", "next": "^14.2.35", diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts index 04e60932..fbc0a229 100644 --- a/src/app/api/user/settings/route.ts +++ b/src/app/api/user/settings/route.ts @@ -24,12 +24,14 @@ export async function GET(req: NextRequest) { // Fetch user from Supabase const { data, error } = await supabaseAdmin .from("users") - .select("id, github_login, is_public, leaderboard_opt_in") + .select( + "id, github_login, is_public, leaderboard_opt_in, seen_onboarding" + ) .eq("id", user.id) .single(); - if (error) { console.error("Error fetching user:", error); + return NextResponse.json( { error: "Failed to fetch user settings" }, { status: 500 } @@ -46,18 +48,23 @@ export async function PATCH(req: NextRequest) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Get user ID from Supabase - const user = await resolveAppUser(session.githubId, session.githubLogin); +const user = await resolveAppUser(session.githubId, session.githubLogin); - if (!user) { - return NextResponse.json( - { error: "User not found" }, - { status: 404 } - ); - } +if (!user) { + console.error("Error fetching user"); + + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); +} + + let body: { + is_public?: boolean; + leaderboard_opt_in?: boolean; + seen_onboarding?: boolean; + }; - // Parse request body - let body: { is_public?: boolean; leaderboard_opt_in?: boolean }; try { body = await req.json(); } catch { @@ -67,49 +74,66 @@ export async function PATCH(req: NextRequest) { ); } - const { is_public, leaderboard_opt_in } = body; + const { + is_public, + leaderboard_opt_in, + seen_onboarding, + } = body; - if ( - typeof is_public !== "boolean" && - typeof leaderboard_opt_in !== "boolean" - ) { - return NextResponse.json( - { error: "At least one boolean setting is required" }, - { status: 400 } - ); - } + const updates: { + is_public?: boolean; + leaderboard_opt_in?: boolean; + seen_onboarding?: boolean; + } = {}; - const updates: { is_public?: boolean; leaderboard_opt_in?: boolean } = {}; if (typeof is_public === "boolean") { updates.is_public = is_public; } + if (typeof leaderboard_opt_in === "boolean") { updates.leaderboard_opt_in = leaderboard_opt_in; + + // If user opts into leaderboard, profile must be public if (leaderboard_opt_in) { updates.is_public = true; } } + if (typeof seen_onboarding === "boolean") { + updates.seen_onboarding = seen_onboarding; + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json( + { error: "No valid fields to update" }, + { status: 400 } + ); + } + const { data: updated, error: updateError } = await supabaseAdmin .from("users") .update(updates) .eq("id", user.id) - .select("id, github_login, is_public, leaderboard_opt_in") + .select( + "id, github_login, is_public, leaderboard_opt_in, seen_onboarding" + ) .single(); if (updateError || !updated) { - console.error("Error updating settings:", updateError); + console.error("Failed to update settings:", updateError); + return NextResponse.json( { error: "Failed to update settings" }, { status: 500 } ); } - // Return updated user (only safe fields) return NextResponse.json({ id: updated.id, github_login: updated.github_login, is_public: updated.is_public, leaderboard_opt_in: updated.leaderboard_opt_in ?? false, + seen_onboarding: updated.seen_onboarding, }); } + diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 0a5de926..d4914bd6 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -51,7 +51,9 @@ export default async function DashboardPage() { {/* Row 1: Contribution graph + heatmap + Friend Comparison on left, Streak on right */}
- +
+ +
@@ -59,15 +61,18 @@ export default async function DashboardPage() {
- -
- +
+
+ +
{/* Row 2: PR metrics, PR breakdown & Time Chart */}
- +
+ +
@@ -91,10 +96,15 @@ export default async function DashboardPage() { {/* Row 5: Top repos + Language breakdown + Goal tracker */}
- +
+ +
- +
+ +
); } + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4ab1a645..d254ac36 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import Providers from "./providers"; +import "driver.js/dist/driver.css"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); @@ -50,3 +51,4 @@ export default function RootLayout({ ); } + diff --git a/src/components/DashboardHeader.tsx b/src/components/DashboardHeader.tsx index 1d5edda3..dc6ca062 100644 --- a/src/components/DashboardHeader.tsx +++ b/src/components/DashboardHeader.tsx @@ -1,20 +1,28 @@ -"use client" -import NotificationBell from "@/components/NotificationBell"; -import { useEffect, useState } from "react"; +"use client"; + +import { useEffect, useState, useCallback } from "react"; import { useSession } from "next-auth/react"; import AccountToggle from "@/components/AccountToggle"; import SignOutButton from "@/components/SignOutButton"; import ThemeToggle from "@/components/ThemeToggle"; import UserAvatar from "@/components/UserAvatar"; +import NotificationBell from "@/components/NotificationBell"; import KeyboardShortcuts from "@/components/KeyboardShortcuts"; +import OnboardingTour from "@/components/OnboardingTour"; + +interface UserSettings { + is_public: boolean; + seen_onboarding: boolean; +} export default function DashboardHeader() { const { data: session } = useSession(); - const [isPublic, setIsPublic] = useState(null); + const [settings, setSettings] = useState(null); + const [showTour, setShowTour] = useState(false); useEffect(() => { if (!session) { - setIsPublic(null); + setSettings(null); return; } @@ -23,52 +31,85 @@ export default function DashboardHeader() { const res = await fetch("/api/user/settings"); if (res.ok) { const data = await res.json(); - setIsPublic(data.is_public === true); + setSettings(data); + if (!data.seen_onboarding) { + setShowTour(true); + } } else { - setIsPublic(false); + setSettings({ is_public: false, seen_onboarding: false }); } } catch (error) { console.error("Failed to load settings:", error); - setIsPublic(false); + setSettings({ is_public: false, seen_onboarding: false }); } } loadSettings(); }, [session]); + const handleTourComplete = useCallback(async () => { + setShowTour(false); + try { + await fetch("/api/user/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ seen_onboarding: true }), + }); + setSettings((prev) => + prev ? { ...prev, seen_onboarding: true } : prev + ); + } catch (error) { + console.error("Failed to save onboarding state:", error); + } + }, []); + + const handleRestartTour = useCallback(() => { + setShowTour(true); + }, []); + return ( -
-
-
-

- Dashboard -

-

- Your coding activity at a glance -

-
+ <> + {showTour && } +
+
+
+

+ Dashboard +

+

+ Your coding activity at a glance +

+
-
- {isPublic === true && session?.githubLogin && ( - + + {settings?.is_public && session?.githubLogin && ( + + Share Profile + + )} + + + + + +
-
- - -
+ + + ); } diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 00000000..fefeb694 --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useEffect, useCallback } from "react"; +import { driver } from "driver.js"; +import "driver.js/dist/driver.css"; + +interface OnboardingTourProps { + onComplete: () => void; +} + +// steps match the actual widget ids on the dashboard +const TOUR_STEPS = [ + { + element: "#widget-contribution-graph", + popover: { + title: "Contribution Graph", + description: "See your daily GitHub commit activity. Switch between 7, 14, 30, and 90 day views.", + }, + }, + { + element: "#widget-streak", + popover: { + title: "Streak Tracker", + description: "Your current commit streak — how many days in a row you've pushed code.", + }, + }, + { + element: "#widget-pr-metrics", + popover: { + title: "PR Analytics", + description: "Average review time, merge rate, and open vs closed pull request counts.", + }, + }, + { + element: "#widget-top-repos", + popover: { + title: "Top Repositories", + description: "Your most active repos ranked by commits. Click column headers to sort.", + }, + }, + { + element: "#widget-goals", + popover: { + title: "Weekly Goals", + description: "Set coding targets and track your progress automatically.", + }, + }, +]; + +export default function OnboardingTour({ onComplete }: OnboardingTourProps) { + const startTour = useCallback(() => { + const driverObj = driver({ + showProgress: true, + animate: true, + allowClose: true, + steps: TOUR_STEPS, + onDestroyStarted: () => { + // mark tour as seen whether user finishes or skips + onComplete(); + driverObj.destroy(); + }, + }); + + driverObj.drive(); + }, [onComplete]); + + + // auto-start on mount + useEffect(() => { + // skip tour in test environments to avoid blocking E2E tests + if (typeof window !== "undefined" && window.navigator.webdriver) return; + const timer = setTimeout(startTour, 800); + return () => clearTimeout(timer); +}, [startTour]); + + return null; +} + diff --git a/src/types/css.d.ts b/src/types/css.d.ts new file mode 100644 index 00000000..2aa223bc --- /dev/null +++ b/src/types/css.d.ts @@ -0,0 +1,5 @@ +declare module '*.css' { + const content: Record; + export default content; +} + diff --git a/supabase/migrations/20260520000000_add_seen_onboarding.sql b/supabase/migrations/20260520000000_add_seen_onboarding.sql new file mode 100644 index 00000000..f5a257a8 --- /dev/null +++ b/supabase/migrations/20260520000000_add_seen_onboarding.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS seen_onboarding BOOLEAN DEFAULT FALSE; +