-
Notifications
You must be signed in to change notification settings - Fork 7
#63 - Add Visual affordance for required photo upload on /setup page #67
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<NametagData>(data) | ||
|
|
@@ -212,7 +219,10 @@ export const Nametag = ({ | |
| </SaveButtonWrapper> | ||
| )} | ||
| <NametagLeft> | ||
| <PhotoFrame onClick={readOnly ? undefined : () => fileInputRef.current?.click()}> | ||
| <PhotoFrame | ||
| onClick={readOnly ? undefined : () => fileInputRef.current?.click()} | ||
| $error={validationErrors.profilePhoto} | ||
| > | ||
| {uploading ? ( | ||
| <PlaceholderAvatar>Loading...</PlaceholderAvatar> | ||
| ) : formData.profilePhoto ? ( | ||
|
|
@@ -228,6 +238,12 @@ export const Nametag = ({ | |
| </PhotoOverlay> | ||
| )} | ||
| </PhotoFrame> | ||
| {showRequiredAsterisks && ( | ||
| <PhotoRequiredLabel> | ||
| Profile Photo <RequiredAsterisk>*</RequiredAsterisk> | ||
| </PhotoRequiredLabel> | ||
| )} | ||
| {validationErrors.profilePhoto && <FieldError>Please upload a profile photo</FieldError>} | ||
| {!readOnly && ( | ||
| <input | ||
| type="file" | ||
|
|
@@ -241,9 +257,16 @@ export const Nametag = ({ | |
|
|
||
| <NametagRight> | ||
| <NametagInputGroup> | ||
| <NametagLabel>HELLO my name is</NametagLabel> | ||
| <NametagLabel> | ||
| HELLO my name is | ||
| {showRequiredAsterisks && <RequiredAsterisk> *</RequiredAsterisk>} | ||
| </NametagLabel> | ||
| <InputWithHelpContainer> | ||
| <NametagInputWrapper $fontSize="1.5rem" $fontWeight="700"> | ||
| <NametagInputWrapper | ||
| $fontSize="1.5rem" | ||
| $fontWeight="700" | ||
| $error={validationErrors.fullName} | ||
| > | ||
| <TextInput | ||
| variant="secondary" | ||
| size="default" | ||
|
|
@@ -256,10 +279,11 @@ export const Nametag = ({ | |
| } | ||
| }} | ||
| placeholder="Your Name" | ||
| required | ||
| error={validationErrors.fullName} | ||
| /> | ||
| </NametagInputWrapper> | ||
| </InputWithHelpContainer> | ||
| {validationErrors.fullName && <FieldError>Please enter your name</FieldError>} | ||
| </NametagInputGroup> | ||
|
|
||
| <NametagInputGroup> | ||
|
|
@@ -277,7 +301,6 @@ export const Nametag = ({ | |
| } | ||
| }} | ||
| placeholder="Title" | ||
| required | ||
| /> | ||
| </NametagInputWrapper> | ||
| <HelpInfoButton>Your job title or role.</HelpInfoButton> | ||
|
|
@@ -299,7 +322,6 @@ export const Nametag = ({ | |
| } | ||
| }} | ||
| placeholder="Affiliation" | ||
| required | ||
| /> | ||
| </NametagInputWrapper> | ||
| <HelpInfoButton>Your company, organization, or school name.</HelpInfoButton> | ||
|
|
@@ -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,27 +623,32 @@ 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); | ||
| } | ||
|
|
||
| &: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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider defining a color |
||
| font-size: 0.75rem; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. font-size here is not consistent with font-size in TextInput. |
||
| font-weight: 500; | ||
| margin-top: 0.5rem; | ||
| ` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,21 +9,26 @@ type BaseInputProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> | |
| interface TextInputProps extends BaseInputProps { | ||
| variant?: "primary" | "secondary" | ||
| size?: "small" | "default" | ||
| error?: boolean | ||
| } | ||
|
|
||
| // Components // | ||
|
|
||
| export const TextInput = forwardRef<HTMLInputElement, TextInputProps>( | ||
| ({ variant = "secondary", size = "small", ...props }, ref) => { | ||
| return <StyledInput ref={ref} $variant={variant} $size={size} {...props} /> | ||
| ({ variant = "secondary", size = "small", error = false, ...props }, ref) => { | ||
| return <StyledInput ref={ref} $variant={variant} $size={size} $error={error} {...props} /> | ||
| } | ||
| ) | ||
|
|
||
| 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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Consider replacing ternary to if statement |
||
| ? "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)" | ||
|
Comment on lines
+55
to
+58
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Might as well change the ternary here to an if statement while we're at it. |
||
| }}; | ||
| background-color: ${(props) => | ||
| props.$variant === "primary" ? "white" : "rgba(255, 255, 255, 0.05)"}; | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() { | |
| <Title>Welcome to DEVx</Title> | ||
| </HeaderRow> | ||
|
|
||
| <Form onSubmit={handleFinishSetup}> | ||
| <Form onSubmit={handleFinishSetup} noValidate> | ||
| <Section> | ||
| <SectionTitle>Choose a handle</SectionTitle> | ||
| <SectionTitle> | ||
| Choose a handle <RequiredAsterisk>*</RequiredAsterisk> | ||
| </SectionTitle> | ||
| <HandleInputWrapper> | ||
| <HandleInputRow> | ||
| <TextInput | ||
| variant="secondary" | ||
| size="default" | ||
| value={handle} | ||
| onChange={(e) => 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 })) | ||
| } | ||
| }} | ||
|
Comment on lines
+321
to
+327
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Move this to a |
||
| placeholder="your-handle" | ||
| required | ||
| pattern="(?:[a-z0-9_]|-){3,30}" | ||
| minLength={3} | ||
| maxLength={30} | ||
| error={hasAttemptedSubmit && validationErrors.handle} | ||
| /> | ||
| <HelpInfoButton minWidth="220px" maxWidth="260px"> | ||
| Your unique DEVx username, used for your nametag or public profile. | ||
|
|
@@ -327,6 +347,9 @@ export default function Setup() { | |
| </HandleStatus> | ||
| )} | ||
| </HandleInputWrapper> | ||
| {hasAttemptedSubmit && validationErrors.handle && ( | ||
| <FieldError>Please choose a valid handle</FieldError> | ||
| )} | ||
| <HelpText> | ||
| 3-30 characters, lowercase letters, numbers, underscores, and hyphens only | ||
| </HelpText> | ||
|
|
@@ -337,27 +360,37 @@ export default function Setup() { | |
| <Nametag | ||
| data={nametagData} | ||
| onSave={async () => {}} | ||
| 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 | ||
| }} | ||
|
Comment on lines
+363
to
+370
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of inlining a new handler function, move your implementation to the handleImageUpload handler. Rework your changes into that existing handler. |
||
| 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() | ||
| })) | ||
| } | ||
| }} | ||
|
Comment on lines
+373
to
+383
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of inlining a new handler function, move your implementation to the a handleDataChange handler. |
||
| validationErrors={{ | ||
| profilePhoto: hasAttemptedSubmit && validationErrors.profilePhoto, | ||
| fullName: hasAttemptedSubmit && validationErrors.fullName | ||
| }} | ||
| showRequiredAsterisks={true} | ||
| /> | ||
| </Section> | ||
|
|
||
| <ButtonWrapper> | ||
| <Button | ||
| type="submit" | ||
| variant="primary" | ||
| size="default" | ||
| disabled={ | ||
| saving || | ||
| uploading || | ||
| !handle.trim() || | ||
| !handleAvailable || | ||
| !nametagData.profilePhoto || | ||
| !nametagData.fullName.trim() | ||
| } | ||
| > | ||
| <Button type="submit" variant="primary" size="default" disabled={saving || uploading}> | ||
| {saving ? "Creating Profile..." : "Finish Setup"} | ||
| </Button> | ||
| </ButtonWrapper> | ||
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. font-size 0.75rem to be consistent with the other FieldError in the nametag? |
||
| font-weight: 500; | ||
| margin: 0.5rem 0 0 0; | ||
| ` | ||
|
|
||
| const ButtonWrapper = styled.div` | ||
| display: flex; | ||
| justify-content: center; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Define a
handlePhotoFrameClickhandler function and implement the logic there.