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()}
$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;
font-size: 0.75rem;
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"
? "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)"};
}
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 }))
}
}}
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
}}
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}
/>
</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;
font-weight: 500;
margin: 0.5rem 0 0 0;
`

const ButtonWrapper = styled.div`
display: flex;
justify-content: center;
Expand Down