Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 60 additions & 12 deletions app/components/Nametag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ type NametagProps = {
forcedEditMode?: boolean
onDataChange?: (data: NametagData) => void
readOnly?: boolean
validationErrors?: {
profilePhoto?: boolean
fullName?: boolean
}
showRequiredAsterisks?: boolean
}

export const Nametag = ({
Expand All @@ -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)
Expand Down Expand Up @@ -212,7 +219,10 @@ export const Nametag = ({
</SaveButtonWrapper>
)}
<NametagLeft>
<PhotoFrame onClick={readOnly ? undefined : () => fileInputRef.current?.click()}>
<PhotoFrame
onClick={readOnly ? undefined : () => fileInputRef.current?.click()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Define a handlePhotoFrameClick handler function and implement the logic there.

$error={validationErrors.profilePhoto}
>
{uploading ? (
<PlaceholderAvatar>Loading...</PlaceholderAvatar>
) : formData.profilePhoto ? (
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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>
Expand All @@ -277,7 +301,6 @@ export const Nametag = ({
}
}}
placeholder="Title"
required
/>
</NametagInputWrapper>
<HelpInfoButton>Your job title or role.</HelpInfoButton>
Expand All @@ -299,7 +322,6 @@ export const Nametag = ({
}
}}
placeholder="Affiliation"
required
/>
</NametagInputWrapper>
<HelpInfoButton>Your company, organization, or school name.</HelpInfoButton>
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
Expand All @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider defining a color --error-color: #f87171 in :root declaration in globals.css. This way we have a consistent color as part of our theming.

font-size: 0.75rem;
Copy link
Contributor

Choose a reason for hiding this comment

The 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;
`
28 changes: 21 additions & 7 deletions app/components/TextInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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")};
Expand All @@ -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"
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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)"};
}
Expand Down
92 changes: 69 additions & 23 deletions app/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to a handleHandleChange. Hah, that's a funny name. Typical naming convention for handler functions is handle[semantic][event], in this case the semantic is "handle" because that's what we call the username, and the event is "Change" because it's for "onChange". Hence the weird sounding name.

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.
Expand All @@ -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>
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The 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>
Expand Down Expand Up @@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The 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;
Expand Down