diff --git a/apps/dashboard/components/finalist-dashboard/EventInfoCard.tsx b/apps/dashboard/components/finalist-dashboard/EventInfoCard.tsx new file mode 100644 index 0000000..f97d67c --- /dev/null +++ b/apps/dashboard/components/finalist-dashboard/EventInfoCard.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import styled from 'styled-components'; +import { neoColors } from '../neo-ui/theme'; +import { HeroBanner, HeroMeta, MetaItem, MetaLabel, MetaValue } from './common'; + +export interface EventInfoCardProps { + date: string; + checkIn: string; + kickoff: string; +} + +export function EventInfoCard(props: EventInfoCardProps) { + const hackerPacketUrl = process.env.NEXT_PUBLIC_HACKER_PACKET_URL; + + return ( + + + + Date + {props.date} + + + Check-in + {props.checkIn} + + + Kickoff + {props.kickoff} + + + Hacker handbook + + {hackerPacketUrl ? ( + + Open + + ) : ( + Coming soon + )} + + + + + ); +} + +const VenueLine = styled.span` + color: ${neoColors.textMuted}; +`; + +const HandbookLink = styled.a` + color: ${neoColors.accent.blue}; + font-weight: 900; + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 3px; + + &:hover { + opacity: 0.85; + } +`; + +export default EventInfoCard; diff --git a/apps/dashboard/components/finalist-dashboard/MonkeytypeCard.tsx b/apps/dashboard/components/finalist-dashboard/MonkeytypeCard.tsx new file mode 100644 index 0000000..e9bdb8e --- /dev/null +++ b/apps/dashboard/components/finalist-dashboard/MonkeytypeCard.tsx @@ -0,0 +1,744 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { FaArrowRotateRight, FaChevronDown } from 'react-icons/fa6'; +import styled from 'styled-components'; +import { neoBorders, neoColors, neoShadows } from '../neo-ui/theme'; +import { + ButtonRow, + Card, + OtpInline, + OtpInlineLabel, + OtpInlineValue, +} from './common'; +import { MONKEYTYPE_THEMES, MonkeytypeTheme } from './monkeytype-themes'; + +function clamp01(n: number) { + return Math.min(1, Math.max(0, n)); +} + +function hexToRgb01(hex: string): { r: number; g: number; b: number } | null { + const normalized = hex.trim(); + if (!normalized.startsWith('#')) return null; + const raw = normalized.slice(1); + const expanded = + raw.length === 3 + ? raw + .split('') + .map((c) => c + c) + .join('') + : raw; + if (expanded.length !== 6) return null; + const r = Number.parseInt(expanded.slice(0, 2), 16); + const g = Number.parseInt(expanded.slice(2, 4), 16); + const b = Number.parseInt(expanded.slice(4, 6), 16); + if ([r, g, b].some((n) => Number.isNaN(n))) return null; + return { r: r / 255, g: g / 255, b: b / 255 }; +} + +function rgb01ToHsl(rgb: { r: number; g: number; b: number }) { + const { r, g, b } = rgb; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (d !== 0) { + s = d / (1 - Math.abs(2 * l - 1)); + switch (max) { + case r: + h = ((g - b) / d) % 6; + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + break; + } + h *= 60; + if (h < 0) h += 360; + } + + return { h, s, l }; +} + +function hexToRgba(hex: string, alpha: number) { + const rgb = hexToRgb01(hex); + if (!rgb) return hex; + const r = Math.round(rgb.r * 255); + const g = Math.round(rgb.g * 255); + const b = Math.round(rgb.b * 255); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** + * Creates a "muted surface" from the theme bg by desaturating and nudging lightness. + * This helps keep OTP + Theme controls consistent across very light and very dark themes. + */ +function mutedSurfaceFromBg(bgHex: string) { + const rgb = hexToRgb01(bgHex); + if (!rgb) return bgHex; + const { h, s, l } = rgb01ToHsl(rgb); + + // Desaturate heavily (towards neutral UI surfaces). + const nextS = clamp01(s * 0.18); + // Lift lightness towards a readable "panel" surface (but don't blow out whites). + const nextL = clamp01(l + (1 - l) * 0.35); + + const sPct = Math.round(nextS * 100); + const lPct = Math.round(nextL * 100); + return `hsl(${Math.round(h)}, ${sPct}%, ${lPct}%)`; +} + +export interface MonkeytypeCardProps { + themes?: MonkeytypeTheme[]; +} + +export function MonkeytypeCard(props: MonkeytypeCardProps) { + const themes = useMemo( + () => props.themes ?? MONKEYTYPE_THEMES, + [props.themes] + ); + const themeById = useMemo( + () => new Map(themes.map((t) => [t.id, t])), + [themes] + ); + + const [otp, setOtp] = useState('—'); + const [settings, setSettings] = useState>({}); + const [selectedThemeId, setSelectedThemeId] = useState( + props.themes?.[0]?.id ?? MONKEYTYPE_THEMES[0].id + ); + const [resetting, setResetting] = useState(false); + const [themeMenuOpen, setThemeMenuOpen] = useState(false); + const [themeQuery, setThemeQuery] = useState(''); + + const selectedTheme = useMemo(() => { + const fromList = themeById.get(selectedThemeId); + return fromList ?? themes[0]; + }, [selectedThemeId, themeById, themes]); + + const filteredThemes = useMemo(() => { + const q = themeQuery.trim().toLowerCase(); + if (!q) return themes; + return themes.filter((t) => `${t.name} ${t.id}`.toLowerCase().includes(q)); + }, [themeQuery, themes]); + + useEffect(() => { + const run = async () => { + try { + const res = await fetch('/api/monkeytype-duel/settings', { + method: 'GET', + }); + if (!res.ok) return; + const body = (await res.json()) as + | { message: string } + | { settings: unknown; otp?: string | null }; + + if (!('settings' in body)) return; + const nextSettings = + body.settings && + typeof body.settings === 'object' && + !Array.isArray(body.settings) + ? (body.settings as Record) + : {}; + setSettings(nextSettings); + + const themeId = nextSettings.themeId; + if (typeof themeId === 'string' && themeId.length > 0) { + setSelectedThemeId(themeById.has(themeId) ? themeId : themes[0].id); + } + + const nextOtp = body.otp; + if (typeof nextOtp === 'string' && nextOtp.length > 0) { + setOtp(nextOtp); + } + } catch { + // ignore + } + }; + run(); + }, [themeById, themes]); + + const persistTheme = async (themeId: string) => { + const next = { ...settings, themeId }; + setSettings(next); + setSelectedThemeId(themeId); + setThemeMenuOpen(false); + setThemeQuery(''); + try { + const res = await fetch('/api/monkeytype-duel/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings: next }), + }); + if (!res.ok) return; + const body = (await res.json()) as + | { message: string } + | { settings: unknown; otp?: string | null }; + if (!('settings' in body)) return; + const nextSettings = + body.settings && + typeof body.settings === 'object' && + !Array.isArray(body.settings) + ? (body.settings as Record) + : {}; + setSettings(nextSettings); + const nextOtp = body.otp; + if (typeof nextOtp === 'string' && nextOtp.length > 0) { + setOtp(nextOtp); + } + } catch { + // ignore + } + }; + + const resetOtp = async () => { + setResetting(true); + try { + const res = await fetch('/api/monkeytype-duel/reset-otp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!res.ok) return; + const body = (await res.json()) as { message?: string; otp?: string }; + if (typeof body.otp === 'string' && body.otp.length > 0) { + setOtp(body.otp); + } + } finally { + setResetting(false); + } + }; + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setThemeMenuOpen(false); + }; + if (!themeMenuOpen) return; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [themeMenuOpen]); + + return ( + + {/* + ); +} + +const ThemedCard = styled(Card)<{ $theme: MonkeytypeTheme }>` + --mt-main: ${({ $theme }) => $theme.mainColor}; + --mt-sub: ${({ $theme }) => $theme.subColor}; + --mt-control-h: 70px; + --mt-bg: ${({ $theme }) => $theme.bgColor}; + --mt-text: ${({ $theme }) => $theme.textColor}; + --mt-textMuted: ${({ $theme }) => hexToRgba($theme.textColor, 0.7)}; + --mt-surface: ${({ $theme }) => mutedSurfaceFromBg($theme.bgColor)}; + background-color: ${({ $theme }) => $theme.bgColor}; + position: relative; + font-family: 'Roboto Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, + Consolas, 'Liberation Mono', 'Courier New', monospace; +`; + +function MonkeytypeTitle(props: { $theme: MonkeytypeTheme }) { + // We intentionally keep this self-contained (no global font changes). + // Monkeytype's wordmark is close to a geometric sans; Inter is already used across the repo. + return ( + + + {/* Normalize exported negative-coordinate SVG into a clean 0-based viewBox. */} + + + + + + + + + + + + + + + monkeytype duel + + + + ); +} + +const ThemedOtpInline = styled(OtpInline)` + height: var(--mt-control-h); + border: 0; + background: transparent; + align-items: center; + color: var(--mt-text); + padding: 0.35rem 0; + + ${OtpInlineLabel} { + color: var(--mt-textMuted); + } +`; + +const TopRow = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding-top: 0.25rem; +`; + +const Row = styled.div` + display: flex; + align-items: stretch; + gap: 2.75rem; + + @media (max-width: 520px) { + flex-direction: column; + align-items: stretch; + } +`; + +const TooltipWrap = styled.div` + position: relative; + + &:hover > div[role='tooltip'], + &:focus-within > div[role='tooltip'] { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } +`; + +const Tooltip = styled.div` + position: absolute; + top: -44px; + right: 0; + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + box-shadow: ${neoShadows.small}; + padding: 0.35rem 0.55rem; + font-size: 0.8rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; + white-space: nowrap; + opacity: 0; + transform: translateY(4px); + pointer-events: none; + transition: opacity 0.12s ease, transform 0.12s ease; + z-index: 30; +`; + +const IconButton = styled.button<{ $boxShadowColor: string }>` + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + color: ${neoColors.text}; + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 3px 3px 0 ${({ $boxShadowColor }) => $boxShadowColor}; + transition: transform 0.08s ease; + + &:hover { + background: ${neoColors.background}; + } + + &:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 #000; + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + &:focus-visible { + outline: 3px solid var(--mt-main); + outline-offset: 2px; + } +`; + +type ThemePickerProps = { + $open: boolean; + themeId: string; + themeName: string; + query: string; + themes: MonkeytypeTheme[]; + total: number; + theme: MonkeytypeTheme; + onToggle: () => void; + onClose: () => void; + onQueryChange: (v: string) => void; + onSelect: (id: string) => void; +}; + +function ThemePicker(props: ThemePickerProps) { + const wrapRef = React.useRef(null); + const inputRef = React.useRef(null); + + useEffect(() => { + if (!props.$open) return; + window.setTimeout(() => inputRef.current?.focus(), 0); + }, [props.$open]); + + useEffect(() => { + const onMouseDown = (e: MouseEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) { + props.onClose(); + } + }; + if (!props.$open) return; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, [props.$open, props]); + + return ( + + + + + Theme + + {props.themeName} + + + + + {props.$open ? ( + + + props.onQueryChange(e.target.value)} + /> + + {props.query.trim() ? props.themes.length : props.total} + + + + + {props.themes.length === 0 ? ( + No matches + ) : ( + props.themes.map((t) => { + const selected = t.id === props.themeId; + return ( + props.onSelect(t.id)} + $selected={selected} + > +
+ {t.name} + {t.id} +
+ +
+ ); + }) + )} +
+
+ ) : null} +
+ ); +} + +const ThemePickerWrap = styled.div` + position: relative; + flex: 1 1 auto; + min-width: 280px; + + @media (max-width: 520px) { + min-width: 0; + } +`; + +const ThemePickerButton = styled.button<{ $boxShadowColor: string }>` + width: 100%; + height: var(--mt-control-h); + border: 0; + background: transparent; + color: var(--mt-text); + padding: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + cursor: pointer; + text-align: left; + box-shadow: none; + + &:hover { + opacity: 0.92; + } + + &:active { + transform: none; + } + + &:focus-visible { + outline: 3px solid var(--mt-main); + outline-offset: 2px; + } +`; + +const ThemePickerLeft = styled.div` + display: flex; + flex-direction: column; + min-width: 0; + padding: 0.45rem 0.85rem; +`; + +const ThemePickerRight = styled.div` + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + padding: 0.45rem 0.85rem; +`; + +const ChevronWrap = styled.div` + margin-left: 6px; + color: var(--mt-textMuted); +`; + +const Swatch = styled.div<{ $size?: 'md' | 'lg' }>` + width: ${({ $size }) => ($size === 'lg' ? '18px' : '12px')}; + height: ${({ $size }) => ($size === 'lg' ? '18px' : '12px')}; + border: ${neoBorders.standard}; +`; + +const ThemeMenu = styled.div` + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + box-shadow: ${neoShadows.medium}; + overflow: hidden; + z-index: 20; +`; + +const ThemeSearchRow = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem; + border-bottom: ${neoBorders.standard}; + background: ${neoColors.background}; +`; + +const ThemeSearch = styled.input` + width: 100%; + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + color: ${neoColors.text}; + padding: 0.6rem 0.7rem; + font: inherit; + + &::placeholder { + color: ${neoColors.textMuted}; + } + + &:focus { + outline: 3px solid var(--mt-main); + outline-offset: 2px; + } +`; + +const ThemeCount = styled.div` + min-width: 38px; + text-align: right; + font-size: 0.8rem; + color: ${neoColors.textMuted}; +`; + +const ThemeList = styled.div` + max-height: 320px; + overflow: auto; +`; + +const ThemeItem = styled.button<{ $selected: boolean }>` + width: 100%; + border: 0; + border-bottom: 2px solid #000; + background: ${({ $selected }) => + $selected ? `${neoColors.accent.yellow}55` : neoColors.surface}; + color: ${neoColors.text}; + padding: 0.65rem 0.8rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + cursor: pointer; + text-align: left; + + &:hover { + background: ${neoColors.accent.yellow}55; + } + + &:last-child { + border-bottom: 0; + } +`; + +const ThemeItemName = styled.div` + font-weight: 800; + line-height: 1.1; +`; + +const ThemeItemMeta = styled.div` + font-size: 0.8rem; + color: ${neoColors.textMuted}; + margin-top: 0.15rem; +`; + +const ThemeItemSwatches = styled.div` + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +`; + +const EmptyRow = styled.div` + padding: 0.85rem 0.8rem; + color: ${neoColors.textMuted}; +`; + +const WordmarkWrap = styled.div<{ $color: string }>` + display: flex; + align-items: center; + gap: 0.2rem; + color: ${({ $color }) => $color}; +`; + +const WordmarkIcon = styled.svg` + width: 56px; + height: 28px; + flex: 0 0 auto; + + rect { + fill: currentColor; + stroke: currentColor; + stroke-width: 2px; + } + + path { + fill: currentColor; + stroke: currentColor; + stroke-width: 2.5px; + stroke-linecap: round; + stroke-linejoin: round; + } +`; + +const WordmarkText = styled.div` + display: flex; + flex-direction: column; + line-height: 1; +`; + +const WordmarkBottom = styled.div<{ $color: string }>` + font-size: 1.5rem; + font-weight: 900; + letter-spacing: -0.02em; + color: ${({ $color }) => $color}; + font-family: 'Lexend Deca', ui-sans-serif, system-ui, -apple-system, + BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica Neue, Arial, Noto Sans, + sans-serif; + line-height: 1; +`; + +export default MonkeytypeCard; diff --git a/apps/dashboard/components/finalist-dashboard/RsvpCard.tsx b/apps/dashboard/components/finalist-dashboard/RsvpCard.tsx new file mode 100644 index 0000000..6763f1d --- /dev/null +++ b/apps/dashboard/components/finalist-dashboard/RsvpCard.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { NeoButton } from '../neo-ui/NeoButton'; +import { neoColors } from '../neo-ui/theme'; +import { NeoConfirmDialog } from '../neo-ui/NeoConfirmDialog'; +// eslint-disable-next-line @nrwl/nx/enforce-module-boundaries +import { ApplicationStatus } from 'libs/types/src/lib/application-status'; +import { + ButtonRow, + Card, + CardHeader, + CardSubtitle, + CardTitle, + MemberList, + MemberName, + MemberRow, + MemberStatus, + SmallMuted, +} from './common'; +import useHibiscusUser from '../../hooks/use-hibiscus-user/use-hibiscus-user'; +import { toast } from 'react-hot-toast'; + +export interface RsvpMember { + readonly name: string; + readonly email: string; + readonly attendanceConfirmed: boolean | null; +} + +export function RsvpCard() { + const { user, updateUser } = useHibiscusUser(); + const [choice, setChoice] = useState<'ACCEPT' | 'DECLINE' | null>(null); + const [loading, setLoading] = useState(false); + const [teamName, setTeamName] = useState('Your team'); + const [deadline, setDeadline] = useState('—'); + const [members, setMembers] = useState([]); + + const fetchTeamRsvp = async () => { + const res = await fetch('/api/finalist/rsvp'); + if (!res.ok) return; + const body = (await res.json()) as { + data?: { teamName: string; deadline: string; members: RsvpMember[] }; + }; + if (!body.data) return; + setTeamName(body.data.teamName); + setDeadline(body.data.deadline); + setMembers(body.data.members); + }; + + useEffect(() => { + fetchTeamRsvp().catch(() => null); + }, []); + + const canRespond = useMemo(() => { + if (!user?.applicationStatus) return false; + // Allow responses unless they've already confirmed/declined. + return ( + user.applicationStatus !== ApplicationStatus.CONFIRMED && + user.applicationStatus !== ApplicationStatus.DECLINED + ); + }, [user?.applicationStatus]); + + const hasResponded = useMemo(() => { + if (!user?.applicationStatus) return false; + return ( + user.applicationStatus === ApplicationStatus.CONFIRMED || + user.applicationStatus === ApplicationStatus.DECLINED + ); + }, [user?.applicationStatus]); + + const handleConfirm = async () => { + if (!choice) return; + setLoading(true); + + try { + const res = await fetch('/api/finalist/rsvp', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ choice }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? 'Failed to update RSVP'); + } + + if (choice === 'ACCEPT') { + toast.success( + 'Congratulations! You are confirmed for the National Finals!' + ); + updateUser({ + applicationStatus: ApplicationStatus.CONFIRMED, + attendanceConfirmed: true, + }); + } else { + toast.success('Your response has been recorded.'); + updateUser({ + applicationStatus: ApplicationStatus.DECLINED, + attendanceConfirmed: false, + }); + } + await fetchTeamRsvp(); + setChoice(null); + } catch (e: unknown) { + const message = e instanceof Error ? e.message : 'Failed to update RSVP'; + toast.error(message); + } finally { + setLoading(false); + } + }; + + return ( + + + RSVP + + Deadline: {deadline}. + + + + + {teamName} + {members.length} members + + + + {members.map((m) => ( + + {m.name} + + {toRsvpLabel(m.attendanceConfirmed)} + + + ))} + + + + {canRespond ? ( + <> + setChoice('ACCEPT')} + style={{ + background: '#fff', + textTransform: 'uppercase', + letterSpacing: '0.08em', + }} + > + Confirm + + setChoice('DECLINE')} + style={{ + textTransform: 'uppercase', + letterSpacing: '0.08em', + }} + > + Decline + + + ) : hasResponded ? ( + <> + ) : ( + + RSVP not available + + )} + + + setChoice(null)} + onConfirm={handleConfirm} + isLoading={loading} + title={choice === 'ACCEPT' ? 'Confirm your spot' : 'Decline your spot'} + message={ + choice === 'ACCEPT' + ? 'By confirming, you commit to attending the National Finals in person.' + : 'Are you sure? This action cannot be undone.' + } + confirmText={choice === 'ACCEPT' ? 'Confirm' : 'Decline'} + cancelText="Cancel" + variant={choice === 'ACCEPT' ? 'info' : 'danger'} + /> + + ); +} + +function toRsvpStatus( + attendanceConfirmed: boolean | null +): 'YES' | 'NO' | 'PENDING' { + if (attendanceConfirmed === true) return 'YES'; + if (attendanceConfirmed === false) return 'NO'; + return 'PENDING'; +} + +function toRsvpLabel(attendanceConfirmed: boolean | null) { + if (attendanceConfirmed === true) return 'RSVP’d'; + if (attendanceConfirmed === false) return 'Declined'; + return 'Pending'; +} + +const HeaderRow = (props: { children: React.ReactNode }) => ( +
+ {props.children} +
+); + +const TeamName = (props: { children: React.ReactNode }) => ( +
{props.children}
+); + +export default RsvpCard; diff --git a/apps/dashboard/components/finalist-dashboard/TravelCard.tsx b/apps/dashboard/components/finalist-dashboard/TravelCard.tsx new file mode 100644 index 0000000..e2a8346 --- /dev/null +++ b/apps/dashboard/components/finalist-dashboard/TravelCard.tsx @@ -0,0 +1,293 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { neoBorders, neoColors } from '../neo-ui/theme'; +import { + CardTitle, + FullWidthCard, + OtpInlineLabel, + OtpInlineValue, + SmallMuted, +} from './common'; + +export interface TravelCardProps { + venueName?: string; + venueLine?: string; +} + +export function TravelCard(props: TravelCardProps) { + const [myGateOtp, setMyGateOtp] = useState(null); + + useEffect(() => { + const run = async () => { + try { + const res = await fetch('/api/finalist/mygate-otp'); + if (!res.ok) return; + const body = (await res.json()) as { + data?: { myGateOtp?: number | null }; + }; + const value = body?.data?.myGateOtp; + setMyGateOtp(value == null ? null : String(value)); + } catch { + // ignore + } + }; + run(); + }, []); + + const venueName = props.venueName ?? 'Ashoka University'; + const venueLine = + props.venueLine ?? + 'Plot No. 2, Rajiv Gandhi Education City, Kundli, Sonipat, Haryana 131028'; + + return ( + + + + Travelling to Ashoka University + +
+ STEP 1: Reaching Azadpur + + + From NDLS (New Delhi Railway Station) + +
  • + Take the Yellow Line (towards Samaypur Badli). +
  • +
  • + Get off at Azadpur. +
  • +
  • Follow signs for the main exit + shuttle pickup.
  • +
    +
    + + + From IGI Airport (T3) + +
  • + Take the Airport Express to New Delhi. +
  • +
  • + Switch to the Yellow Line towards{' '} + Samaypur Badli. +
  • +
  • + Get off at Azadpur. +
  • +
    +
    +
    +
    + +
    + STEP 2: From Azadpur → Ashoka + +
  • + Board the Ashoka University shuttle (Force Traveller). +
  • +
  • + On arrival, go to the Gate No. 1. +
  • +
  • + Show your MyGate OTP at the gate and enter campus. +
  • +
  • Head to the help desk for your badge + Wi‑Fi.
  • +
    +
    + +
    + Alternative: Cab + +
  • + Book an Uber/Ola to Ashoka University (Gate No. 1). +
  • +
  • + At the gate, show your MyGate OTP to enter campus. +
  • +
    +
    +
    + + + + + + + window.open( + 'https://www.google.com/maps/search/?api=1&query=Ashoka%20University%2C%20Sonipat', + '_blank' + ) + } + > + Open in Google Maps + + + + + + + Venue + + {venueName} + + {venueLine} + + + +
    + MyGate OTP + {myGateOtp ?? '*****'} + {myGateOtp == null ? ( + + Will be updated closer to the hackathon date. + + ) : null} +
    +
    +
    +
    +
    +
    + ); +} + +const TravelGrid = styled.div` + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 1rem; + align-items: stretch; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } +`; + +const TravelLeft = styled.div` + display: flex; + flex-direction: column; + gap: 1.5rem; + min-width: 0; +`; + +const RightStack = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; + height: 100%; +`; + +const RightBottomRow = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + + @media (max-width: 520px) { + grid-template-columns: 1fr; + } +`; + +const List = styled.ul` + padding-left: 0rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + margin: 0; + margin-top: 0.5rem; + + li { + line-height: 1.2; + font-size: 0.9rem; + } +`; + +const SectionLabel = styled.div` + font-size: 0.9rem; + font-weight: 900; + text-transform: uppercase; + color: ${neoColors.textMuted}; +`; + +const RouteGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; + padding-top: 0.5rem; + + @media (max-width: 520px) { + grid-template-columns: 1fr; + } +`; + +const RouteBox = styled.div` + border: ${neoBorders.standard}; + background: ${neoColors.background}; + padding: 0.75rem; +`; + +const RouteTitle = styled.div` + font-weight: 900; + margin-bottom: 0.35rem; + line-height: 1.2; + font-size: 1.1rem; +`; + +const SubCard = styled.div` + border: ${neoBorders.standard}; + background: ${neoColors.background}; + padding: 0.9rem; + display: flex; + flex-direction: column; + gap: 0.35rem; + min-height: 140px; +`; + +const MapWrap = styled.div` + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; + flex: 1 1 auto; + min-height: 320px; +`; + +const MapFrame = styled.iframe` + width: 100%; + height: 100%; + border: 0; + background: ${neoColors.background}; +`; + +const MapOverlay = styled.div` + position: absolute; + top: 10px; + right: 10px; +`; + +const MapOverlayButton = styled.button` + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + font-weight: 900; + padding: 0.45rem 0.65rem; + cursor: pointer; + box-shadow: 3px 3px 0 #000; + + &:hover { + background: ${neoColors.background}; + } + + &:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0 #000; + } +`; + +export default TravelCard; diff --git a/apps/dashboard/components/finalist-dashboard/TravelReimbursementCard.tsx b/apps/dashboard/components/finalist-dashboard/TravelReimbursementCard.tsx new file mode 100644 index 0000000..aafbdd2 --- /dev/null +++ b/apps/dashboard/components/finalist-dashboard/TravelReimbursementCard.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { neoColors } from '../neo-ui/theme'; +import { + Card, + CardHeader, + CardTitle, + EmbedFrame, + EmbedShell, + SmallMuted, + StatusBadge, + StatusRow, +} from './common'; + +export type TravelReimbursementStatus = + | 'NOT_STARTED' + | 'DRAFT' + | 'SUBMITTED' + | 'UNDER_REVIEW' + | 'APPROVED' + | 'REJECTED'; + +export interface TravelReimbursementCardProps { + status: TravelReimbursementStatus; + lastUpdated: string; + tallyUrl: string; +} + +export function TravelReimbursementCard(props: TravelReimbursementCardProps) { + return ( + + + Travel reimbursement + + + + + {humanizeStatus(props.status)} + + Last Updated: {props.lastUpdated} + + + {statusMessage(props.status)} + + {['NOT_STARTED', 'DRAFT'].includes(props.status) ? ( + + + + ) : null} + + ); +} + +function humanizeStatus(status: TravelReimbursementStatus) { + switch (status) { + case 'NOT_STARTED': + return 'Not started'; + case 'DRAFT': + return 'Draft'; + case 'SUBMITTED': + return 'Submitted'; + case 'UNDER_REVIEW': + return 'Under review'; + case 'APPROVED': + return 'Approved'; + case 'REJECTED': + return 'Rejected'; + } +} + +function statusMessage(status: TravelReimbursementStatus) { + if (status === 'UNDER_REVIEW') { + return 'We received your form. Ops is verifying receipts — expect an update soon.'; + } + if (status === 'APPROVED') { + return 'Approved. Payout will be processed shortly.'; + } + if (status === 'REJECTED') { + return 'Needs changes. Please re-submit with corrected details.'; + } + return 'Submit the form to start your reimbursement request.'; +} + +export default TravelReimbursementCard; diff --git a/apps/dashboard/components/finalist-dashboard/common.tsx b/apps/dashboard/components/finalist-dashboard/common.tsx new file mode 100644 index 0000000..56a7321 --- /dev/null +++ b/apps/dashboard/components/finalist-dashboard/common.tsx @@ -0,0 +1,308 @@ +import styled from 'styled-components'; +import { NeoCard } from '../neo-ui/NeoCard'; +import { neoBorders, neoColors } from '../neo-ui/theme'; + +export const PageContainer = styled.div` + max-width: 1050px; + margin: 0 auto; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + gap: 1.25rem; +`; + +export const HeaderRow = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +`; + +export const PageTitle = styled.h1` + font-size: 2rem; + font-weight: 800; + margin: 0; + text-transform: uppercase; +`; + +export const PageSubtitle = styled.p` + margin: 0.25rem 0 0; + color: ${neoColors.textMuted}; + font-weight: 500; + max-width: 55ch; +`; + +export const CountdownChip = styled.div` + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + padding: 0.75rem 0.9rem; + min-width: 220px; +`; + +export const CountdownValue = styled.div` + font-size: 1.05rem; + font-weight: 900; + margin-top: 0.15rem; +`; + +export const CountdownHint = styled.div` + font-size: 0.8rem; + color: ${neoColors.textMuted}; + margin-top: 0.15rem; +`; + +export const HeroBanner = styled(NeoCard).attrs({ + accent: neoColors.accent.yellow, +})` + display: flex; + flex-direction: column; + gap: 0.75rem; +`; + +export const HeroMeta = styled.div` + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + + @media (max-width: 900px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (max-width: 520px) { + grid-template-columns: 1fr; + } +`; + +export const MetaItem = styled.div` + background: ${neoColors.background}; + border: ${neoBorders.standard}; + padding: 0.75rem; +`; + +export const MetaLabel = styled.div` + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; + color: ${neoColors.textMuted}; + margin-bottom: 0.25rem; +`; + +export const MetaValue = styled.div` + font-size: 0.95rem; + font-weight: 700; + line-height: 1.25; +`; + +export const TwoColumn = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + align-items: start; + + @media (max-width: 900px) { + grid-template-columns: 1fr; + } +`; + +export const Column = styled.div` + display: flex; + flex-direction: column; + gap: 1rem; +`; + +export const Card = styled(NeoCard)<{ accent?: string }>` + display: flex; + flex-direction: column; + gap: 0.9rem; +`; + +export const FullWidthCard = styled(Card)` + width: 100%; +`; + +export const CardHeader = styled.div` + display: flex; + flex-direction: column; + gap: 0.35rem; +`; + +export const CardTitle = styled.h2` + margin: 0; + font-size: 1.25rem; + font-weight: 800; +`; + +export const CardSubtitle = styled.p` + margin: 0; + font-size: 0.9rem; + line-height: 1.4; + color: ${neoColors.textMuted}; +`; + +export const SmallMuted = styled.div` + font-size: 0.85rem; + color: ${neoColors.textMuted}; +`; + +export const ButtonRow = styled.div` + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +`; + +export const Divider = styled.div` + height: 1px; + width: 100%; + border-top: ${neoBorders.standard}; +`; + +export const StatusRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +`; + +export const StatusBadge = styled.div<{ + $tone: + | 'NOT_STARTED' + | 'DRAFT' + | 'SUBMITTED' + | 'UNDER_REVIEW' + | 'APPROVED' + | 'REJECTED'; +}>` + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.6rem; + border: ${neoBorders.standard}; + font-weight: 800; + text-transform: uppercase; + font-size: 0.75rem; + background: ${({ $tone }) => { + switch ($tone) { + case 'APPROVED': + return `${neoColors.status.success}22`; + case 'REJECTED': + return `${neoColors.status.error}22`; + case 'UNDER_REVIEW': + return `${neoColors.accent.yellow}55`; + case 'SUBMITTED': + return `${neoColors.accent.blue}22`; + default: + return `${neoColors.background}`; + } + }}; +`; + +export const EmbedShell = styled.div` + border: ${neoBorders.standard}; + background: ${neoColors.surface}; + overflow: hidden; +`; + +export const EmbedFrame = styled.iframe` + width: 100%; + height: 320px; + border: 0; + background: ${neoColors.background}; +`; + +export const OtpInline = styled.div` + border: ${neoBorders.standard}; + background: ${neoColors.background}; + padding: 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +`; + +export const OtpInlineLabel = styled.div` + font-size: 0.75rem; + font-weight: 900; + text-transform: uppercase; +`; + +export const OtpInlineValue = styled.div` + font-weight: 900; + font-size: 1.3rem; + letter-spacing: 0.08em; + line-height: 1.1; +`; + +export const MemberList = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +export const MemberRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border: ${neoBorders.standard}; + background: ${neoColors.background}; +`; + +export const MemberName = styled.div` + font-weight: 900; +`; + +export const MemberStatus = styled.div<{ $status: 'YES' | 'NO' | 'PENDING' }>` + border: ${neoBorders.standard}; + font-weight: 900; + text-transform: uppercase; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + background: ${({ $status }) => + $status === 'YES' + ? `${neoColors.status.success}22` + : $status === 'NO' + ? `${neoColors.status.error}22` + : `${neoColors.accent.yellow}55`}; +`; + +export const ThemeGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + + @media (max-width: 520px) { + grid-template-columns: 1fr; + } +`; + +export const ThemeOption = styled.button<{ $selected: boolean }>` + border: ${neoBorders.standard}; + background: ${({ $selected }) => + $selected ? `${neoColors.accent.yellow}55` : neoColors.background}; + padding: 0.75rem; + display: flex; + align-items: center; + gap: 0.75rem; + cursor: pointer; + text-align: left; + + &:hover { + background: ${neoColors.accent.yellow}55; + } +`; + +export const ThemeSwatch = styled.div` + width: 18px; + height: 18px; + border: ${neoBorders.standard}; + flex: 0 0 auto; +`; + +export const ThemeName = styled.div` + font-weight: 900; + line-height: 1.2; +`; diff --git a/apps/dashboard/components/finalist-dashboard/monkeytype-themes.ts b/apps/dashboard/components/finalist-dashboard/monkeytype-themes.ts new file mode 100644 index 0000000..f3c4974 --- /dev/null +++ b/apps/dashboard/components/finalist-dashboard/monkeytype-themes.ts @@ -0,0 +1,63 @@ +export type MonkeytypeThemeColors = { + bgColor: string; + mainColor: string; + subColor: string; + textColor: string; +}; + +export type MonkeytypeTheme = MonkeytypeThemeColors & { + /** Monkeytype theme key, e.g. `ms_cupcakes` */ + id: string; + /** Human readable theme name */ + name: string; +}; + +// Seed list (you'll paste the full Monkeytype theme list later). +export const MONKEYTYPE_THEMES: MonkeytypeTheme[] = [ + { + id: 'ms_cupcakes', + name: 'ms cupcakes', + bgColor: '#ffffff', + mainColor: '#5ed5f3', + subColor: '#d64090', + textColor: '#0a282f', + }, + { + id: 'dollar', + name: 'dollar', + bgColor: '#e4e4d4', + mainColor: '#6b886b', + subColor: '#8a9b69', + textColor: '#555a56', + }, + { + id: 'lime', + name: 'lime', + bgColor: '#7c878e', + mainColor: '#93c247', + subColor: '#4b5257', + textColor: '#bfcfdc', + }, + { + id: 'sweden', + name: 'sweden', + bgColor: '#0058a3', + mainColor: '#ffcc02', + subColor: '#57abdb', + textColor: '#ffffff', + }, +]; + +export const monkeytypeThemeById = new Map( + MONKEYTYPE_THEMES.map((t) => [t.id, t]) +); + +export function getMonkeytypeThemeById( + themeId: string | null | undefined +): MonkeytypeTheme { + if (typeof themeId === 'string' && themeId.length > 0) { + const theme = monkeytypeThemeById.get(themeId); + if (theme) return theme; + } + return MONKEYTYPE_THEMES[0]; +} diff --git a/apps/dashboard/components/nav/side-nav2.tsx b/apps/dashboard/components/nav/side-nav2.tsx index 6d2273f..3268689 100644 --- a/apps/dashboard/components/nav/side-nav2.tsx +++ b/apps/dashboard/components/nav/side-nav2.tsx @@ -58,6 +58,10 @@ const SideNav = ({ options }: Props) => {
    Contact RBH Support
    +91 90500 14105
    +
    + This phone number is managed by a full-time student. Please call + between 10:00 AM–6:00 PM IST. +
    redbrickhacks@ashoka.edu.in
    diff --git a/apps/dashboard/components/neo-ui/NeoSidebar.tsx b/apps/dashboard/components/neo-ui/NeoSidebar.tsx index 87bd226..dd1abc6 100644 --- a/apps/dashboard/components/neo-ui/NeoSidebar.tsx +++ b/apps/dashboard/components/neo-ui/NeoSidebar.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import Link from 'next/link'; import { useRouter } from 'next/router'; +import { HibiscusRole, HibiscusUser } from '@hibiscus/types'; import { FaHouse, FaUsers, @@ -15,28 +16,43 @@ import { neoColors, neoBorders, neoTransition } from './theme'; const CONTACT = { email: 'redbrickhacks@ashoka.edu.in', phone: '+91 90500 14105', + phoneHours: '10:00 AM–6:00 PM IST', }; -const NAV_ITEMS = [ - { path: '/', label: 'Home', icon: FaHouse }, - { path: '/team', label: 'Team', icon: FaUsers }, - { path: '/submit', label: 'Submit', icon: FaCloudArrowUp }, - { path: '/apply', label: 'Profile', icon: FaUser }, -]; +const NAV_ITEMS = (user: HibiscusUser) => + user.role === HibiscusRole.FINALIST + ? [ + { path: '/finalist', label: 'Home', icon: FaHouse }, + { path: '/team', label: 'Team', icon: FaUsers }, + { path: '/submit', label: 'Submit', icon: FaCloudArrowUp }, + { path: '/apply', label: 'Profile', icon: FaUser }, + ] + : [ + { path: '/', label: 'Home', icon: FaHouse }, + { path: '/team', label: 'Team', icon: FaUsers }, + { path: '/submit', label: 'Submit', icon: FaCloudArrowUp }, + { path: '/apply', label: 'Profile', icon: FaUser }, + ]; function isActiveRoute(itemPath: string, currentPath: string): boolean { if (itemPath === '/') return currentPath === '/'; return currentPath.startsWith(itemPath); } -export function NeoSidebar() { +export interface NeoSidebarProps { + user: HibiscusUser; +} + +export function NeoSidebar({ user }: NeoSidebarProps) { const router = useRouter(); const currentPath = router.pathname; + const navItems = useMemo(() => NAV_ITEMS(user), [user]); + return (