From a99301ab38f053b710ce563b98588cfe72095c01 Mon Sep 17 00:00:00 2001 From: jagadeep kalluri Date: Thu, 5 Mar 2026 13:53:35 -0600 Subject: [PATCH 1/2] feat: improve hacker application UX with phone input, university search, and other field support - Add PhoneInput component with country code dropdown and auto-formatting - Add UniversityCombobox with Hipo API search and popular universities list - Add SelectWithOther component for dropdowns with custom 'Other' text input - Add AgreementsSection to admin review panels for better visibility - Change 'Major' label to 'Field(s) of Study' across application forms - Add missing Race field to PersonalInfoStep - Fix .air.toml for Windows compatibility --- .air.toml | 6 +- client/web/package-lock.json | 77 ++++++--- client/web/package.json | 1 + client/web/src/components/ui/phone-input.tsx | 160 ++++++++++++++++++ .../_shared/grading/GradingDetailsPanel.tsx | 2 + .../components/ApplicationDetailPanel.tsx | 2 + .../detail-sections/AgreementsSection.tsx | 66 ++++++++ .../components/detail-sections/index.ts | 1 + .../components/ApplicationDetailsPanel.tsx | 58 ++++++- client/web/src/pages/hacker/apply/api.ts | 53 ++++++ .../apply/components/SelectWithOther.tsx | 123 ++++++++++++++ .../apply/components/UniversityComboBox.tsx | 155 +++++++++++++++++ .../hacker/apply/steps/ExperienceStep.tsx | 24 ++- .../hacker/apply/steps/PersonalInfoStep.tsx | 60 +++---- .../pages/hacker/apply/steps/ReviewStep.tsx | 2 +- .../hacker/apply/steps/SchoolInfoStep.tsx | 47 +++-- 16 files changed, 741 insertions(+), 96 deletions(-) create mode 100644 client/web/src/components/ui/phone-input.tsx create mode 100644 client/web/src/pages/admin/all-applicants/components/detail-sections/AgreementsSection.tsx create mode 100644 client/web/src/pages/hacker/apply/components/SelectWithOther.tsx create mode 100644 client/web/src/pages/hacker/apply/components/UniversityComboBox.tsx diff --git a/.air.toml b/.air.toml index 65f92311..34efb6cc 100644 --- a/.air.toml +++ b/.air.toml @@ -6,10 +6,10 @@ tmp_dir = "tmp" [build] args_bin = [] - bin = "./tmp/main" - cmd = "go build -o ./tmp/main ./cmd/api" + bin = "./tmp/main.exe" + cmd = "go build -o ./tmp/main.exe ./cmd/api" delay = 1000 - entrypoint = ["./tmp/main"] + entrypoint = ["./tmp/main.exe"] exclude_dir = ["assets", "tmp", "vendor", "testdata", "web", "docs", "scripts", "client"] exclude_file = [] exclude_regex = ["_test.go"] diff --git a/client/web/package-lock.json b/client/web/package-lock.json index 826934f4..87ccb388 100644 --- a/client/web/package-lock.json +++ b/client/web/package-lock.json @@ -49,6 +49,7 @@ "react-day-picker": "^9.13.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-phone-number-input": "^3.4.16", "react-resizable-panels": "^4.4.1", "react-router-dom": "^7.12.0", "recharts": "^2.15.4", @@ -112,7 +113,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3460,7 +3460,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3471,7 +3470,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3482,7 +3480,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3532,7 +3529,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -3771,7 +3767,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3920,7 +3915,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3995,6 +3989,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4060,6 +4060,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/country-flag-icons": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/country-flag-icons/-/country-flag-icons-1.6.15.tgz", + "integrity": "sha512-92HoA8l6DluEidku8tKBftjuFRj4Rv3zDW1lXxCuNnqAxhUSkvso9gM/Afj4F5BnK+wneHIe3ydI+s+4NA29/Q==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4285,8 +4291,7 @@ "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/embla-carousel-react": { "version": "8.6.0", @@ -4393,7 +4398,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4942,6 +4946,27 @@ "node": ">=0.8.19" } }, + "node_modules/input-format": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/input-format/-/input-format-0.3.14.tgz", + "integrity": "sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=18.1.0", + "react-dom": ">=18.1.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -5128,6 +5153,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.12.38", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.38.tgz", + "integrity": "sha512-vwzxmasAy9hZigxtqTbFEwp8ZdZ975TiqVDwj5bKx5sR+zi5ucUQy9mbVTkKM9GzqdLdxux/hTw2nmN5J7POMA==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -5752,7 +5783,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5783,7 +5813,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5796,7 +5825,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -5814,6 +5842,23 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-phone-number-input": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/react-phone-number-input/-/react-phone-number-input-3.4.16.tgz", + "integrity": "sha512-ix1+SIyGuLxnlAeW+XQNhwOmmDd4Zmr6Ng1eQl0E2XS8xIuue3gdZeBG4LGTMjTIFneOTO9pUPL+AEDhV6+r+A==", + "license": "MIT", + "dependencies": { + "classnames": "^2.5.1", + "country-flag-icons": "^1.6.14", + "input-format": "^0.3.14", + "libphonenumber-js": "^1.12.37", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-qr-code": { "version": "2.0.18", "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", @@ -6215,7 +6260,6 @@ "resolved": "https://registry.npmjs.org/supertokens-web-js/-/supertokens-web-js-0.16.0.tgz", "integrity": "sha512-wuIdlVJtOsx4ZX0kCyl8lxmmAodXLlMAniZEHyVhsH2fhersh7bMrHpvgN9WoC470HYNC22qpHdlJngvyh/cSA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@simplewebauthn/browser": "^13.0.0", "supertokens-js-override": "0.0.4", @@ -6331,7 +6375,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6400,7 +6443,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6587,7 +6629,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6679,7 +6720,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6745,7 +6785,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/client/web/package.json b/client/web/package.json index 35f50e12..8d74e951 100644 --- a/client/web/package.json +++ b/client/web/package.json @@ -53,6 +53,7 @@ "react-day-picker": "^9.13.0", "react-dom": "^19.2.0", "react-hook-form": "^7.71.1", + "react-phone-number-input": "^3.4.16", "react-resizable-panels": "^4.4.1", "react-router-dom": "^7.12.0", "recharts": "^2.15.4", diff --git a/client/web/src/components/ui/phone-input.tsx b/client/web/src/components/ui/phone-input.tsx new file mode 100644 index 00000000..c24fbccf --- /dev/null +++ b/client/web/src/components/ui/phone-input.tsx @@ -0,0 +1,160 @@ +import * as React from "react"; + +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/shared/lib/utils"; + +// Country codes list +const COUNTRY_CODES = [ + { code: "US", dialCode: "+1", name: "United States" }, + { code: "CA", dialCode: "+1", name: "Canada" }, + { code: "MX", dialCode: "+52", name: "Mexico" }, + { code: "GB", dialCode: "+44", name: "United Kingdom" }, + { code: "DE", dialCode: "+49", name: "Germany" }, + { code: "FR", dialCode: "+33", name: "France" }, + { code: "IN", dialCode: "+91", name: "India" }, + { code: "CN", dialCode: "+86", name: "China" }, + { code: "JP", dialCode: "+81", name: "Japan" }, + { code: "KR", dialCode: "+82", name: "South Korea" }, + { code: "AU", dialCode: "+61", name: "Australia" }, + { code: "BR", dialCode: "+55", name: "Brazil" }, + { code: "ES", dialCode: "+34", name: "Spain" }, + { code: "IT", dialCode: "+39", name: "Italy" }, + { code: "NL", dialCode: "+31", name: "Netherlands" }, + { code: "SE", dialCode: "+46", name: "Sweden" }, + { code: "CH", dialCode: "+41", name: "Switzerland" }, + { code: "SG", dialCode: "+65", name: "Singapore" }, + { code: "AE", dialCode: "+971", name: "UAE" }, + { code: "SA", dialCode: "+966", name: "Saudi Arabia" }, + { code: "NG", dialCode: "+234", name: "Nigeria" }, + { code: "ZA", dialCode: "+27", name: "South Africa" }, + { code: "PH", dialCode: "+63", name: "Philippines" }, + { code: "VN", dialCode: "+84", name: "Vietnam" }, + { code: "ID", dialCode: "+62", name: "Indonesia" }, + { code: "MY", dialCode: "+60", name: "Malaysia" }, + { code: "TH", dialCode: "+66", name: "Thailand" }, + { code: "PL", dialCode: "+48", name: "Poland" }, + { code: "TR", dialCode: "+90", name: "Turkey" }, + { code: "EG", dialCode: "+20", name: "Egypt" }, +] as const; + +// Format phone number for display (US format) +function formatPhoneDisplay(digits: string): string { + if (digits.length <= 3) return digits; + if (digits.length <= 6) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6, 10)}`; +} + +// Parse E.164 to extract country code and national number +function parseE164(e164: string): { countryCode: string; nationalNumber: string } { + if (!e164 || !e164.startsWith("+")) { + return { countryCode: "US", nationalNumber: "" }; + } + + // Sort by dial code length (longest first) to match correctly + const sortedCodes = [...COUNTRY_CODES].sort( + (a, b) => b.dialCode.length - a.dialCode.length + ); + + for (const country of sortedCodes) { + if (e164.startsWith(country.dialCode)) { + return { + countryCode: country.code, + nationalNumber: e164.slice(country.dialCode.length), + }; + } + } + + return { countryCode: "US", nationalNumber: e164.slice(1) }; +} + +interface PhoneInputProps { + value?: string; + onChange?: (value: string | undefined) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +function PhoneInput({ + value = "", + onChange, + placeholder = "(555) 123-4567", + disabled, + className, +}: PhoneInputProps) { + // Parse the E.164 value + const parsed = parseE164(value); + const [countryCode, setCountryCode] = React.useState(parsed.countryCode); + const [nationalNumber, setNationalNumber] = React.useState(parsed.nationalNumber); + + // Get the dial code for selected country + const selectedCountry = COUNTRY_CODES.find((c) => c.code === countryCode) || COUNTRY_CODES[0]; + + // Sync when value prop changes externally + React.useEffect(() => { + const parsed = parseE164(value); + setCountryCode(parsed.countryCode); + setNationalNumber(parsed.nationalNumber); + }, [value]); + + // Update the E.164 value when country or number changes + const updateValue = (newCountryCode: string, newNationalNumber: string) => { + const country = COUNTRY_CODES.find((c) => c.code === newCountryCode) || COUNTRY_CODES[0]; + const digits = newNationalNumber.replace(/\D/g, ""); + if (digits) { + onChange?.(country.dialCode + digits); + } else { + onChange?.(undefined); + } + }; + + const handleCountryChange = (newCode: string) => { + setCountryCode(newCode); + updateValue(newCode, nationalNumber); + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const digits = e.target.value.replace(/\D/g, "").slice(0, 15); + setNationalNumber(digits); + updateValue(countryCode, digits); + }; + + return ( +
+ {/* Country Code Dropdown */} + + + {/* Phone Number Input */} + +
+ ); +} + +export { PhoneInput }; \ No newline at end of file diff --git a/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx b/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx index 41a71c29..74e2f1fa 100644 --- a/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx +++ b/client/web/src/pages/admin/_shared/grading/GradingDetailsPanel.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { Skeleton } from "@/components/ui/skeleton"; import { fetchApplicationResumeURL } from "@/pages/admin/all-applicants/api"; import { + AgreementsSection, DemographicsSection, EducationSection, EventPreferencesSection, @@ -83,6 +84,7 @@ export const GradingDetailsPanel = memo(function GradingDetailsPanel({ onViewResume={handleViewResume} isOpeningResume={isOpeningResume} /> + {children} diff --git a/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx b/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx index 21d66870..a558810e 100644 --- a/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx +++ b/client/web/src/pages/admin/all-applicants/components/ApplicationDetailPanel.tsx @@ -17,6 +17,7 @@ import type { Application } from "@/types"; import { fetchApplicationResumeURL } from "../api"; import { formatName, getStatusColor } from "../utils"; import { + AgreementsSection, DemographicsSection, EducationSection, EventPreferencesSection, @@ -133,6 +134,7 @@ export const ApplicationDetailPanel = memo(function ApplicationDetailPanel({ onViewResume={handleViewResume} isOpeningResume={isOpeningResume} /> + ) : null} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/AgreementsSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/AgreementsSection.tsx new file mode 100644 index 00000000..52749305 --- /dev/null +++ b/client/web/src/pages/admin/all-applicants/components/detail-sections/AgreementsSection.tsx @@ -0,0 +1,66 @@ +import { CheckCircle2, XCircle } from "lucide-react"; + +import { cn } from "@/shared/lib/utils"; +import type { Application } from "@/types"; + +interface AgreementsSectionProps { + application: Application; +} + +function AgreementItem({ + label, + checked, + required = false, +}: { + label: string; + checked: boolean; + required?: boolean; +}) { + return ( +
+ {checked ? ( + + ) : ( + + )} + + {label} + {required && *} + +
+ ); +} + +export function AgreementsSection({ application }: AgreementsSectionProps) { + return ( +
+

Agreements & Acknowledgments

+
+ + + + +
+
+ ); +} diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/index.ts b/client/web/src/pages/admin/all-applicants/components/detail-sections/index.ts index bd1b0417..5590a804 100644 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/index.ts +++ b/client/web/src/pages/admin/all-applicants/components/detail-sections/index.ts @@ -1,3 +1,4 @@ +export { AgreementsSection } from "./AgreementsSection"; export { DemographicsSection } from "./DemographicsSection"; export { EducationSection } from "./EducationSection"; export { EventPreferencesSection } from "./EventPreferencesSection"; diff --git a/client/web/src/pages/admin/assigned/components/ApplicationDetailsPanel.tsx b/client/web/src/pages/admin/assigned/components/ApplicationDetailsPanel.tsx index ce64c2ae..f38716df 100644 --- a/client/web/src/pages/admin/assigned/components/ApplicationDetailsPanel.tsx +++ b/client/web/src/pages/admin/assigned/components/ApplicationDetailsPanel.tsx @@ -1,4 +1,4 @@ -import { ExternalLink, Loader2 } from "lucide-react"; +import { CheckCircle2, ExternalLink, Loader2, XCircle } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; @@ -6,11 +6,41 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { errorAlert } from "@/shared/lib/api"; +import { cn } from "@/shared/lib/utils"; import type { Application } from "@/types"; import { fetchApplicationResumeURL } from "../../all-applicants/api"; import type { Review } from "../types"; +function AgreementItem({ + label, + checked, + required = false, +}: { + label: string; + checked: boolean; + required?: boolean; +}) { + return ( +
+ {checked ? ( + + ) : ( + + )} + + {label} + {required && *} + +
+ ); +} + interface ApplicationDetailsPanelProps { application: Application; selectedReview: Review; @@ -284,6 +314,32 @@ export function ApplicationDetailsPanel({ )} + {/* Agreements & Acknowledgments */} +
+

Agreements & Acknowledgments

+
+ + + + +
+
+ {/* Timeline */}

Timeline

diff --git a/client/web/src/pages/hacker/apply/api.ts b/client/web/src/pages/hacker/apply/api.ts index 29a1e0d3..06fded7c 100644 --- a/client/web/src/pages/hacker/apply/api.ts +++ b/client/web/src/pages/hacker/apply/api.ts @@ -93,3 +93,56 @@ export async function uploadResumeToSignedURL( } export { MAX_RESUME_SIZE_BYTES }; + + +// University Search API (Hipo) +export interface University { + name: string; + country: string; + alpha_two_code: string; + domains: string[]; + web_pages: string[]; + state_province: string | null; +} + +const HIPO_API_BASE = "http://universities.hipolabs.com"; +const universityCache = new Map(); + +//Search universities via Hipo API +export async function searchUniversities(query: string): Promise { + if (query.length < 2) return []; + + const cacheKey = query.toLowerCase(); + if (universityCache.has(cacheKey)) { + return universityCache.get(cacheKey)!; + } + + try { + const params = new URLSearchParams({ name: query }); + const response = await fetch(`${HIPO_API_BASE}/search?${params}`); + + if (!response.ok) return []; + + const universities: University[] = await response.json(); + + // Cache results (limit cache size) + if (universityCache.size > 100) { + const firstKey = universityCache.keys().next().value; + if (firstKey) universityCache.delete(firstKey); + } + universityCache.set(cacheKey, universities); + + return universities; + } catch { + return []; + } +} + +export const POPULAR_UNIVERSITIES = [ + "University of Texas at Dallas", + "University of Texas at Austin", + "Texas A&M University", + "Massachusetts Institute of Technology", + "Stanford University", + "Harvard University", +]; \ No newline at end of file diff --git a/client/web/src/pages/hacker/apply/components/SelectWithOther.tsx b/client/web/src/pages/hacker/apply/components/SelectWithOther.tsx new file mode 100644 index 00000000..fd9e6465 --- /dev/null +++ b/client/web/src/pages/hacker/apply/components/SelectWithOther.tsx @@ -0,0 +1,123 @@ +import * as React from "react"; + +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface Option { + value: string; + label: string; +} + +interface SelectWithOtherProps { + options: Option[]; + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + otherPlaceholder?: string; + disabled?: boolean; +} + +export function SelectWithOther({ + options, + value, + onChange, + placeholder = "Select an option", + otherPlaceholder = "Please specify...", + disabled, +}: SelectWithOtherProps) { + // Check if current value is a predefined option + const isPredefinedValue = options.some((opt) => opt.value === value); + + // Determine initial state: show input if value exists but isn't a predefined option + const getInitialOtherMode = () => { + if (!value) return false; + if (value === "other") return true; + return !isPredefinedValue; + }; + + const [isOtherMode, setIsOtherMode] = React.useState(getInitialOtherMode); + const [customValue, setCustomValue] = React.useState( + value && !isPredefinedValue && value !== "other" ? value : "" + ); + + // Only sync from props on mount - intentionally empty deps to run once + const initializedRef = React.useRef(false); + React.useEffect(() => { + if (!initializedRef.current) { + initializedRef.current = true; + if (value && !isPredefinedValue && value !== "other") { + setIsOtherMode(true); + setCustomValue(value); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleSelectChange = (newValue: string) => { + if (newValue === "other") { + setIsOtherMode(true); + setCustomValue(""); + onChange?.(""); // Clear value until they type + } else { + setIsOtherMode(false); + setCustomValue(""); + onChange?.(newValue); + } + }; + + const handleOtherInputChange = (e: React.ChangeEvent) => { + const newCustomValue = e.target.value; + setCustomValue(newCustomValue); + onChange?.(newCustomValue); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + // Blur the input to indicate the value is saved + (e.target as HTMLInputElement).blur(); + } + }; + + // Determine what to show in the select + const selectValue = isOtherMode ? "other" : value || ""; + + return ( +
+ + + {isOtherMode && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/client/web/src/pages/hacker/apply/components/UniversityComboBox.tsx b/client/web/src/pages/hacker/apply/components/UniversityComboBox.tsx new file mode 100644 index 00000000..0cbb513c --- /dev/null +++ b/client/web/src/pages/hacker/apply/components/UniversityComboBox.tsx @@ -0,0 +1,155 @@ +import { Check, ChevronsUpDown, Loader2 } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/shared/lib/utils"; + +import { POPULAR_UNIVERSITIES, searchUniversities } from "../api"; + +interface UniversityComboboxProps { + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; +} + +export function UniversityCombobox({ + value, + onChange, + placeholder = "Select university...", + disabled, +}: UniversityComboboxProps) { + const [open, setOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(""); + const [universities, setUniversities] = + React.useState(POPULAR_UNIVERSITIES); + const [loading, setLoading] = React.useState(false); + + // Debounced search + React.useEffect(() => { + if (searchQuery.length < 2) { + setUniversities(POPULAR_UNIVERSITIES); + return; + } + + const timeoutId = setTimeout(async () => { + setLoading(true); + try { + const results = await searchUniversities(searchQuery); + if (results.length > 0) { + const names = [...new Set(results.map((u) => u.name))]; + setUniversities(names.slice(0, 50)); + } else { + setUniversities(POPULAR_UNIVERSITIES); + } + } finally { + setLoading(false); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [searchQuery]); + + const handleSelect = (selectedValue: string) => { + onChange?.(selectedValue); + setOpen(false); + setSearchQuery(""); + }; + + return ( + + + + + + + + + {loading ? ( +
+ + + Searching... + +
+ ) : ( + <> + +

+ No universities found. +

+
+ + {universities.map((university) => ( + handleSelect(university)} + > + + {university} + + ))} + + {searchQuery.length >= 2 && + !universities.includes(searchQuery) && ( + + handleSelect(searchQuery)} + > + + Use "{searchQuery}" + + + )} + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/client/web/src/pages/hacker/apply/steps/ExperienceStep.tsx b/client/web/src/pages/hacker/apply/steps/ExperienceStep.tsx index c33f7f76..e1ae862b 100644 --- a/client/web/src/pages/hacker/apply/steps/ExperienceStep.tsx +++ b/client/web/src/pages/hacker/apply/steps/ExperienceStep.tsx @@ -16,6 +16,7 @@ import { SelectValue, } from "@/components/ui/select"; +import { SelectWithOther } from "../components/SelectWithOther"; import type { ApplicationFormData } from "../validations"; import { EXPERIENCE_LEVEL_OPTIONS, HEARD_ABOUT_OPTIONS } from "../validations"; @@ -87,20 +88,15 @@ export function ExperienceStep() { render={({ field }) => ( Where did you hear about this event? * - + + + )} diff --git a/client/web/src/pages/hacker/apply/steps/PersonalInfoStep.tsx b/client/web/src/pages/hacker/apply/steps/PersonalInfoStep.tsx index f1183b15..3a40e6ca 100644 --- a/client/web/src/pages/hacker/apply/steps/PersonalInfoStep.tsx +++ b/client/web/src/pages/hacker/apply/steps/PersonalInfoStep.tsx @@ -8,6 +8,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { PhoneInput } from "@/components/ui/phone-input"; import { Select, SelectContent, @@ -16,6 +17,7 @@ import { SelectValue, } from "@/components/ui/select"; +import { SelectWithOther } from "../components/SelectWithOther"; import type { ApplicationFormData } from "../validations"; import { COUNTRY_OPTIONS, @@ -89,12 +91,13 @@ export function PersonalInfoStep({ userEmail }: PersonalInfoStepProps) { Phone Number * - + -

- Include country code (e.g., +1 for US) -

)} /> @@ -127,50 +130,41 @@ export function PersonalInfoStep({ userEmail }: PersonalInfoStepProps) { render={({ field }) => ( Country of Residence * - + + + )} /> - + ( Gender * - + + + )} /> + - + Which university do you attend? * - + @@ -50,9 +49,12 @@ export function SchoolInfoStep() { name="major" render={({ field }) => ( - What is your major? * + Field(s) of study * - + @@ -65,24 +67,19 @@ export function SchoolInfoStep() { render={({ field }) => ( Current level of study * - + + + )} />
); -} +} \ No newline at end of file From 89fb47a705b0fb5bf28a4c2f134512e194320314 Mon Sep 17 00:00:00 2001 From: jagadeep kalluri Date: Thu, 5 Mar 2026 19:44:34 -0600 Subject: [PATCH 2/2] style: run prettier formatting --- .gitignore | 1 + client/web/src/components/ui/phone-input.tsx | 25 ++- .../detail-sections/AgreementsSection.tsx | 6 +- .../components/ApplicationDetailsPanel.tsx | 6 +- client/web/src/pages/hacker/apply/api.ts | 3 +- .../apply/components/SelectWithOther.tsx | 6 +- .../apply/components/UniversityComboBox.tsx | 2 +- .../hacker/apply/steps/PersonalInfoStep.tsx | 3 +- .../hacker/apply/steps/SchoolInfoStep.tsx | 2 +- cmd/api/applications.go | 198 +++++++++--------- cmd/api/auth.go | 34 +-- cmd/api/resume.go | 74 +++---- cmd/api/scans.go | 98 ++++----- cmd/api/settings.go | 92 ++++---- cmd/api/superadmin_users.go | 58 ++--- 15 files changed, 311 insertions(+), 297 deletions(-) diff --git a/.gitignore b/.gitignore index 139d0b93..dcf67f29 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ vendor/ # Air live reload tmp/ +.air.toml # IDE .idea/ diff --git a/client/web/src/components/ui/phone-input.tsx b/client/web/src/components/ui/phone-input.tsx index c24fbccf..79a8df52 100644 --- a/client/web/src/components/ui/phone-input.tsx +++ b/client/web/src/components/ui/phone-input.tsx @@ -52,14 +52,17 @@ function formatPhoneDisplay(digits: string): string { } // Parse E.164 to extract country code and national number -function parseE164(e164: string): { countryCode: string; nationalNumber: string } { +function parseE164(e164: string): { + countryCode: string; + nationalNumber: string; +} { if (!e164 || !e164.startsWith("+")) { return { countryCode: "US", nationalNumber: "" }; } // Sort by dial code length (longest first) to match correctly const sortedCodes = [...COUNTRY_CODES].sort( - (a, b) => b.dialCode.length - a.dialCode.length + (a, b) => b.dialCode.length - a.dialCode.length, ); for (const country of sortedCodes) { @@ -92,10 +95,13 @@ function PhoneInput({ // Parse the E.164 value const parsed = parseE164(value); const [countryCode, setCountryCode] = React.useState(parsed.countryCode); - const [nationalNumber, setNationalNumber] = React.useState(parsed.nationalNumber); + const [nationalNumber, setNationalNumber] = React.useState( + parsed.nationalNumber, + ); // Get the dial code for selected country - const selectedCountry = COUNTRY_CODES.find((c) => c.code === countryCode) || COUNTRY_CODES[0]; + const selectedCountry = + COUNTRY_CODES.find((c) => c.code === countryCode) || COUNTRY_CODES[0]; // Sync when value prop changes externally React.useEffect(() => { @@ -106,7 +112,8 @@ function PhoneInput({ // Update the E.164 value when country or number changes const updateValue = (newCountryCode: string, newNationalNumber: string) => { - const country = COUNTRY_CODES.find((c) => c.code === newCountryCode) || COUNTRY_CODES[0]; + const country = + COUNTRY_CODES.find((c) => c.code === newCountryCode) || COUNTRY_CODES[0]; const digits = newNationalNumber.replace(/\D/g, ""); if (digits) { onChange?.(country.dialCode + digits); @@ -129,7 +136,11 @@ function PhoneInput({ return (
{/* Country Code Dropdown */} - {selectedCountry.code} {selectedCountry.dialCode} @@ -157,4 +168,4 @@ function PhoneInput({ ); } -export { PhoneInput }; \ No newline at end of file +export { PhoneInput }; diff --git a/client/web/src/pages/admin/all-applicants/components/detail-sections/AgreementsSection.tsx b/client/web/src/pages/admin/all-applicants/components/detail-sections/AgreementsSection.tsx index 52749305..6782cb84 100644 --- a/client/web/src/pages/admin/all-applicants/components/detail-sections/AgreementsSection.tsx +++ b/client/web/src/pages/admin/all-applicants/components/detail-sections/AgreementsSection.tsx @@ -26,7 +26,7 @@ function AgreementItem({ {label} @@ -39,7 +39,9 @@ function AgreementItem({ export function AgreementsSection({ application }: AgreementsSectionProps) { return (
-

Agreements & Acknowledgments

+

+ Agreements & Acknowledgments +

{label} @@ -316,7 +316,9 @@ export function ApplicationDetailsPanel({ {/* Agreements & Acknowledgments */}
-

Agreements & Acknowledgments

+

+ Agreements & Acknowledgments +

opt.value === value); - + // Determine initial state: show input if value exists but isn't a predefined option const getInitialOtherMode = () => { if (!value) return false; @@ -43,7 +43,7 @@ export function SelectWithOther({ const [isOtherMode, setIsOtherMode] = React.useState(getInitialOtherMode); const [customValue, setCustomValue] = React.useState( - value && !isPredefinedValue && value !== "other" ? value : "" + value && !isPredefinedValue && value !== "other" ? value : "", ); // Only sync from props on mount - intentionally empty deps to run once @@ -120,4 +120,4 @@ export function SelectWithOther({ )}
); -} \ No newline at end of file +} diff --git a/client/web/src/pages/hacker/apply/components/UniversityComboBox.tsx b/client/web/src/pages/hacker/apply/components/UniversityComboBox.tsx index 0cbb513c..07372f0d 100644 --- a/client/web/src/pages/hacker/apply/components/UniversityComboBox.tsx +++ b/client/web/src/pages/hacker/apply/components/UniversityComboBox.tsx @@ -152,4 +152,4 @@ export function UniversityCombobox({ ); -} \ No newline at end of file +} diff --git a/client/web/src/pages/hacker/apply/steps/PersonalInfoStep.tsx b/client/web/src/pages/hacker/apply/steps/PersonalInfoStep.tsx index 3a40e6ca..6d07a321 100644 --- a/client/web/src/pages/hacker/apply/steps/PersonalInfoStep.tsx +++ b/client/web/src/pages/hacker/apply/steps/PersonalInfoStep.tsx @@ -143,7 +143,7 @@ export function PersonalInfoStep({ userEmail }: PersonalInfoStepProps) { )} /> - + -
); -} \ No newline at end of file +} diff --git a/cmd/api/applications.go b/cmd/api/applications.go index 7418a813..a31035b4 100644 --- a/cmd/api/applications.go +++ b/cmd/api/applications.go @@ -56,16 +56,16 @@ type ApplicationWithQuestions struct { // getOrCreateApplicationHandler returns or creates the user's hackathon application // -// @Summary Get or create application -// @Description Returns the authenticated user's hackathon application. If no application exists, creates a new draft application. -// @Tags hackers -// @Accept json -// @Produce json -// @Success 200 {object} store.Application -// @Failure 401 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /applications/me [get] +// @Summary Get or create application +// @Description Returns the authenticated user's hackathon application. If no application exists, creates a new draft application. +// @Tags hackers +// @Accept json +// @Produce json +// @Success 200 {object} store.Application +// @Failure 401 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /applications/me [get] func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -117,19 +117,19 @@ func (app *application) getOrCreateApplicationHandler(w http.ResponseWriter, r * // updateApplicationHandler partially updates the authenticated user's application // -// @Summary Update application -// @Description Partially updates the authenticated user's application. Only fields included in the request body are updated. Application must be in draft status. -// @Tags hackers -// @Accept json -// @Produce json -// @Param application body UpdateApplicationPayload true "Fields to update" -// @Success 200 {object} store.Application -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 409 {object} object{error=string} "Application not in draft status" -// @Security CookieAuth -// @Router /applications/me [patch] +// @Summary Update application +// @Description Partially updates the authenticated user's application. Only fields included in the request body are updated. Application must be in draft status. +// @Tags hackers +// @Accept json +// @Produce json +// @Param application body UpdateApplicationPayload true "Fields to update" +// @Success 200 {object} store.Application +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 409 {object} object{error=string} "Application not in draft status" +// @Security CookieAuth +// @Router /applications/me [patch] func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -255,17 +255,17 @@ func (app *application) updateApplicationHandler(w http.ResponseWriter, r *http. // submitApplicationHandler submits the authenticated user's application for review // -// @Summary Submit application -// @Description Submits the authenticated user's application for review. All required fields must be filled and acknowledgments must be accepted. Application must be in draft status. -// @Tags hackers -// @Produce json -// @Success 200 {object} store.Application -// @Failure 400 {object} object{error=string} "Missing required fields" -// @Failure 401 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 409 {object} object{error=string} "Application not in draft status" -// @Security CookieAuth -// @Router /applications/me/submit [post] +// @Summary Submit application +// @Description Submits the authenticated user's application for review. All required fields must be filled and acknowledgments must be accepted. Application must be in draft status. +// @Tags hackers +// @Produce json +// @Success 200 {object} store.Application +// @Failure 400 {object} object{error=string} "Missing required fields" +// @Failure 401 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 409 {object} object{error=string} "Application not in draft status" +// @Security CookieAuth +// @Router /applications/me/submit [post] func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -392,16 +392,16 @@ func (app *application) submitApplicationHandler(w http.ResponseWriter, r *http. // getApplicationStatsHandler returns aggregated statistics for all applications // -// @Summary Get application stats (Admin) -// @Description Returns aggregated statistics for all applications -// @Tags admin/applications -// @Produce json -// @Success 200 {object} store.ApplicationStats -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/applications/stats [get] +// @Summary Get application stats (Admin) +// @Description Returns aggregated statistics for all applications +// @Tags admin/applications +// @Produce json +// @Success 200 {object} store.ApplicationStats +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/applications/stats [get] func (app *application) getApplicationStatsHandler(w http.ResponseWriter, r *http.Request) { stats, err := app.store.Application.GetStats(r.Context()) if err != nil { @@ -416,22 +416,22 @@ func (app *application) getApplicationStatsHandler(w http.ResponseWriter, r *htt // listApplicationsHandler lists all applications with cursor-based pagination // -// @Summary List applications (Admin) -// @Description Lists all applications with cursor-based pagination and optional status filter -// @Tags admin/applications -// @Produce json -// @Param cursor query string false "Pagination cursor" -// @Param status query string false "Filter by status (draft, submitted, accepted, rejected, waitlisted)" -// @Param limit query int false "Page size (default 50, max 100)" -// @Param direction query string false "Pagination direction: forward (default) or backward" -// @Param sort_by query string false "Sort column: created_at (default), accept_votes, reject_votes, waitlist_votes" -// @Success 200 {object} store.ApplicationListResult -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/applications [get] +// @Summary List applications (Admin) +// @Description Lists all applications with cursor-based pagination and optional status filter +// @Tags admin/applications +// @Produce json +// @Param cursor query string false "Pagination cursor" +// @Param status query string false "Filter by status (draft, submitted, accepted, rejected, waitlisted)" +// @Param limit query int false "Page size (default 50, max 100)" +// @Param direction query string false "Pagination direction: forward (default) or backward" +// @Param sort_by query string false "Sort column: created_at (default), accept_votes, reject_votes, waitlist_votes" +// @Success 200 {object} store.ApplicationListResult +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/applications [get] func (app *application) listApplicationsHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() @@ -540,21 +540,21 @@ type EmailListResponse struct { // setApplicationStatus sets the final status on an application // -// @Summary Set application status (Super Admin) -// @Description Sets the final status (accepted, rejected, or waitlisted) on an application -// @Tags superadmin/applications -// @Accept json -// @Produce json -// @Param applicationID path string true "Application ID" -// @Param status body SetStatusPayload true "New status" -// @Success 200 {object} ApplicationResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/applications/{applicationID}/status [patch] +// @Summary Set application status (Super Admin) +// @Description Sets the final status (accepted, rejected, or waitlisted) on an application +// @Tags superadmin/applications +// @Accept json +// @Produce json +// @Param applicationID path string true "Application ID" +// @Param status body SetStatusPayload true "New status" +// @Success 200 {object} ApplicationResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/applications/{applicationID}/status [patch] func (app *application) setApplicationStatus(w http.ResponseWriter, r *http.Request) { applicationID := chi.URLParam(r, "applicationID") if applicationID == "" { @@ -590,18 +590,18 @@ func (app *application) setApplicationStatus(w http.ResponseWriter, r *http.Requ // getApplication returns a single application by ID with embedded questions // -// @Summary Get application by ID (Admin) -// @Description Returns a single application by its ID with embedded short answer questions -// @Tags admin/applications -// @Produce json -// @Param applicationID path string true "Application ID" -// @Success 200 {object} ApplicationWithQuestions -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/applications/{applicationID} [get] +// @Summary Get application by ID (Admin) +// @Description Returns a single application by its ID with embedded short answer questions +// @Tags admin/applications +// @Produce json +// @Param applicationID path string true "Application ID" +// @Success 200 {object} ApplicationWithQuestions +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/applications/{applicationID} [get] func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { applicationID := chi.URLParam(r, "applicationID") if applicationID == "" { @@ -639,18 +639,18 @@ func (app *application) getApplication(w http.ResponseWriter, r *http.Request) { // getApplicantEmailsByStatusHandler returns applicant emails filtered by status // -// @Summary Get applicant emails by status (Super Admin) -// @Description Returns a list of applicant emails filtered by application status (accepted, rejected, or waitlisted) -// @Tags superadmin/applications -// @Produce json -// @Param status query string true "Application status (accepted, rejected, or waitlisted)" -// @Success 200 {object} EmailListResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/applications/emails [get] +// @Summary Get applicant emails by status (Super Admin) +// @Description Returns a list of applicant emails filtered by application status (accepted, rejected, or waitlisted) +// @Tags superadmin/applications +// @Produce json +// @Param status query string true "Application status (accepted, rejected, or waitlisted)" +// @Success 200 {object} EmailListResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/applications/emails [get] func (app *application) getApplicantEmailsByStatusHandler(w http.ResponseWriter, r *http.Request) { statusStr := r.URL.Query().Get("status") if statusStr == "" { diff --git a/cmd/api/auth.go b/cmd/api/auth.go index 94bfd0ca..0013bbd8 100644 --- a/cmd/api/auth.go +++ b/cmd/api/auth.go @@ -20,14 +20,14 @@ type UserResponse struct { // getCurrentUserHandler returns the authenticated user's profile // -// @Summary Get current user -// @Description Returns the authenticated user's profile -// @Tags auth -// @Accept json -// @Produce json -// @Success 200 {object} UserResponse -// @Failure 401 {object} object{error=string} -// @Router /auth/me [get] +// @Summary Get current user +// @Description Returns the authenticated user's profile +// @Tags auth +// @Accept json +// @Produce json +// @Success 200 {object} UserResponse +// @Failure 401 {object} object{error=string} +// @Router /auth/me [get] func (app *application) getCurrentUserHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -57,15 +57,15 @@ type CheckEmailResponse struct { // checkEmailAuthMethodHandler checks if an email is registered and returns the auth method // -// @Summary Check email auth method -// @Description Checks if an email is registered and returns the auth method used -// @Tags auth -// @Accept json -// @Produce json -// @Param email query string true "Email address to check" -// @Success 200 {object} CheckEmailResponse -// @Failure 400 {object} object{error=string} -// @Router /auth/check-email [get] +// @Summary Check email auth method +// @Description Checks if an email is registered and returns the auth method used +// @Tags auth +// @Accept json +// @Produce json +// @Param email query string true "Email address to check" +// @Success 200 {object} CheckEmailResponse +// @Failure 400 {object} object{error=string} +// @Router /auth/check-email [get] func (app *application) checkEmailAuthMethodHandler(w http.ResponseWriter, r *http.Request) { email := r.URL.Query().Get("email") if email == "" { diff --git a/cmd/api/resume.go b/cmd/api/resume.go index 78aa13a4..445e9fb5 100644 --- a/cmd/api/resume.go +++ b/cmd/api/resume.go @@ -24,18 +24,18 @@ type ResumeDownloadURLResponse struct { // generateResumeUploadURLHandler returns a signed upload URL for hacker resume uploads. // -// @Summary Generate resume upload URL -// @Description Generates a signed GCS upload URL for the authenticated user's resume. Application must be in draft status. -// @Tags hackers -// @Produce json -// @Success 200 {object} ResumeUploadURLResponse -// @Failure 401 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 409 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Failure 503 {object} object{error=string} -// @Security CookieAuth -// @Router /applications/me/resume-upload-url [post] +// @Summary Generate resume upload URL +// @Description Generates a signed GCS upload URL for the authenticated user's resume. Application must be in draft status. +// @Tags hackers +// @Produce json +// @Success 200 {object} ResumeUploadURLResponse +// @Failure 401 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 409 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Failure 503 {object} object{error=string} +// @Security CookieAuth +// @Router /applications/me/resume-upload-url [post] func (app *application) generateResumeUploadURLHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -88,17 +88,17 @@ func (app *application) generateResumeUploadURLHandler(w http.ResponseWriter, r // deleteResumeHandler removes the resume path from the draft application and best-effort deletes from GCS. // -// @Summary Delete resume -// @Description Deletes the resume reference from the authenticated user's draft application and best-effort deletes the object from GCS. -// @Tags hackers -// @Produce json -// @Success 200 {object} store.Application -// @Failure 401 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 409 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /applications/me/resume [delete] +// @Summary Delete resume +// @Description Deletes the resume reference from the authenticated user's draft application and best-effort deletes the object from GCS. +// @Tags hackers +// @Produce json +// @Success 200 {object} store.Application +// @Failure 401 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 409 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /applications/me/resume [delete] func (app *application) deleteResumeHandler(w http.ResponseWriter, r *http.Request) { user := getUserFromContext(r.Context()) if user == nil { @@ -147,20 +147,20 @@ func (app *application) deleteResumeHandler(w http.ResponseWriter, r *http.Reque // getResumeDownloadURLHandler returns a signed download URL for admin viewing. // -// @Summary Get resume download URL (Admin) -// @Description Generates a signed GCS download URL for an application's resume. -// @Tags admin/applications -// @Produce json -// @Param applicationID path string true "Application ID" -// @Success 200 {object} ResumeDownloadURLResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Failure 503 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/applications/{applicationID}/resume-url [get] +// @Summary Get resume download URL (Admin) +// @Description Generates a signed GCS download URL for an application's resume. +// @Tags admin/applications +// @Produce json +// @Param applicationID path string true "Application ID" +// @Success 200 {object} ResumeDownloadURLResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Failure 503 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/applications/{applicationID}/resume-url [get] func (app *application) getResumeDownloadURLHandler(w http.ResponseWriter, r *http.Request) { applicationID := chi.URLParam(r, "applicationID") if applicationID == "" { diff --git a/cmd/api/scans.go b/cmd/api/scans.go index a5074423..62064c20 100644 --- a/cmd/api/scans.go +++ b/cmd/api/scans.go @@ -55,20 +55,20 @@ func (app *application) getScanTypesHandler(w http.ResponseWriter, r *http.Reque // createScanHandler records a scan for a user // -// @Summary Create a scan (Admin) -// @Description Records a scan for a user. Validates scan type exists and is active. Non-check_in scans require the user to have checked in first. -// @Tags admin/scans -// @Accept json -// @Produce json -// @Param scan body CreateScanPayload true "Scan to create" -// @Success 201 {object} store.Scan -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 409 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/scans [post] +// @Summary Create a scan (Admin) +// @Description Records a scan for a user. Validates scan type exists and is active. Non-check_in scans require the user to have checked in first. +// @Tags admin/scans +// @Accept json +// @Produce json +// @Param scan body CreateScanPayload true "Scan to create" +// @Success 201 {object} store.Scan +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 409 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/scans [post] func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request) { var req CreateScanPayload if err := readJSON(w, r, &req); err != nil { @@ -155,18 +155,18 @@ func (app *application) createScanHandler(w http.ResponseWriter, r *http.Request // getUserScansHandler returns all scan records for a specified user // -// @Summary Get scans for a user (Admin) -// @Description Returns all scan records for the specified user, ordered by most recent first -// @Tags admin/scans -// @Produce json -// @Param userID path string true "User ID" -// @Success 200 {object} ScansResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/scans/user/{userID} [get] +// @Summary Get scans for a user (Admin) +// @Description Returns all scan records for the specified user, ordered by most recent first +// @Tags admin/scans +// @Produce json +// @Param userID path string true "User ID" +// @Success 200 {object} ScansResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/scans/user/{userID} [get] func (app *application) getUserScansHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if userID == "" { @@ -187,16 +187,16 @@ func (app *application) getUserScansHandler(w http.ResponseWriter, r *http.Reque // getScanStatsHandler returns aggregate scan counts grouped by scan type // -// @Summary Get scan statistics (Admin) -// @Description Returns aggregate scan counts grouped by scan type -// @Tags admin/scans -// @Produce json -// @Success 200 {object} ScanStatsResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /admin/scans/stats [get] +// @Summary Get scan statistics (Admin) +// @Description Returns aggregate scan counts grouped by scan type +// @Tags admin/scans +// @Produce json +// @Success 200 {object} ScanStatsResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /admin/scans/stats [get] func (app *application) getScanStatsHandler(w http.ResponseWriter, r *http.Request) { stats, err := app.store.Scans.GetStats(r.Context()) if err != nil { @@ -211,19 +211,19 @@ func (app *application) getScanStatsHandler(w http.ResponseWriter, r *http.Reque // updateScanTypesHandler replaces all scan types with the provided array // -// @Summary Update scan types (Super Admin) -// @Description Replaces all scan types with the provided array. Must include at least one check_in category type. Names must be unique. -// @Tags superadmin/settings -// @Accept json -// @Produce json -// @Param scan_types body UpdateScanTypesPayload true "Scan types to set" -// @Success 200 {object} ScanTypesResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/settings/scan-types [put] +// @Summary Update scan types (Super Admin) +// @Description Replaces all scan types with the provided array. Must include at least one check_in category type. Names must be unique. +// @Tags superadmin/settings +// @Accept json +// @Produce json +// @Param scan_types body UpdateScanTypesPayload true "Scan types to set" +// @Success 200 {object} ScanTypesResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/scan-types [put] func (app *application) updateScanTypesHandler(w http.ResponseWriter, r *http.Request) { var req UpdateScanTypesPayload if err := readJSON(w, r, &req); err != nil { diff --git a/cmd/api/settings.go b/cmd/api/settings.go index e7a08e0c..47008649 100644 --- a/cmd/api/settings.go +++ b/cmd/api/settings.go @@ -18,16 +18,16 @@ type ShortAnswerQuestionsResponse struct { // getShortAnswerQuestions returns all configurable short answer questions // -// @Summary Get short answer questions (Super Admin) -// @Description Returns all configurable short answer questions for hacker applications -// @Tags superadmin/settings -// @Produce json -// @Success 200 {object} ShortAnswerQuestionsResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/settings/saquestions [get] +// @Summary Get short answer questions (Super Admin) +// @Description Returns all configurable short answer questions for hacker applications +// @Tags superadmin/settings +// @Produce json +// @Success 200 {object} ShortAnswerQuestionsResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/saquestions [get] func (app *application) getShortAnswerQuestions(w http.ResponseWriter, r *http.Request) { questions, err := app.store.Settings.GetShortAnswerQuestions(r.Context()) if err != nil { @@ -46,19 +46,19 @@ func (app *application) getShortAnswerQuestions(w http.ResponseWriter, r *http.R // updateShortAnswerQuestions replaces all short answer questions // -// @Summary Update short answer questions (Super Admin) -// @Description Replaces all short answer questions with the provided array -// @Tags superadmin/settings -// @Accept json -// @Produce json -// @Param questions body UpdateShortAnswerQuestionsPayload true "Questions to set" -// @Success 200 {object} ShortAnswerQuestionsResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/settings/saquestions [put] +// @Summary Update short answer questions (Super Admin) +// @Description Replaces all short answer questions with the provided array +// @Tags superadmin/settings +// @Accept json +// @Produce json +// @Param questions body UpdateShortAnswerQuestionsPayload true "Questions to set" +// @Success 200 {object} ShortAnswerQuestionsResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/saquestions [put] func (app *application) updateShortAnswerQuestions(w http.ResponseWriter, r *http.Request) { var req UpdateShortAnswerQuestionsPayload if err := readJSON(w, r, &req); err != nil { @@ -103,16 +103,16 @@ type ReviewsPerAppResponse struct { // getReviewsPerApp returns the number of reviews required per application // -// @Summary Get reviews per application (Super Admin) -// @Description Returns the number of reviews required per application -// @Tags superadmin/settings -// @Produce json -// @Success 200 {object} ReviewsPerAppResponse -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/settings/reviews-per-app [get] +// @Summary Get reviews per application (Super Admin) +// @Description Returns the number of reviews required per application +// @Tags superadmin/settings +// @Produce json +// @Success 200 {object} ReviewsPerAppResponse +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/reviews-per-app [get] func (app *application) getReviewsPerApp(w http.ResponseWriter, r *http.Request) { count, err := app.store.Settings.GetReviewsPerApplication(r.Context()) if err != nil { @@ -131,19 +131,19 @@ func (app *application) getReviewsPerApp(w http.ResponseWriter, r *http.Request) // setReviewsPerApp sets the number of reviews required per application // -// @Summary Set reviews per application (Super Admin) -// @Description Sets the number of reviews required per application -// @Tags superadmin/settings -// @Accept json -// @Produce json -// @Param reviews_per_application body SetReviewsPerAppPayload true "Reviews per application value" -// @Success 200 {object} ReviewsPerAppResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/settings/reviews-per-app [post] +// @Summary Set reviews per application (Super Admin) +// @Description Sets the number of reviews required per application +// @Tags superadmin/settings +// @Accept json +// @Produce json +// @Param reviews_per_application body SetReviewsPerAppPayload true "Reviews per application value" +// @Success 200 {object} ReviewsPerAppResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/settings/reviews-per-app [post] func (app *application) setReviewsPerApp(w http.ResponseWriter, r *http.Request) { var req SetReviewsPerAppPayload if err := readJSON(w, r, &req); err != nil { diff --git a/cmd/api/superadmin_users.go b/cmd/api/superadmin_users.go index 995ce281..f0ec88b5 100644 --- a/cmd/api/superadmin_users.go +++ b/cmd/api/superadmin_users.go @@ -24,20 +24,20 @@ type UpdateRoleResponse struct { // searchUsersHandler searches users by email or name // -// @Summary Search users (Super Admin) -// @Description Searches users by email, first name, or last name using trigram matching -// @Tags superadmin/users -// @Produce json -// @Param search query string true "Search query (min 2 chars)" -// @Param limit query int false "Page size (default 20, max 100)" -// @Param offset query int false "Offset (default 0)" -// @Success 200 {object} UserSearchResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/users [get] +// @Summary Search users (Super Admin) +// @Description Searches users by email, first name, or last name using trigram matching +// @Tags superadmin/users +// @Produce json +// @Param search query string true "Search query (min 2 chars)" +// @Param limit query int false "Page size (default 20, max 100)" +// @Param offset query int false "Offset (default 0)" +// @Success 200 {object} UserSearchResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/users [get] func (app *application) searchUsersHandler(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() @@ -88,21 +88,21 @@ func (app *application) searchUsersHandler(w http.ResponseWriter, r *http.Reques // updateUserRoleHandler updates a user's role // -// @Summary Update user role (Super Admin) -// @Description Updates the role of a user by their ID -// @Tags superadmin/users -// @Accept json -// @Produce json -// @Param userID path string true "User ID" -// @Param role body UpdateRolePayload true "New role" -// @Success 200 {object} UserResponse -// @Failure 400 {object} object{error=string} -// @Failure 401 {object} object{error=string} -// @Failure 403 {object} object{error=string} -// @Failure 404 {object} object{error=string} -// @Failure 500 {object} object{error=string} -// @Security CookieAuth -// @Router /superadmin/users/{userID}/role [patch] +// @Summary Update user role (Super Admin) +// @Description Updates the role of a user by their ID +// @Tags superadmin/users +// @Accept json +// @Produce json +// @Param userID path string true "User ID" +// @Param role body UpdateRolePayload true "New role" +// @Success 200 {object} UserResponse +// @Failure 400 {object} object{error=string} +// @Failure 401 {object} object{error=string} +// @Failure 403 {object} object{error=string} +// @Failure 404 {object} object{error=string} +// @Failure 500 {object} object{error=string} +// @Security CookieAuth +// @Router /superadmin/users/{userID}/role [patch] func (app *application) updateUserRoleHandler(w http.ResponseWriter, r *http.Request) { userID := chi.URLParam(r, "userID") if userID == "" {