From 67d1f7a14d7c30d9dced048a7002e8505c57cd81 Mon Sep 17 00:00:00 2001 From: Andrew Nguyen Date: Tue, 6 Jan 2026 01:54:25 -0800 Subject: [PATCH] Visual Affordance Changes --- app/components/Nametag.tsx | 72 +++++++++++++++++++++++----- app/components/TextInput.tsx | 28 ++++++++--- app/setup/page.tsx | 92 +++++++++++++++++++++++++++--------- 3 files changed, 150 insertions(+), 42 deletions(-) diff --git a/app/components/Nametag.tsx b/app/components/Nametag.tsx index 19b9246..9700900 100644 --- a/app/components/Nametag.tsx +++ b/app/components/Nametag.tsx @@ -23,6 +23,11 @@ type NametagProps = { forcedEditMode?: boolean onDataChange?: (data: NametagData) => void readOnly?: boolean + validationErrors?: { + profilePhoto?: boolean + fullName?: boolean + } + showRequiredAsterisks?: boolean } export const Nametag = ({ @@ -34,7 +39,9 @@ export const Nametag = ({ initialEditing = false, forcedEditMode = false, onDataChange, - readOnly = false + readOnly = false, + validationErrors = {}, + showRequiredAsterisks = false }: NametagProps) => { const [isEditing, setIsEditing] = useState(initialEditing || forcedEditMode) const [formData, setFormData] = useState(data) @@ -212,7 +219,10 @@ export const Nametag = ({ )} - fileInputRef.current?.click()}> + fileInputRef.current?.click()} + $error={validationErrors.profilePhoto} + > {uploading ? ( Loading... ) : formData.profilePhoto ? ( @@ -228,6 +238,12 @@ export const Nametag = ({ )} + {showRequiredAsterisks && ( + + Profile Photo * + + )} + {validationErrors.profilePhoto && Please upload a profile photo} {!readOnly && ( - HELLO my name is + + HELLO my name is + {showRequiredAsterisks && *} + - + + {validationErrors.fullName && Please enter your name} @@ -277,7 +301,6 @@ export const Nametag = ({ } }} placeholder="Title" - required /> Your job title or role. @@ -299,7 +322,6 @@ export const Nametag = ({ } }} placeholder="Affiliation" - required /> Your company, organization, or school name. @@ -533,16 +555,17 @@ const PhotoOverlay = styled.div` pointer-events: none; ` -const PhotoFrame = styled.div` +const PhotoFrame = styled.div<{ $error?: boolean }>` width: 120px; height: 120px; border-radius: 8px; overflow: hidden; background-color: rgba(255, 255, 255, 0.1); - border: 2px solid rgba(255, 255, 255, 0.3); + border: 2px solid ${(props) => (props.$error ? "#f87171" : "rgba(255, 255, 255, 0.3)")}; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5); cursor: pointer; position: relative; + transition: border-color 0.2s ease; &:hover ${PhotoOverlay} { opacity: 1; @@ -600,19 +623,24 @@ const InputWithHelpContainer = styled.div` position: relative; ` -const NametagInputWrapper = styled.div<{ $fontSize?: string; $fontWeight?: string }>` +const NametagInputWrapper = styled.div<{ + $fontSize?: string + $fontWeight?: string + $error?: boolean +}>` flex: 1; width: 100%; input { background: transparent; border: none; - border-bottom: 2px solid rgba(255, 255, 255, 0.2); + border-bottom: 2px solid ${(props) => (props.$error ? "#f87171" : "rgba(255, 255, 255, 0.2)")}; padding: 0.25rem 0; font-size: ${(props) => props.$fontSize || "1rem"}; font-weight: ${(props) => props.$fontWeight || "normal"}; color: rgba(255, 255, 255, 0.95); width: 100%; + transition: border-bottom-color 0.2s ease; &::placeholder { color: rgba(255, 255, 255, 0.5); @@ -620,7 +648,7 @@ const NametagInputWrapper = styled.div<{ $fontSize?: string; $fontWeight?: strin &:focus { outline: none; - border-bottom-color: rgba(156, 163, 255, 0.8); + border-bottom-color: ${(props) => (props.$error ? "#f87171" : "rgba(156, 163, 255, 0.8)")}; background: rgba(255, 255, 255, 0.05); } } @@ -632,3 +660,23 @@ const NametagDisplayText = styled.div<{ $fontSize?: string; $fontWeight?: string color: rgba(255, 255, 255, 0.95); padding: 0.25rem 0; ` + +const RequiredAsterisk = styled.span` + color: #f87171; + font-weight: 700; +` + +const PhotoRequiredLabel = styled.div` + color: rgba(255, 255, 255, 0.7); + font-size: 0.75rem; + font-weight: 500; + text-align: center; + margin-top: 0.5rem; +` + +const FieldError = styled.p` + color: #f87171; + font-size: 0.75rem; + font-weight: 500; + margin-top: 0.5rem; +` diff --git a/app/components/TextInput.tsx b/app/components/TextInput.tsx index 10fec38..da36fcc 100644 --- a/app/components/TextInput.tsx +++ b/app/components/TextInput.tsx @@ -9,13 +9,14 @@ type BaseInputProps = Omit, "size"> interface TextInputProps extends BaseInputProps { variant?: "primary" | "secondary" size?: "small" | "default" + error?: boolean } // Components // export const TextInput = forwardRef( - ({ variant = "secondary", size = "small", ...props }, ref) => { - return + ({ variant = "secondary", size = "small", error = false, ...props }, ref) => { + return } ) @@ -23,7 +24,11 @@ TextInput.displayName = "TextInput" // Styled Components // -const StyledInput = styled.input<{ $variant: "primary" | "secondary"; $size: "small" | "default" }>` +const StyledInput = styled.input<{ + $variant: "primary" | "secondary" + $size: "small" | "default" + $error?: boolean +}>` padding: ${(props) => (props.$size === "small" ? "0.5rem 1rem" : "0.75rem 1.5rem")}; border-radius: 0.25rem; font-weight: ${(props) => (props.$size === "small" ? "500" : "600")}; @@ -35,14 +40,23 @@ const StyledInput = styled.input<{ $variant: "primary" | "secondary"; $size: "sm box-sizing: border-box; background-color: ${(props) => (props.$variant === "primary" ? "white" : "transparent")}; color: ${(props) => (props.$variant === "primary" ? "black" : "white")}; - border: ${(props) => - props.$variant === "secondary" + border: ${(props) => { + if (props.$error) { + return "1px solid #f87171" + } + return props.$variant === "secondary" ? "1px solid rgba(255, 255, 255, 0.3)" - : "1px solid rgba(0, 0, 0, 0.2)"}; + : "1px solid rgba(0, 0, 0, 0.2)" + }}; &:focus { outline: none; - border-color: ${(props) => (props.$variant === "secondary" ? "white" : "rgba(0, 0, 0, 0.4)")}; + border-color: ${(props) => { + if (props.$error) { + return "#f87171" + } + return props.$variant === "secondary" ? "white" : "rgba(0, 0, 0, 0.4)" + }}; background-color: ${(props) => props.$variant === "primary" ? "white" : "rgba(255, 255, 255, 0.05)"}; } diff --git a/app/setup/page.tsx b/app/setup/page.tsx index 8d29638..15fbe6c 100644 --- a/app/setup/page.tsx +++ b/app/setup/page.tsx @@ -30,6 +30,12 @@ export default function Setup() { affiliation: "", profilePhoto: "" }) + const [validationErrors, setValidationErrors] = useState({ + handle: false, + profilePhoto: false, + fullName: false + }) + const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false) const router = useRouter() useEffect(() => { @@ -184,13 +190,19 @@ export default function Setup() { const handleFinishSetup = async (e: React.FormEvent) => { e.preventDefault() + setHasAttemptedSubmit(true) - // Validate form - if (!handle.trim() || !handleAvailable) { - return + // Validate form and set error states + const errors = { + handle: !handle.trim() || !handleAvailable, + profilePhoto: !nametagData.profilePhoto, + fullName: !nametagData.fullName.trim() } - if (!nametagData.profilePhoto || !nametagData.fullName.trim()) { + setValidationErrors(errors) + + // If there are any errors, don't proceed + if (errors.handle || errors.profilePhoto || errors.fullName) { return } @@ -295,21 +307,29 @@ export default function Setup() { Welcome to DEVx -
+
- Choose a handle + + Choose a handle * + setHandle(e.target.value.toLowerCase())} + onChange={(e) => { + setHandle(e.target.value.toLowerCase()) + // Clear error when user starts typing + if (hasAttemptedSubmit && validationErrors.handle) { + setValidationErrors((prev) => ({ ...prev, handle: false })) + } + }} placeholder="your-handle" - required pattern="(?:[a-z0-9_]|-){3,30}" minLength={3} maxLength={30} + error={hasAttemptedSubmit && validationErrors.handle} /> Your unique DEVx username, used for your nametag or public profile. @@ -327,6 +347,9 @@ export default function Setup() { )} + {hasAttemptedSubmit && validationErrors.handle && ( + Please choose a valid handle + )} 3-30 characters, lowercase letters, numbers, underscores, and hyphens only @@ -337,27 +360,37 @@ export default function Setup() { {}} - onImageUpload={handleImageUpload} + onImageUpload={async (file) => { + const url = await handleImageUpload(file) + // Clear photo error immediately when photo is uploaded + if (hasAttemptedSubmit && validationErrors.profilePhoto) { + setValidationErrors((prev) => ({ ...prev, profilePhoto: false })) + } + return url + }} uploading={uploading} forcedEditMode={true} - onDataChange={setNametagData} + onDataChange={(data) => { + setNametagData(data) + // Clear errors when user provides valid data + if (hasAttemptedSubmit) { + setValidationErrors((prev) => ({ + ...prev, + profilePhoto: !data.profilePhoto, + fullName: !data.fullName.trim() + })) + } + }} + validationErrors={{ + profilePhoto: hasAttemptedSubmit && validationErrors.profilePhoto, + fullName: hasAttemptedSubmit && validationErrors.fullName + }} + showRequiredAsterisks={true} />
- @@ -470,6 +503,19 @@ const HelpText = styled.p` margin: 0; ` +const RequiredAsterisk = styled.span` + color: #f87171; + font-weight: 700; + margin-left: 0.25rem; +` + +const FieldError = styled.p` + color: #f87171; + font-size: 0.875rem; + font-weight: 500; + margin: 0.5rem 0 0 0; +` + const ButtonWrapper = styled.div` display: flex; justify-content: center;