diff --git a/scripts/generate-unclaimed-colleges-csv.ts b/scripts/generate-unclaimed-colleges-csv.ts new file mode 100644 index 0000000..71d13ea --- /dev/null +++ b/scripts/generate-unclaimed-colleges-csv.ts @@ -0,0 +1,189 @@ +#!/usr/bin/env tsx + +/** + * Unclaimed Colleges CSV Generator Script + * + * This script generates a CSV file containing all unclaimed college/university profiles + * (schools with no associated coaches) along with their claim links. + * + * Usage: + * npx tsx scripts/generate-unclaimed-colleges-csv.ts [--base-url=https://evalgaming.com] [--output=unclaimed-colleges.csv] + * + * Options: + * --base-url Base URL for generating profile and claim links (default: https://evalgaming.com) + * --output Output file path (default: unclaimed-colleges-{date}.csv) + */ + +// Load environment variables from .env file +import dotenv from "dotenv"; +dotenv.config(); + +import { writeFileSync } from "fs"; +import { db } from "../src/server/db"; + +// Colors for console output +const colors = { + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + reset: "\x1b[0m", + cyan: "\x1b[36m", + magenta: "\x1b[35m", +}; + +const log = { + info: (msg: string) => console.log(`${colors.blue}ℹ️ ${msg}${colors.reset}`), + success: (msg: string) => + console.log(`${colors.green}✅ ${msg}${colors.reset}`), + warning: (msg: string) => + console.log(`${colors.yellow}⚠️ ${msg}${colors.reset}`), + error: (msg: string) => console.log(`${colors.red}❌ ${msg}${colors.reset}`), + step: (msg: string) => console.log(`${colors.cyan}🔄 ${msg}${colors.reset}`), + data: (msg: string) => + console.log(`${colors.magenta}📄 ${msg}${colors.reset}`), +}; + +// Parse command line arguments +function parseArgs(): { baseUrl: string; output: string } { + const args = process.argv.slice(2); + let baseUrl = "https://evalgaming.com"; + let output = `unclaimed-colleges-${new Date().toISOString().split("T")[0]}.csv`; + + for (const arg of args) { + if (arg.startsWith("--base-url=")) { + baseUrl = arg.split("=")[1] ?? baseUrl; + } else if (arg.startsWith("--output=")) { + output = arg.split("=")[1] ?? output; + } + } + + return { baseUrl, output }; +} + +// Escape CSV field values +function escapeCSVField(value: string | null | undefined): string { + if (!value) return ""; + // If the value contains comma, newline, or quote, wrap in quotes and escape internal quotes + if (value.includes(",") || value.includes("\n") || value.includes('"')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +async function main() { + const { baseUrl, output } = parseArgs(); + + log.info("Unclaimed Colleges CSV Generator"); + log.info("================================"); + log.data(`Base URL: ${baseUrl}`); + log.data(`Output file: ${output}`); + console.log(""); + + try { + log.step("Connecting to database..."); + + // Fetch all unclaimed colleges/universities + log.step("Fetching unclaimed college profiles..."); + const schools = await db.school.findMany({ + where: { + type: { + in: ["COLLEGE", "UNIVERSITY"], + }, + coaches: { + none: {}, + }, + }, + select: { + id: true, + name: true, + type: true, + location: true, + state: true, + region: true, + country: true, + website: true, + }, + orderBy: [{ state: "asc" }, { name: "asc" }], + }); + + if (schools.length === 0) { + log.warning("No unclaimed colleges found!"); + await db.$disconnect(); + return; + } + + log.success(`Found ${schools.length} unclaimed college profiles`); + + // Generate CSV content + log.step("Generating CSV content..."); + + const headers = [ + "School Name", + "School Type", + "Location", + "State", + "Region", + "Country", + "Website", + "Profile URL", + "Claim Link", + ]; + + const rows = schools.map((school) => { + const profileUrl = `${baseUrl}/profiles/school/${school.id}`; + const claimLink = `${baseUrl}/onboarding/coach?schoolId=${school.id}&schoolName=${encodeURIComponent(school.name)}`; + + return [ + escapeCSVField(school.name), + school.type, + escapeCSVField(school.location), + escapeCSVField(school.state), + escapeCSVField(school.region), + escapeCSVField(school.country ?? "USA"), + escapeCSVField(school.website), + profileUrl, + claimLink, + ].join(","); + }); + + const csvContent = [headers.join(","), ...rows].join("\n"); + + // Write CSV file + log.step(`Writing CSV file to ${output}...`); + writeFileSync(output, csvContent, "utf-8"); + + log.success(`CSV file generated successfully!`); + console.log(""); + log.info("Summary:"); + log.data(` - Total unclaimed colleges: ${schools.length}`); + log.data(` - Output file: ${output}`); + log.data(` - File size: ${(csvContent.length / 1024).toFixed(2)} KB`); + + // Print some sample data + console.log(""); + log.info("Sample entries (first 5):"); + schools.slice(0, 5).forEach((school, i) => { + log.data( + ` ${i + 1}. ${school.name} (${school.state || "Unknown State"})`, + ); + }); + + // Cleanup + await db.$disconnect(); + + log.success("Script completed successfully!"); + } catch (error) { + log.error(`Error generating CSV: ${error instanceof Error ? error.message : "Unknown error"}`); + console.error(error); + await db.$disconnect(); + process.exit(1); + } +} + +// Run the script +main().catch((error) => { + log.error(`Unhandled error: ${error instanceof Error ? error.message : "Unknown error"}`); + console.error(error); + process.exit(1); +}); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e4b5933..5fc91cc 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Card, CardContent, @@ -23,8 +24,12 @@ import { ClipboardList, Crown, AlertCircle, + Download, + School, + Loader2, } from "lucide-react"; import { api } from "@/trpc/react"; +import { toast } from "sonner"; const adminTools = [ { @@ -80,6 +85,8 @@ const adminTools = [ ]; export default function AdminDashboard() { + const [isDownloadingCSV, setIsDownloadingCSV] = useState(false); + // Fetch pending counts const { data: pendingSchoolRequests } = api.schoolAssociationRequests.getPendingCount.useQuery(); @@ -88,11 +95,91 @@ export default function AdminDashboard() { const { data: pendingLeagueSchoolCreationRequests } = api.leagueSchoolCreationRequests.getPendingCount.useQuery(); + // Fetch unclaimed colleges count + const { data: unclaimedCollegesData } = + api.adminDirectory.getUnclaimedColleges.useQuery({ + limit: 1, + offset: 0, + }); + + // CSV generation query (only fetch when download is triggered) + const csvQuery = api.adminDirectory.getUnclaimedCollegesCSV.useQuery( + { + baseUrl: + typeof window !== "undefined" ? window.location.origin : "https://evalgaming.com", + }, + { + enabled: false, // Only fetch manually + }, + ); + const totalPending = (pendingSchoolRequests ?? 0) + (pendingLeagueRequests ?? 0) + (pendingLeagueSchoolCreationRequests ?? 0); + const handleDownloadCSV = async () => { + setIsDownloadingCSV(true); + try { + const result = await csvQuery.refetch(); + + if (!result.data?.data || result.data.data.length === 0) { + toast.error("No unclaimed colleges found"); + return; + } + + // Generate CSV content + const headers = [ + "School Name", + "School Type", + "Location", + "State", + "Region", + "Country", + "Website", + "Profile URL", + "Claim Link", + ]; + + const rows = result.data.data.map((school) => [ + `"${school.schoolName.replace(/"/g, '""')}"`, + school.schoolType, + `"${school.location.replace(/"/g, '""')}"`, + school.state, + school.region, + school.country, + school.website, + school.profileUrl, + school.claimLink, + ]); + + const csvContent = [headers.join(","), ...rows.map((row) => row.join(","))].join( + "\n", + ); + + // Create and download the file + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.setAttribute( + "download", + `unclaimed-colleges-${new Date().toISOString().split("T")[0]}.csv`, + ); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success(`Downloaded ${result.data.data.length} unclaimed college profiles`); + } catch (error) { + console.error("Error downloading CSV:", error); + toast.error("Failed to download CSV"); + } finally { + setIsDownloadingCSV(false); + } + }; + return (
@@ -236,6 +323,57 @@ export default function AdminDashboard() { + {/* Unclaimed Colleges Section */} + + +
+
+ + + Coach Recruitment Tools + +
+ {unclaimedCollegesData && ( + + {unclaimedCollegesData.total} Unclaimed + + )} +
+ + Download CSV of unclaimed college profiles with claim links for coach outreach + +
+ +
+

+ Generate a CSV file containing all college and university profiles without associated coaches. + Each row includes the school name, location, profile URL, and a unique claim link that coaches + can use to sign up and request association with their school. +

+
+ +
+
+
diff --git a/src/app/onboarding/coach/layout.tsx b/src/app/onboarding/coach/layout.tsx new file mode 100644 index 0000000..cf02090 --- /dev/null +++ b/src/app/onboarding/coach/layout.tsx @@ -0,0 +1,29 @@ +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Claim School Profile - EVAL Gaming", + description: + "Claim your school's esports profile on EVAL Gaming. Sign up as a coach to manage tryouts, recruit players, and build your competitive gaming program.", + keywords: [ + "claim school", + "esports coach", + "college esports", + "school profile", + "coach registration", + "EVAL Gaming", + ], + openGraph: { + title: "Claim School Profile - EVAL Gaming", + description: + "Claim your school's esports profile on EVAL Gaming. Sign up as a coach to manage tryouts, recruit players, and build your competitive gaming program.", + type: "website", + }, +}; + +export default function CoachOnboardingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/onboarding/coach/page.tsx b/src/app/onboarding/coach/page.tsx new file mode 100644 index 0000000..8150c6d --- /dev/null +++ b/src/app/onboarding/coach/page.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { useUser, SignUp } from "@clerk/nextjs"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState, Suspense } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { HandshakeIcon, Loader2, Users, Search, MessageSquare } from "lucide-react"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; + +function CoachOnboardingContent() { + const { user, isLoaded, isSignedIn } = useUser(); + const router = useRouter(); + const searchParams = useSearchParams(); + + const schoolId = searchParams.get("schoolId"); + const schoolName = searchParams.get("schoolName"); + + const [claimMessage, setClaimMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showClaimForm, setShowClaimForm] = useState(false); + + const userType = user?.unsafeMetadata?.userType as string | undefined; + const isCoach = userType === "coach" || userType === "school"; + + // School association request mutation + const submitAssociationRequest = api.coachProfile.submitSchoolAssociationRequest.useMutation({ + onSuccess: () => { + toast.success( + "School association request submitted! An admin will review your request.", + ); + // Redirect to coach dashboard + router.push("/dashboard/coaches"); + }, + onError: (error) => { + toast.error(error.message || "Failed to submit request"); + setIsSubmitting(false); + }, + }); + + useEffect(() => { + if (!isLoaded) return; + + // If no schoolId provided, redirect to sign-up + if (!schoolId || !schoolName) { + router.push("/sign-up/schools"); + return; + } + + // If signed in as coach, show the claim form + if (isSignedIn && isCoach) { + setShowClaimForm(true); + } + // If signed in but not as coach, redirect to dashboard to select type + else if (isSignedIn && userType && !isCoach) { + toast.error("You must be registered as a coach to claim a school profile."); + router.push("/dashboard"); + } + }, [isLoaded, isSignedIn, isCoach, userType, schoolId, schoolName, router]); + + const handleSubmitClaim = () => { + if (!schoolId) return; + setIsSubmitting(true); + submitAssociationRequest.mutate({ + school_id: schoolId, + request_message: claimMessage || undefined, + }); + }; + + // Loading state + if (!isLoaded) { + return ( +
+
+ + Loading... +
+
+ ); + } + + // If signed in as coach, show the claim confirmation form + if (showClaimForm && schoolId && schoolName) { + return ( +
+ {/* Background Effects */} +
+
+ + {/* Animated Background Elements */} +
+
+ +
+ + +
+ +
+ + Claim School Profile + + + Submit a request to be associated with{" "} + {decodeURIComponent(schoolName)} + +
+ +
+

+ An admin will review your request and verify your association with this school. + Once approved, you'll have full access to manage the school's profile, tryouts, and announcements. +

+
+ +
+ +