diff --git a/apps/client/app/(auth)/login.tsx b/apps/client/app/(auth)/login.tsx index 91d56d0..71b5257 100644 --- a/apps/client/app/(auth)/login.tsx +++ b/apps/client/app/(auth)/login.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { View, Text, @@ -7,19 +7,30 @@ import { ScrollView, ActivityIndicator, } from 'react-native'; +import { router } from 'expo-router'; import { useAuth } from '@/contexts/AuthContext'; import { useGoogleAuth } from '@/contexts/GoogleAuthContext'; +import { useDevMode } from '@/contexts/DevModeContext'; +import { DEV_ACCOUNTS } from '@/constants/devAccounts'; import { useResponsive } from '@/hooks/useResponsive'; import { useAppTheme } from '@/hooks/useAppTheme'; import { AppPalette } from '@/constants/theme'; export default function LoginScreen() { - const { isLoading, error } = useAuth(); + const { isLoading, error, isAuthenticated } = useAuth(); const { googleSignIn, isLoading: isGoogleLoading } = useGoogleAuth(); + const { isDevMode, signInAsDevUser } = useDevMode(); const { isMobile } = useResponsive(); const { colors, fontScale } = useAppTheme(); const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + // Covers both Google sign-in and dev-mode test accounts + useEffect(() => { + if (isAuthenticated) { + router.replace('/(tabs)'); + } + }, [isAuthenticated]); + const handleGoogleSignIn = async () => { await googleSignIn(); // On success, onAuthStateChange will set session; tabs layout redirects to /(tabs) @@ -73,7 +84,32 @@ export default function LoginScreen() { Sign up with Google + + {/* Dev mode: CMU SSO becomes optional — sign in as a local test persona */} + {isDevMode && ( + + + + DEV MODE · TEST ACCOUNTS + + + {DEV_ACCOUNTS.map((account) => ( + signInAsDevUser(account.user.id)} + > + {account.user.name} + {account.description} + + ))} + + )} + + router.push('/dev')} style={styles.devLink}> + Developer mode + ); } @@ -187,4 +223,50 @@ const createStyles = (colors: AppPalette, fontScale: number) => color: colors.primary, fontWeight: '600', }, + devSection: { + marginTop: 24, + }, + devDivider: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginBottom: 12, + }, + devDividerLine: { + flex: 1, + height: 1, + backgroundColor: colors.border, + }, + devDividerText: { + fontSize: 10 * fontScale, + fontWeight: '700', + letterSpacing: 1, + color: colors.textTertiary, + }, + devAccountButton: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + marginBottom: 8, + }, + devAccountName: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + devAccountHint: { + fontSize: 12 * fontScale, + color: colors.textSecondary, + marginTop: 1, + }, + devLink: { + marginTop: 20, + padding: 8, + }, + devLinkText: { + fontSize: 12 * fontScale, + color: colors.textTertiary, + }, }); diff --git a/apps/client/app/_layout.tsx b/apps/client/app/_layout.tsx index 1f16774..7b8f38c 100644 --- a/apps/client/app/_layout.tsx +++ b/apps/client/app/_layout.tsx @@ -4,6 +4,7 @@ import { StatusBar } from 'expo-status-bar'; import 'react-native-reanimated'; import { AuthProvider } from '@/contexts/AuthContext'; +import { DevModeProvider } from '@/contexts/DevModeContext'; import { GoogleAuthProvider } from '@/contexts/GoogleAuthContext'; import { GoogleCalendarProvider } from '@/contexts/GoogleCalendarContext'; import { EventsProvider } from '@/contexts/EventsContext'; @@ -25,6 +26,8 @@ function ThemedStack() { + + @@ -34,18 +37,20 @@ function ThemedStack() { export default function RootLayout() { return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); } diff --git a/apps/client/app/dev.tsx b/apps/client/app/dev.tsx new file mode 100644 index 0000000..e468975 --- /dev/null +++ b/apps/client/app/dev.tsx @@ -0,0 +1,683 @@ +import React, { useState } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + TouchableOpacity, + TextInput, + Platform, + Switch, +} from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useDevMode } from '@/contexts/DevModeContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { useEvents } from '@/contexts/EventsContext'; +import { useSettings } from '@/contexts/SettingsContext'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette, FontScales } from '@/constants/theme'; +import { DEV_ACCOUNTS } from '@/constants/devAccounts'; +import { generateDevEvents, DEV_EVENT_PREFIX } from '@/utils/devTools'; +import { isSupabaseConfigured } from '@/lib/supabase'; +import { storage } from '@/lib/storage'; + +/** + * Dev-mode panel: password-gated testing tools. + * + * The gate is a client-side convenience latch (the password ships in the + * bundle) — everything behind it is local-only and can never touch real + * user data. See contexts/DevModeContext.tsx. + */ +export default function DevScreen() { + const { colors, fontScale } = useAppTheme(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const { + isDevMode, + devUser, + enableDevMode, + disableDevMode, + signInAsDevUser, + signOutDevUser, + } = useDevMode(); + const { currentUser } = useAuth(); + const { events, addExternalEvents, removeExternalEvents, refreshEvents } = useEvents(); + const { settings, updateSettings, resetSettings } = useSettings(); + + const [password, setPassword] = useState(''); + const [gateError, setGateError] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + + const flash = (message: string) => { + setStatusMessage(message); + setTimeout(() => setStatusMessage(null), 2500); + }; + + /* ── Locked state: password gate ── */ + if (!isDevMode) { + return ( + + + + + + Developer Mode + + Testing tools for the CMUnify team: test accounts that bypass CMU + sign-in, data seeding, and theme controls. Enter the dev password + to continue. + + { + setPassword(text); + setGateError(false); + }} + placeholder="Dev password" + placeholderTextColor={colors.textTertiary} + secureTextEntry + autoCapitalize="none" + onSubmitEditing={() => { + if (!enableDevMode(password)) setGateError(true); + }} + /> + {gateError && Wrong password.} + { + if (!enableDevMode(password)) setGateError(true); + }} + > + Unlock + + router.back()}> + ← Back + + + + ); + } + + /* ── Unlocked: the panel ── */ + const seededCount = events.filter((e) => e.id.startsWith(DEV_EVENT_PREFIX)).length; + + const clearLocalState = async () => { + // Everything except auth/dev-mode keys, which have their own controls + const keys = [ + 'universify_settings', + 'universify_scheduled_events', + 'universify_google_events', + 'universify_google_events_last_sync', + 'universify_gcal_event_map', + 'universify_fired_reminders', + 'universify_slack_config', + 'universify_slack_events', + 'universify_slack_last_import', + ]; + await Promise.all(keys.map((k) => storage.removeItem(k).catch(() => {}))); + await resetSettings(); + flash('Local app state cleared'); + }; + + const testNotification = () => { + if (Platform.OS !== 'web' || typeof Notification === 'undefined') { + flash('Notifications are web-only'); + return; + } + Notification.requestPermission().then((perm) => { + if (perm === 'granted') { + new Notification('CMUnify test notification', { + body: 'This is what an event reminder looks like.', + }); + flash('Notification fired'); + } else { + flash(`Notification permission: ${perm}`); + } + }); + }; + + return ( + + {/* Header */} + + + Developer Mode + Local testing tools — nothing here touches real data + + { + disableDevMode(); + router.replace('/'); + }} + > + Exit dev mode + + + + {statusMessage && ( + + {statusMessage} + + )} + + {/* Test accounts */} +
+ + Sign in without CMU SSO. Personas are local-only: RSVPs, schedules, + and created events stay on this device. + + {DEV_ACCOUNTS.map((account) => { + const active = devUser?.id === account.user.id; + return ( + { + signInAsDevUser(account.user.id); + flash(`Signed in as ${account.user.name}`); + }} + > + + + {account.user.name.split(' ').map((p) => p[0]).join('')} + + + + {account.user.name} + {account.description} + + {active && } + + ); + })} + + {devUser && ( + <> + router.push('/(tabs)')}> + Open app as {devUser.name.split(' ')[0]} + + { + signOutDevUser(); + flash('Signed out of test account'); + }} + > + Sign out + + + )} + +
+ + {/* Data tools */} +
+ + Seeded events are pinned to the next 7 days (bundled mock data has + fixed dates), tagged dev-seed, and removable in one tap. + + + { + addExternalEvents(generateDevEvents(10)); + flash('Seeded 10 events across this week'); + }} + > + Seed 10 events this week + + { + removeExternalEvents(DEV_EVENT_PREFIX); + flash('Removed seeded events'); + }} + > + Clear seeded ({seededCount}) + + + + { + refreshEvents(); + flash('Events reloaded'); + }} + > + Reload events + + + Reset local app state + + +
+ + {/* Theme playground */} +
+ Theme + + {(['light', 'dark', 'system'] as const).map((theme) => ( + updateSettings({ theme })} + > + + {theme} + + + ))} + + Font size + + {(Object.keys(FontScales) as (keyof typeof FontScales)[]).map((size) => ( + updateSettings({ fontSize: size })} + > + + {size} + + + ))} + + + High contrast + + updateSettings({ + accessibility: { ...settings.accessibility, highContrast: value }, + }) + } + trackColor={{ false: colors.border, true: colors.primary }} + /> + + + Reduce motion + + updateSettings({ + accessibility: { ...settings.accessibility, reduceMotion: value }, + }) + } + trackColor={{ false: colors.border, true: colors.primary }} + /> + +
+ + {/* Shortcuts */} +
+ + router.push('/(tabs)/calendar')}> + Calendar + + router.push('/(tabs)/find')}> + Find + + router.push('/(tabs)/create')}> + Create + + + + router.push('/resources')}> + Freshman guide + + + Test notification + + +
+ + {/* Diagnostics */} +
+ + + + + +
+
+ ); +} + +/* ─── Small pieces ──────────────────────────────────────────────────── */ + +function Section({ + styles, + colors, + title, + icon, + children, +}: { + styles: ReturnType; + colors: AppPalette; + title: string; + icon: keyof typeof Ionicons.glyphMap; + children: React.ReactNode; +}) { + return ( + + + + {title} + + {children} + + ); +} + +function DiagRow({ + styles, + label, + value, +}: { + styles: ReturnType; + label: string; + value: string; +}) { + return ( + + {label} + {value} + + ); +} + +/* ─── Styles ────────────────────────────────────────────────────────── */ + +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + scrollContent: { + padding: 20, + paddingBottom: 48, + maxWidth: 720, + width: '100%', + alignSelf: 'center', + }, + + /* Gate */ + gateContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + backgroundColor: colors.background, + }, + gateCard: { + width: '100%', + maxWidth: 420, + backgroundColor: colors.surface, + borderRadius: 16, + borderWidth: 1, + borderColor: colors.border, + padding: 28, + alignItems: 'center', + }, + gateIcon: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: colors.surfaceAlt, + justifyContent: 'center', + alignItems: 'center', + marginBottom: 16, + }, + gateTitle: { + fontSize: 22 * fontScale, + fontWeight: '800', + letterSpacing: -0.5, + color: colors.textPrimary, + marginBottom: 8, + }, + gateBody: { + fontSize: 14 * fontScale, + lineHeight: 21 * fontScale, + color: colors.textSecondary, + textAlign: 'center', + marginBottom: 20, + }, + gateInput: { + width: '100%', + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 15 * fontScale, + color: colors.textPrimary, + marginBottom: 8, + }, + gateInputError: { + borderColor: colors.danger, + }, + gateErrorText: { + fontSize: 13 * fontScale, + color: colors.danger, + alignSelf: 'flex-start', + marginBottom: 4, + }, + gateButton: { + width: '100%', + backgroundColor: colors.primary, + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + marginTop: 8, + marginBottom: 14, + }, + gateButtonText: { + fontSize: 15 * fontScale, + fontWeight: '600', + color: colors.onPrimary, + }, + gateBack: { + fontSize: 14 * fontScale, + color: colors.textSecondary, + }, + + /* Panel */ + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 16, + gap: 12, + flexWrap: 'wrap', + }, + title: { + fontSize: 26 * fontScale, + fontWeight: '800', + letterSpacing: -0.5, + color: colors.textPrimary, + }, + subtitle: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + marginTop: 2, + }, + exitButton: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 8, + }, + exitButtonText: { + fontSize: 13 * fontScale, + fontWeight: '600', + color: colors.danger, + }, + statusBanner: { + backgroundColor: colors.infoSoft, + borderRadius: 10, + padding: 12, + marginBottom: 12, + }, + statusBannerText: { + fontSize: 14 * fontScale, + color: colors.infoText, + textAlign: 'center', + }, + section: { + backgroundColor: colors.surface, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + padding: 18, + marginBottom: 14, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 12, + }, + sectionTitle: { + fontSize: 16 * fontScale, + fontWeight: '700', + color: colors.textPrimary, + }, + sectionHint: { + fontSize: 13 * fontScale, + lineHeight: 19 * fontScale, + color: colors.textSecondary, + marginBottom: 12, + }, + accountRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + padding: 12, + borderRadius: 10, + borderWidth: 1, + borderColor: colors.border, + marginBottom: 8, + }, + accountRowActive: { + borderColor: colors.primary, + backgroundColor: colors.surfaceAlt, + }, + accountAvatar: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.surfaceAlt, + justifyContent: 'center', + alignItems: 'center', + }, + accountAvatarText: { + fontSize: 13 * fontScale, + fontWeight: '700', + color: colors.primary, + }, + accountInfo: { + flex: 1, + }, + accountName: { + fontSize: 15 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + accountDescription: { + fontSize: 12 * fontScale, + color: colors.textSecondary, + marginTop: 1, + }, + buttonRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginTop: 4, + }, + actionButton: { + backgroundColor: colors.primary, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + }, + actionButtonText: { + fontSize: 13 * fontScale, + fontWeight: '600', + color: colors.onPrimary, + }, + quietButton: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + paddingHorizontal: 14, + paddingVertical: 10, + }, + quietButtonText: { + fontSize: 13 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + }, + rowLabel: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 8, + }, + pillRow: { + flexDirection: 'row', + gap: 8, + marginBottom: 14, + }, + pill: { + paddingHorizontal: 14, + paddingVertical: 7, + borderRadius: 999, + borderWidth: 1, + borderColor: colors.border, + }, + pillActive: { + backgroundColor: colors.primary, + borderColor: colors.primary, + }, + pillText: { + fontSize: 13 * fontScale, + fontWeight: '600', + color: colors.textSecondary, + }, + pillTextActive: { + color: colors.onPrimary, + }, + switchRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 6, + }, + diagRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 6, + borderBottomWidth: 1, + borderBottomColor: colors.border, + gap: 12, + }, + diagLabel: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + }, + diagValue: { + fontSize: 13 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + flexShrink: 1, + textAlign: 'right', + }, + }); diff --git a/apps/client/app/index.tsx b/apps/client/app/index.tsx index dc7a0f9..5948ef1 100644 --- a/apps/client/app/index.tsx +++ b/apps/client/app/index.tsx @@ -67,12 +67,20 @@ export default function LandingPage() { CMUnify - router.push('/(auth)/login')} - > - Sign in - + + router.push('/resources')} + > + Freshman Guide + + router.push('/(auth)/login')} + > + Sign in + +
{/* Hero */} @@ -232,6 +240,9 @@ export default function LandingPage() { {/* Footer */} + router.push('/resources')}> + New to CMU? Read the Freshman Guide → + Made with care at Carnegie Mellon · ScottyLabs Labrador · © 2026 CMUnify @@ -380,6 +391,20 @@ const createStyles = (colors: AppPalette, fontScale: number) => wordmarkAccent: { color: colors.primary, }, + topBarActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + topBarLink: { + paddingHorizontal: 10, + paddingVertical: 8, + }, + topBarLinkText: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.textSecondary, + }, topBarButton: { paddingHorizontal: 16, paddingVertical: 8, @@ -706,6 +731,12 @@ const createStyles = (colors: AppPalette, fontScale: number) => alignItems: 'center', paddingHorizontal: 24, paddingTop: 8, + gap: 10, + }, + footerLink: { + fontSize: 14 * fontScale, + fontWeight: '600', + color: colors.primary, }, footerText: { fontSize: 13 * fontScale, diff --git a/apps/client/app/resources.tsx b/apps/client/app/resources.tsx new file mode 100644 index 0000000..97a6763 --- /dev/null +++ b/apps/client/app/resources.tsx @@ -0,0 +1,494 @@ +import React, { useMemo, useState } from 'react'; +import { + View, + Text, + ScrollView, + StyleSheet, + TouchableOpacity, + TextInput, + Linking, + Platform, +} from 'react-native'; +import { router } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useAppTheme } from '@/hooks/useAppTheme'; +import { AppPalette } from '@/constants/theme'; +import { useResponsive } from '@/hooks/useResponsive'; + +/** + * The Freshman Guide: a one-stop link tree of everything a new CMU student + * needs — accounts, academics, food, health, safety, money, getting around, + * and getting involved. Public page, no sign-in required. + * + * Links point at canonical top-level CMU pages (most stable URLs). If one + * breaks, tell us and we'll fix it. + */ + +interface ResourceLink { + title: string; + description: string; + url: string; +} + +interface ResourceSection { + id: string; + title: string; + icon: keyof typeof Ionicons.glyphMap; + blurb: string; + links: ResourceLink[]; +} + +const SECTIONS: ResourceSection[] = [ + { + id: 'start', + title: 'Start here: your accounts', + icon: 'key-outline', + blurb: 'The logins everything else depends on.', + links: [ + { title: 'Andrew Account & Email', description: 'Your CMU identity — email, WiFi, printing, everything uses it.', url: 'https://www.cmu.edu/computing/services/comm-collab/email-calendar/' }, + { title: 'SIO (Student Information Online)', description: 'Register for classes, view grades, pay bills, update info.', url: 'https://www.cmu.edu/hub/sio/' }, + { title: 'Canvas', description: 'Course materials, assignments, and grades for most classes.', url: 'https://canvas.cmu.edu' }, + { title: 'Stellic', description: 'Degree audit — track requirements and plan future semesters.', url: 'https://cmu.stellic.com' }, + { title: 'Workday', description: 'Campus job paperwork, payroll, and direct deposit.', url: 'https://www.cmu.edu/my-workday-toolkit/' }, + { title: 'Duo Two-Factor (2fa)', description: 'Required for almost every CMU login — set it up first.', url: 'https://www.cmu.edu/computing/services/security/identity-access/authentication/' }, + ], + }, + { + id: 'academics', + title: 'Classes & academics', + icon: 'school-outline', + blurb: 'Registering, planning, and getting help when a course fights back.', + links: [ + { title: 'Schedule of Classes', description: 'Every course offered, with times, rooms, and instructors.', url: 'https://enr-apps.as.cmu.edu/open/SOC/SOCServlet/search' }, + { title: 'Academic Calendar', description: 'Semester dates, add/drop deadlines, breaks, and finals.', url: 'https://www.cmu.edu/hub/calendar/' }, + { title: 'The HUB', description: 'Registration, financial aid, student accounts — the admin front door.', url: 'https://www.cmu.edu/hub/' }, + { title: 'Student Academic Success Center', description: 'Free tutoring, academic coaching, and communication support.', url: 'https://www.cmu.edu/student-success/' }, + { title: 'FCE (Course Evaluations)', description: 'What past students thought of a course before you take it.', url: 'https://www.cmu.edu/hub/fce/' }, + { title: 'University Libraries', description: 'Study spaces, research help, and course reserves at Hunt & Sorrells.', url: 'https://www.library.cmu.edu' }, + { title: 'ScottyLabs CMU Courses', description: 'Student-built course browser with FCE data and prereq maps.', url: 'https://cmucourses.com' }, + { title: 'Undergraduate Catalog', description: 'Official degree requirements and university policies.', url: 'https://www.cmu.edu/academic-catalog/' }, + ], + }, + { + id: 'tech', + title: 'Tech & IT help', + icon: 'laptop-outline', + blurb: 'WiFi, printing, software, and who to call when none of it works.', + links: [ + { title: 'Computing Services', description: 'The IT front door — help desk, guides, and status.', url: 'https://www.cmu.edu/computing/' }, + { title: 'WiFi Setup (CMU-SECURE)', description: 'Get your laptop and phone on the campus network.', url: 'https://www.cmu.edu/computing/services/endpoint/network-access/wireless/' }, + { title: 'Printing (andrew printing)', description: 'Print from anywhere; release at clusters around campus.', url: 'https://www.cmu.edu/computing/services/endpoint/printing/' }, + { title: 'Free & Discounted Software', description: 'MATLAB, Office, Adobe, and more with your Andrew ID.', url: 'https://www.cmu.edu/computing/software/' }, + { title: 'VPN Access', description: 'Reach campus-only resources from off campus.', url: 'https://www.cmu.edu/computing/services/endpoint/network-access/vpn/' }, + { title: 'Computer Labs & Clusters', description: 'Public machines with specialized software.', url: 'https://www.cmu.edu/computing/services/endpoint/computer-labs/' }, + ], + }, + { + id: 'food', + title: 'Food & dining', + icon: 'restaurant-outline', + blurb: 'Where to eat and how meal blocks actually work.', + links: [ + { title: 'Dining Services', description: 'Meal plans, locations, and how blocks + DineXtra work.', url: 'https://www.cmu.edu/dining/' }, + { title: 'Dining Hours & Menus', description: 'What is open right now and what they are serving.', url: 'https://www.cmu.edu/dining/locations/' }, + { title: 'CMUEats', description: 'Student-built live dashboard of what is open at a glance.', url: 'https://cmueats.com' }, + ], + }, + { + id: 'housing', + title: 'Housing & living', + icon: 'home-outline', + blurb: 'Your room, your mail, and the people paid to help you settle in.', + links: [ + { title: 'Housing Services', description: 'Room assignments, fixes, moving, and next-year selection.', url: 'https://www.cmu.edu/housing/' }, + { title: 'Residential Education', description: 'Your RA and Housefellow — community and support in the dorms.', url: 'https://www.cmu.edu/residential-education/' }, + { title: 'Mail Services', description: 'Your SMC mailbox — where packages actually go.', url: 'https://www.cmu.edu/mail-services/' }, + { title: 'FixIt (Maintenance Requests)', description: 'Broken heater? Leaky faucet? File it here.', url: 'https://www.cmu.edu/fmcs/service-requests/' }, + ], + }, + { + id: 'health', + title: 'Health & wellbeing', + icon: 'heart-outline', + blurb: 'Physical health, mental health, and everything that keeps you running.', + links: [ + { title: 'University Health Services (UHS)', description: 'Doctor visits, immunizations, and pharmacy on campus.', url: 'https://www.cmu.edu/health-services/' }, + { title: 'CaPS (Counseling & Psychological Services)', description: 'Free, confidential counseling for students.', url: 'https://www.cmu.edu/counseling/' }, + { title: 'TimelyCare', description: '24/7 virtual medical and mental health care, free for students.', url: 'https://www.cmu.edu/wellbeing/resources/timely-care.html' }, + { title: 'Cohon Fitness & GroupX', description: 'Gym, pool, climbing wall, and free group fitness classes.', url: 'https://athletics.cmu.edu/athletics/fitness/index' }, + { title: 'Student Wellbeing', description: 'Wellness programs, mindfulness room, and self-care resources.', url: 'https://www.cmu.edu/wellbeing/' }, + { title: 'Disability Resources', description: 'Academic accommodations and accessibility support.', url: 'https://www.cmu.edu/disability-resources/' }, + { title: 'CMU Food Pantry', description: 'Free groceries for any student who needs them, no questions.', url: 'https://www.cmu.edu/student-affairs/resources/cmu-pantry/' }, + ], + }, + { + id: 'safety', + title: 'Safety & emergencies', + icon: 'shield-checkmark-outline', + blurb: 'Numbers to save in your phone tonight.', + links: [ + { title: 'CMU Police (412-268-2323)', description: 'Campus emergencies — save this number, 911 works too.', url: 'https://www.cmu.edu/police/' }, + { title: 'Safewalk & Shuttle/Escort', description: 'Free rides and walking escorts around campus at night.', url: 'https://www.cmu.edu/parking/transport/index.html' }, + { title: 'CMU Alert', description: 'Emergency text alerts — make sure your number is registered.', url: 'https://www.cmu.edu/alert/' }, + { title: 'Ethics & Compliance Reporting', description: 'Report concerns anonymously.', url: 'https://www.cmu.edu/hr/resources/ethics-reporting.html' }, + ], + }, + { + id: 'money', + title: 'Money & jobs', + icon: 'wallet-outline', + blurb: 'Bills, aid, and getting paid.', + links: [ + { title: 'Student Financial Services', description: 'Tuition bills, payment plans, and financial aid questions.', url: 'https://www.cmu.edu/sfs/' }, + { title: 'Handshake', description: 'Campus jobs, internships, and new-grad roles.', url: 'https://cmu.joinhandshake.com' }, + { title: 'Career & Professional Development Center', description: 'Resume reviews, mock interviews, and career fairs.', url: 'https://www.cmu.edu/career/' }, + { title: 'Emergency Support Funding', description: 'One-time help when something unexpected hits your wallet.', url: 'https://www.cmu.edu/student-affairs/dean/loans/' }, + ], + }, + { + id: 'transport', + title: 'Getting around', + icon: 'bus-outline', + blurb: 'Your ID is a bus pass. Use it.', + links: [ + { title: 'Free PRT Transit (with CMU ID)', description: 'All Pittsburgh buses and the incline — free with your card.', url: 'https://www.cmu.edu/parking/transport/prt/index.html' }, + { title: 'PRT Trip Planner', description: 'Routes and real-time arrivals for Pittsburgh transit.', url: 'https://www.rideprt.org' }, + { title: 'Campus Shuttles', description: 'CMU shuttle routes and schedules, including grocery runs.', url: 'https://www.cmu.edu/parking/transport/index.html' }, + { title: 'Interactive Campus Map', description: 'Find any building — and the bathrooms inside it.', url: 'https://www.cmu.edu/visit/map-interactive.html' }, + ], + }, + { + id: 'involvement', + title: 'Clubs & getting involved', + icon: 'people-outline', + blurb: 'The part of college you will actually remember.', + links: [ + { title: 'TartanConnect', description: 'Every student org, their events, and how to join.', url: 'https://tartanconnect.cmu.edu' }, + { title: 'SLICE', description: 'Student Leadership, Involvement, and Civic Engagement — org support and the activities fair.', url: 'https://www.cmu.edu/student-affairs/slice/' }, + { title: 'Athletics & Club Sports', description: 'Varsity schedules, intramurals, and club teams.', url: 'https://athletics.cmu.edu' }, + { title: 'Fraternity & Sorority Life', description: 'Greek chapters and recruitment.', url: 'https://www.cmu.edu/student-affairs/slice/fraternity-sorority-life/index.html' }, + { title: 'Center for Student Diversity & Inclusion', description: 'Identity-based communities, programs, and spaces.', url: 'https://www.cmu.edu/student-diversity/' }, + { title: 'The Tartan', description: 'Student newspaper — read it or join it.', url: 'https://thetartan.org' }, + ], + }, + { + id: 'help', + title: 'When you don’t know who to ask', + icon: 'help-buoy-outline', + blurb: 'Stuck, overwhelmed, or facing something weird? These offices exist for exactly that.', + links: [ + { title: 'Dean of Students Office', description: 'The catch-all: personal emergencies, absences, or "I don’t know who handles this."', url: 'https://www.cmu.edu/student-affairs/dean/' }, + { title: 'Your Academic Advisor', description: 'Course planning, requirements, and academic trouble — find yours in SIO.', url: 'https://www.cmu.edu/hub/sio/' }, + { title: 'The Word (Student Handbook)', description: 'Every policy, tradition, and rule in one place.', url: 'https://www.cmu.edu/student-affairs/theword/' }, + { title: 'Office of International Education', description: 'Visas, OPT/CPT, and support for international students.', url: 'https://www.cmu.edu/oie/' }, + { title: 'Graduation & Enrollment Verification', description: 'Enrollment letters for insurance, leases, and visas.', url: 'https://www.cmu.edu/hub/registrar/' }, + { title: 'First-Gen Community', description: 'Programs and mentorship for first-generation college students.', url: 'https://www.cmu.edu/student-success/programs/fgen.html' }, + ], + }, +]; + +export default function ResourcesScreen() { + const { colors, fontScale } = useAppTheme(); + const { isDesktop } = useResponsive(); + const styles = React.useMemo(() => createStyles(colors, fontScale), [colors, fontScale]); + const [query, setQuery] = useState(''); + + const filteredSections = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return SECTIONS; + return SECTIONS.map((section) => ({ + ...section, + links: section.links.filter( + (link) => + link.title.toLowerCase().includes(q) || + link.description.toLowerCase().includes(q) || + section.title.toLowerCase().includes(q) + ), + })).filter((section) => section.links.length > 0); + }, [query]); + + const openLink = (url: string) => { + if (Platform.OS === 'web' && typeof window !== 'undefined') { + window.open(url, '_blank', 'noopener'); + } else { + Linking.openURL(url).catch(() => {}); + } + }; + + return ( + + {/* Top bar */} + + router.back()} style={styles.backButton}> + + + + CMUnify + + + + + {/* Header */} + + + THE FRESHMAN GUIDE + + Everything you need,{'\n'}one page + + Every account, office, and resource a new Tartan needs — so when you + don't know how to do something or who to talk to, you start here. + + + {/* Search */} + + + + {query.length > 0 && ( + setQuery('')}> + + + )} + + + + {/* Sections */} + {filteredSections.length === 0 ? ( + + Nothing matched “{query}” + + Try a broader word — or ask the Dean of Students Office, whose whole + job is questions that don't fit anywhere else. + + + ) : ( + filteredSections.map((section) => ( + + + + + + + {section.title} + {section.blurb} + + + + {section.links.map((link) => ( + openLink(link.url)} + activeOpacity={0.7} + > + + {link.title} + {link.description} + + + + ))} + + + )) + )} + + {/* Footer note */} + + Maintained by students. Spot a broken or missing link? Tell the CMUnify + team and we'll fix it. + + + ); +} + +const createStyles = (colors: AppPalette, fontScale: number) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + scrollContent: { + paddingBottom: 48, + maxWidth: 960, + width: '100%', + alignSelf: 'center', + }, + topBar: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingVertical: 16, + }, + backButton: { + width: 36, + height: 36, + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + }, + wordmark: { + fontSize: 18 * fontScale, + fontWeight: '800', + letterSpacing: -0.5, + color: colors.textPrimary, + }, + wordmarkAccent: { + color: colors.primary, + }, + header: { + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 28, + }, + kickerPill: { + paddingHorizontal: 12, + paddingVertical: 5, + borderRadius: 999, + backgroundColor: colors.surfaceAlt, + borderWidth: 1, + borderColor: colors.border, + marginBottom: 16, + }, + kickerText: { + fontSize: 11 * fontScale, + fontWeight: '700', + letterSpacing: 1.2, + color: colors.primary, + }, + title: { + fontSize: 34 * fontScale, + lineHeight: 39 * fontScale, + fontWeight: '800', + letterSpacing: -1, + textAlign: 'center', + color: colors.textPrimary, + marginBottom: 12, + }, + subtitle: { + fontSize: 15 * fontScale, + lineHeight: 23 * fontScale, + textAlign: 'center', + color: colors.textSecondary, + maxWidth: 560, + marginBottom: 24, + }, + searchWrap: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + width: '100%', + maxWidth: 560, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 10, + }, + searchInput: { + flex: 1, + fontSize: 15 * fontScale, + color: colors.textPrimary, + }, + emptyState: { + alignItems: 'center', + padding: 40, + }, + emptyTitle: { + fontSize: 17 * fontScale, + fontWeight: '700', + color: colors.textPrimary, + marginBottom: 6, + }, + emptyBody: { + fontSize: 14 * fontScale, + lineHeight: 21 * fontScale, + color: colors.textSecondary, + textAlign: 'center', + maxWidth: 420, + }, + section: { + paddingHorizontal: 20, + marginBottom: 28, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 12, + marginBottom: 12, + }, + sectionIconWrap: { + width: 36, + height: 36, + borderRadius: 10, + backgroundColor: colors.surfaceAlt, + justifyContent: 'center', + alignItems: 'center', + }, + sectionHeaderText: { + flex: 1, + }, + sectionTitle: { + fontSize: 19 * fontScale, + fontWeight: '800', + letterSpacing: -0.3, + color: colors.textPrimary, + }, + sectionBlurb: { + fontSize: 13 * fontScale, + color: colors.textSecondary, + marginTop: 1, + }, + linkGrid: { + gap: 8, + }, + linkGridDesktop: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + linkCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + }, + linkCardDesktop: { + flexBasis: '48%', + flexGrow: 1, + }, + linkCardText: { + flex: 1, + }, + linkTitle: { + fontSize: 15 * fontScale, + fontWeight: '600', + color: colors.textPrimary, + marginBottom: 2, + }, + linkDescription: { + fontSize: 13 * fontScale, + lineHeight: 18 * fontScale, + color: colors.textSecondary, + }, + footerNote: { + fontSize: 12 * fontScale, + color: colors.textTertiary, + textAlign: 'center', + paddingHorizontal: 24, + marginTop: 8, + }, + }); diff --git a/apps/client/constants/devAccounts.ts b/apps/client/constants/devAccounts.ts new file mode 100644 index 0000000..a8b5d22 --- /dev/null +++ b/apps/client/constants/devAccounts.ts @@ -0,0 +1,103 @@ +import { User } from '@/types/user'; + +/** + * Dev-mode test accounts. + * + * These personas exist ONLY on the client: their ids carry a "dev-" prefix, + * which the data layer uses to skip every Supabase write (they are not real + * auth.users rows). They exist so the app can be exercised end-to-end — + * tabs, calendar, RSVPs, recommendations — without CMU SSO. + * + * NOTE: dev mode is gated by a password that ships in the client bundle. + * That is a convenience latch for testers, NOT security. Nothing behind it + * may ever grant access to real data. + */ + +const baseSettings: User['settings'] = { + theme: 'system', + defaultHomePage: 'calendar', + calendarViewDays: 7, + colorScheme: 'default', + fontSize: 'medium', + compactView: false, + accessibility: { + highContrast: false, + reduceMotion: false, + }, +}; + +const basePreferences: User['preferences'] = { + categoryInterests: [], + eventTypePreferences: { + clubEvents: true, + socialEvents: true, + }, + defaultRSVPVisibility: 'public', + notificationPreferences: { + email: false, + push: false, + eventReminders: true, + newEventsInCategories: false, + }, + publicProfile: false, +}; + +function makeAccount(overrides: Partial & { id: string; name: string }): User { + return { + email: `${overrides.id}@example.invalid`, + university: 'Carnegie Mellon University', + preferences: basePreferences, + settings: baseSettings, + savedEvents: [], + createdEvents: [], + createdAt: '2026-01-01T00:00:00.000Z', + lastLogin: new Date().toISOString(), + ...overrides, + }; +} + +export interface DevAccount { + user: User; + /** Short blurb shown in the account picker */ + description: string; +} + +export const DEV_ACCOUNTS: DevAccount[] = [ + { + user: makeAccount({ + id: 'dev-freshman', + name: 'Fiona Freshman', + email: 'dev-freshman@example.invalid', + }), + description: 'Brand-new user — no interests, no history. Cold-start experience.', + }, + { + user: makeAccount({ + id: 'dev-power-user', + name: 'Petra Poweruser', + email: 'dev-power-user@example.invalid', + preferences: { + ...basePreferences, + categoryInterests: ['Tech', 'Social', 'Food'], + }, + }), + description: 'Explicit category interests set — exercises the ranked Home feed.', + }, + { + user: makeAccount({ + id: 'dev-organizer', + name: 'Oscar Organizer', + email: 'dev-organizer@example.invalid', + preferences: { + ...basePreferences, + categoryInterests: ['Career', 'Academic'], + }, + }), + description: 'Club-officer persona — use Create to add events, then find them under My Events.', + }, +]; + +/** True when a user id belongs to a local dev persona (never hits Supabase). */ +export function isDevUserId(id: string | undefined | null): boolean { + return typeof id === 'string' && id.startsWith('dev-'); +} diff --git a/apps/client/contexts/DevModeContext.tsx b/apps/client/contexts/DevModeContext.tsx new file mode 100644 index 0000000..4b0cdd7 --- /dev/null +++ b/apps/client/contexts/DevModeContext.tsx @@ -0,0 +1,113 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useCallback, + ReactNode, +} from 'react'; +import { User } from '@/types/user'; +import { DEV_ACCOUNTS } from '@/constants/devAccounts'; +import { storage } from '@/lib/storage'; + +/** + * Dev mode: a password-gated set of testing tools. + * + * The password ships in the client bundle, so this is a convenience latch — + * it keeps casual visitors out of test features, nothing more. Everything + * dev mode unlocks is client-local: mock sign-in personas, theme toggles, + * local data seeding/reset. It can never touch real user data. + */ + +const DEV_MODE_PASSWORD = 'dev50'; +const DEV_MODE_KEY = 'universify_dev_mode'; +const DEV_USER_KEY = 'universify_dev_user'; + +interface DevModeContextType { + /** True once the password has been entered on this device */ + isDevMode: boolean; + /** The currently signed-in test persona, if any */ + devUser: User | null; + /** True until stored dev state has been loaded */ + isHydrating: boolean; + enableDevMode: (password: string) => boolean; + disableDevMode: () => void; + signInAsDevUser: (accountId: string) => void; + signOutDevUser: () => void; +} + +const DevModeContext = createContext(undefined); + +export const DevModeProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [isDevMode, setIsDevMode] = useState(false); + const [devUser, setDevUser] = useState(null); + const [isHydrating, setIsHydrating] = useState(true); + + useEffect(() => { + (async () => { + try { + const [mode, userId] = await Promise.all([ + storage.getItem(DEV_MODE_KEY), + storage.getItem(DEV_USER_KEY), + ]); + if (mode === '1') { + setIsDevMode(true); + if (userId) { + const account = DEV_ACCOUNTS.find((a) => a.user.id === userId); + if (account) setDevUser(account.user); + } + } + } catch { + // Fresh state on storage failure + } finally { + setIsHydrating(false); + } + })(); + }, []); + + const enableDevMode = useCallback((password: string): boolean => { + if (password !== DEV_MODE_PASSWORD) return false; + setIsDevMode(true); + storage.setItem(DEV_MODE_KEY, '1').catch(() => {}); + return true; + }, []); + + const disableDevMode = useCallback(() => { + setIsDevMode(false); + setDevUser(null); + storage.removeItem(DEV_MODE_KEY).catch(() => {}); + storage.removeItem(DEV_USER_KEY).catch(() => {}); + }, []); + + const signInAsDevUser = useCallback((accountId: string) => { + const account = DEV_ACCOUNTS.find((a) => a.user.id === accountId); + if (!account) return; + setDevUser(account.user); + storage.setItem(DEV_USER_KEY, accountId).catch(() => {}); + }, []); + + const signOutDevUser = useCallback(() => { + setDevUser(null); + storage.removeItem(DEV_USER_KEY).catch(() => {}); + }, []); + + const value: DevModeContextType = { + isDevMode, + devUser, + isHydrating, + enableDevMode, + disableDevMode, + signInAsDevUser, + signOutDevUser, + }; + + return {children}; +}; + +export const useDevMode = (): DevModeContextType => { + const context = useContext(DevModeContext); + if (context === undefined) { + throw new Error('useDevMode must be used within a DevModeProvider'); + } + return context; +}; diff --git a/apps/client/contexts/EventsContext.tsx b/apps/client/contexts/EventsContext.tsx index 5b3ede5..05e83f6 100644 --- a/apps/client/contexts/EventsContext.tsx +++ b/apps/client/contexts/EventsContext.tsx @@ -11,6 +11,7 @@ import { } from '@/lib/api'; import { useAuth } from '@/contexts/AuthContext'; import { dedupeAgainst } from '@/utils/dedupe'; +import { isDevUserId } from '@/constants/devAccounts'; import allEventsData from '@/data/allEvents.json'; interface EventsContextType { @@ -62,6 +63,39 @@ export const EventsProvider: React.FC<{ children: ReactNode }> = ({ children }) const createEvent = async (eventData: EventFormData, userId: string): Promise => { const organizerName = 'Current User'; + + // Dev-mode personas create events in local state only (they are not + // real auth.users rows, so a Supabase insert would be rejected anyway) + if (isDevUserId(userId)) { + const now = new Date().toISOString(); + const localEvent: Event = { + id: `dev-evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + title: eventData.title, + description: eventData.description, + startTime: new Date(`${eventData.startDate}T${eventData.startTime}:00`).toISOString(), + endTime: new Date(`${eventData.endDate}T${eventData.endTime}:00`).toISOString(), + location: eventData.location, + categories: eventData.categories, + organizer: { id: userId, name: organizerName, type: eventData.isClubEvent ? 'club' : 'individual' }, + color: eventData.color, + rsvpEnabled: eventData.rsvpEnabled, + rsvpCounts: { going: 0, maybe: 0, notGoing: 0 }, + attendees: [], + attendeeVisibility: eventData.attendeeVisibility, + isClubEvent: eventData.isClubEvent, + isSocialEvent: eventData.isSocialEvent, + capacity: eventData.capacity, + recurring: eventData.recurring, + tags: eventData.tags, + createdAt: now, + updatedAt: now, + imageUrl: eventData.imageUrl, + }; + setEvents((prev) => [...prev, localEvent]); + addCreatedEvent(localEvent.id); + return localEvent; + } + const newEvent = await createEventAPI(eventData, userId, organizerName); setEvents((prev) => [...prev, newEvent]); addCreatedEvent(newEvent.id); @@ -119,6 +153,9 @@ export const EventsProvider: React.FC<{ children: ReactNode }> = ({ children }) ) ); + // Dev-mode personas keep RSVPs in local state only + if (isDevUserId(userId)) return; + try { // Write the user's own RSVP row; a DB trigger recomputes the aggregates await setRSVPAPI(eventId, userId, status); diff --git a/apps/client/hooks/useAuth.ts b/apps/client/hooks/useAuth.ts index 90952ec..7b8fa29 100644 --- a/apps/client/hooks/useAuth.ts +++ b/apps/client/hooks/useAuth.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { User, AuthCredentials, SignupData } from '@/types/user'; import { EventCategory } from '@/types/event'; import { useGoogleAuth } from '@/contexts/GoogleAuthContext'; +import { useDevMode } from '@/contexts/DevModeContext'; import { fetchUserProfile, upsertUserProfile, updateUserProfile, updateUserProfilePreferences } from '@/lib/userProfilesApi'; import { fetchCreatedEventIds } from '@/lib/api'; @@ -76,6 +77,11 @@ export const useAuth = () => { error: googleError, clearError: clearGoogleError, } = useGoogleAuth(); + // Dev mode can substitute a local test persona for real CMU SSO. Dev + // users never reach Supabase (their ids carry a "dev-" prefix that the + // data layer checks before every write). + const { devUser, signOutDevUser, isHydrating: isDevHydrating } = useDevMode(); + const [devUserOverrides, setDevUserOverrides] = useState>({}); useEffect(() => { if (!isGoogleAuthenticated || !googleSession?.user) { @@ -132,19 +138,38 @@ export const useAuth = () => { }, [isGoogleAuthenticated, googleSession]); const logout = useCallback(async () => { + if (devUser) { + signOutDevUser(); + setDevUserOverrides({}); + return; + } await googleSignOut(); setCurrentUser(null); - }, [googleSignOut]); + }, [googleSignOut, devUser, signOutDevUser]); const addCreatedEvent = useCallback((eventId: string) => { + if (devUser) { + setDevUserOverrides((prev) => { + const created = prev.createdEvents ?? devUser.createdEvents; + return created.includes(eventId) + ? prev + : { ...prev, createdEvents: [...created, eventId] }; + }); + return; + } setCurrentUser((prev) => prev && !prev.createdEvents.includes(eventId) ? { ...prev, createdEvents: [...prev.createdEvents, eventId] } : prev ); - }, []); + }, [devUser]); const updateUser = useCallback(async (updates: Partial) => { + if (devUser) { + // Dev personas live in memory only — never write to Supabase + setDevUserOverrides((prev) => ({ ...prev, ...updates })); + return; + } if (!currentUser) return; setCurrentUser((prev) => (prev ? { ...prev, ...updates } : null)); try { @@ -164,12 +189,14 @@ export const useAuth = () => { } catch (err) { console.error('Failed to update user profile:', err); } - }, [currentUser]); + }, [currentUser, devUser]); + + const effectiveDevUser: User | null = devUser ? { ...devUser, ...devUserOverrides } : null; return { - currentUser, - isAuthenticated: isGoogleAuthenticated, - isLoading: isGoogleLoading, + currentUser: effectiveDevUser ?? currentUser, + isAuthenticated: Boolean(effectiveDevUser) || isGoogleAuthenticated, + isLoading: isGoogleLoading || isDevHydrating, error: googleError, login: async (_credentials?: AuthCredentials) => { await googleSignIn(); diff --git a/apps/client/hooks/useScheduledEvents.ts b/apps/client/hooks/useScheduledEvents.ts index 8d95bab..5202326 100644 --- a/apps/client/hooks/useScheduledEvents.ts +++ b/apps/client/hooks/useScheduledEvents.ts @@ -12,8 +12,12 @@ import { unscheduleEventInSupabase, getAllScheduledEventIdsFromSupabase, } from '@/lib/scheduledEventsApi'; +import { isDevUserId } from '@/constants/devAccounts'; -export function useScheduledEvents(userId: string | undefined, weekKey: string) { +export function useScheduledEvents(rawUserId: string | undefined, weekKey: string) { + // Dev-mode personas are not real auth.users rows — route them through the + // local-storage path exactly like an unauthenticated visitor. + const userId = isDevUserId(rawUserId) ? undefined : rawUserId; const [scheduledEventIds, setScheduledEventIds] = useState([]); const [allScheduledIds, setAllScheduledIds] = useState([]); const [isLoading, setIsLoading] = useState(true); diff --git a/apps/client/utils/devTools.ts b/apps/client/utils/devTools.ts new file mode 100644 index 0000000..116eb35 --- /dev/null +++ b/apps/client/utils/devTools.ts @@ -0,0 +1,72 @@ +import { Event, EventCategory } from '@/types/event'; + +/** + * Dev-mode helpers: generate realistic test events pinned to the CURRENT + * week (the bundled mock data has fixed dates that drift into the past), + * so the calendar and feed always have something fresh to show. + */ + +const SAMPLE_TITLES: { title: string; categories: EventCategory[]; location: string }[] = [ + { title: 'Poker Night @ Wiegand', categories: ['Social', 'Fun'], location: 'Wiegand Gym Lounge' }, + { title: 'Intro to Systems Study Session', categories: ['Academic', 'Tech'], location: 'Gates 4401' }, + { title: 'ScottyLabs Demo Day', categories: ['Tech', 'Networking'], location: 'Rangos Ballroom' }, + { title: 'Late Night Pancakes', categories: ['Food', 'Social'], location: 'Cohon Center Kitchen' }, + { title: 'Climbing Wall Open Hours', categories: ['Sports', 'Wellness'], location: 'Cohon Fitness Center' }, + { title: 'Resume Review Drop-in', categories: ['Career'], location: 'CPDC Office' }, + { title: 'A Cappella Showcase', categories: ['Arts'], location: 'McConomy Auditorium' }, + { title: 'Board Game Cafe', categories: ['Fun', 'Social'], location: 'Danforth Lounge' }, + { title: 'Startup Pitch Practice', categories: ['Career', 'Networking'], location: 'Swartz Center' }, + { title: 'Trivia Night', categories: ['Fun', 'Social'], location: 'Schatz Dining' }, +]; + +const COLORS = ['#E11D48', '#8B5CF6', '#0EA5E9', '#059669', '#D97706', '#DB2777']; + +export const DEV_EVENT_PREFIX = 'dev-seed-'; + +/** + * Generate `count` events spread over the next 7 days at plausible times. + * Ids carry DEV_EVENT_PREFIX so they can be cleared with one tap. + */ +export function generateDevEvents(count: number): Event[] { + const events: Event[] = []; + const now = new Date(); + + for (let i = 0; i < count; i++) { + const sample = SAMPLE_TITLES[i % SAMPLE_TITLES.length]; + const dayOffset = i % 7; + const startHour = 10 + ((i * 3) % 11); // 10:00 .. 20:00 + + const start = new Date(now); + start.setDate(now.getDate() + dayOffset); + start.setHours(startHour, 0, 0, 0); + const end = new Date(start); + end.setHours(startHour + 1, 30, 0, 0); + + const going = ((i * 17) % 80) + 5; + const iso = new Date().toISOString(); + + events.push({ + id: `${DEV_EVENT_PREFIX}${i}-${start.toISOString().slice(0, 10)}`, + title: sample.title, + description: `Test event seeded by dev mode. ${sample.title} — come hang out!`, + startTime: start.toISOString(), + endTime: end.toISOString(), + location: sample.location, + categories: sample.categories, + organizer: { id: 'dev-organizer', name: 'Dev Mode', type: 'club' }, + color: COLORS[i % COLORS.length], + rsvpEnabled: true, + rsvpCounts: { going, maybe: (i * 5) % 20, notGoing: (i * 3) % 10 }, + attendees: [], + attendeeVisibility: 'public', + isClubEvent: i % 2 === 0, + isSocialEvent: i % 2 === 1, + capacity: i % 3 === 0 ? going + 20 : undefined, + tags: ['dev-seed'], + createdAt: iso, + updatedAt: iso, + }); + } + + return events; +}