From abf077d79b065b58218a4ba189201423306259cb Mon Sep 17 00:00:00 2001 From: Marius Solaas Date: Fri, 26 Sep 2025 12:19:33 +0200 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8add=20prioritization=20of=20=C3=A5?= =?UTF-8?q?rskull=20to=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement priority registration date and time fields - Add validation for prioritized years (comma-separated format) - Update event models with new prioritizedRegistrationDate and prioritizedYears fields - Show priority registration info to eligible users in EventBody component - Add disabling of 'classof' field in user settings if a year already is set --- .../molecules/event/eventBody/EventBody.tsx | 175 ++++++++++++++++-- .../molecules/forms/eventForm/EventForm.tsx | 126 +++++++++++-- .../forms/settingsForm/SettingsForm.tsx | 1 + src/models/apiModels.ts | 11 ++ src/utils/validators.ts | 30 +++ 5 files changed, 306 insertions(+), 37 deletions(-) diff --git a/src/components/molecules/event/eventBody/EventBody.tsx b/src/components/molecules/event/eventBody/EventBody.tsx index b352fc92..85ed7d18 100644 --- a/src/components/molecules/event/eventBody/EventBody.tsx +++ b/src/components/molecules/event/eventBody/EventBody.tsx @@ -9,7 +9,12 @@ import ToggleButton from 'components/atoms/toggleButton/ToggleButton'; import { Text } from '@chakra-ui/react'; import { ListItem, List, ListIcon } from '@chakra-ui/react'; import { MdCheckCircle, MdBlock } from 'react-icons/md'; -import { getJoinedParticipants, updateEvent, uploadEventPicture } from 'api'; +import { + getJoinedParticipants, + updateEvent, + uploadEventPicture, + getMemberAssociatedWithToken, +} from 'api'; import { addressValidator, dateValidator, @@ -49,6 +54,10 @@ export const EditEvent: React.FC<{ event: Event; setEdit: () => void }> = ({ const [file, setFile] = useState(); const { addToast } = useToast(); + const prioritizedRegistrationTimeDate = event.prioritizedRegistrationDate + ? new Date(event.prioritizedRegistrationDate) + : undefined; + const initalValue = { title: event.title, description: event.description, @@ -62,6 +71,14 @@ export const EditEvent: React.FC<{ event: Event; setEdit: () => void }> = ({ ':' + registrationTimeDate.getMinutes() : '', + prioritizedRegisterDate: + event.prioritizedRegistrationDate?.split('T')[0] ?? '', + prioritizedRegisterTime: prioritizedRegistrationTimeDate + ? prioritizedRegistrationTimeDate.getHours() + + ':' + + prioritizedRegistrationTimeDate.getMinutes() + : '', + prioritizedYears: event.prioritizedYears?.join(',') ?? '', // maxParticipants is set to "" when event does not have maxParticipants // since "" is the init value for fields["maxParticipants"] maxParticipants: event.maxParticipants?.toString() ?? '', @@ -115,6 +132,29 @@ export const EditEvent: React.FC<{ event: Event; setEdit: () => void }> = ({ ? null : fields['registerDate'].value + ' ' + fields['registerTime'].value, }), + ...(fields['prioritizedRegisterDate']?.value + + 'T' + + fields['prioritizedRegisterTime']?.value !== + initalValue.prioritizedRegisterDate + + 'T' + + initalValue.prioritizedRegisterTime && { + prioritizedRegistrationDate: + fields['prioritizedRegisterDate'].value === '' + ? null + : fields['prioritizedRegisterDate'].value + + ' ' + + fields['prioritizedRegisterTime'].value, + }), + ...(fields['prioritizedYears']?.value !== + initalValue.prioritizedYears && { + prioritizedYears: + fields['prioritizedYears'].value === '' + ? null + : fields['prioritizedYears'].value + .split(',') + .map((year: string) => parseInt(year.trim())) + .filter((year: number) => !isNaN(year)), + }), ...(toggleFood !== event.food && { food: toggleFood, }), @@ -289,6 +329,28 @@ export const EditEvent: React.FC<{ event: Event; setEdit: () => void }> = ({ value={fields['registerTime'].value ?? ''} onChange={onFieldChange} /> + Prioritert registrering åpner: + + +
void }> = ({ ); }; -const validJoin = (role: RoleOptions, eventRegString: string | undefined) => { - if (eventRegString === undefined) { - return false; - } +const validJoin = (role: RoleOptions, event: Event, userClassof?: string) => { if (role === Roles.admin) { return true; } + const now = new Date(); - const openingDate = new Date(eventRegString); - return now >= openingDate; + + // Check if user is eligible for prioritized registration + if ( + userClassof && + event.prioritizedRegistrationDate && + event.prioritizedYears + ) { + const userYear = parseInt(userClassof); + const isPrioritizedUser = event.prioritizedYears.includes(userYear); + + if (isPrioritizedUser) { + const prioritizedDate = new Date(event.prioritizedRegistrationDate); + if (now >= prioritizedDate) { + return true; + } + } + } + + // Check regular registration date + if (event.registrationOpeningDate) { + const regularDate = new Date(event.registrationOpeningDate); + return now >= regularDate; + } + + return false; }; export const EventInfo: React.FC<{ event: Event; role: RoleOptions }> = ({ @@ -355,8 +438,9 @@ export const EventInfo: React.FC<{ event: Event; role: RoleOptions }> = ({ }) => { const [participantsHeader, setParticipantHeader] = useState(''); const [participantsText, setParticipantText] = useState(''); - const canJoinEvent = validJoin(role, event.registrationOpeningDate); + const [userClassof, setUserClassof] = useState(); const { authenticated } = useContext(AuthenticateContext); + const canJoinEvent = validJoin(role, event, userClassof); // sets correct header and text based on what the user should see function getParticipantsText( @@ -398,6 +482,22 @@ export const EventInfo: React.FC<{ event: Event; role: RoleOptions }> = ({ } }, [event.eid, event?.maxParticipants]); + useEffect(() => { + const fetchUserInfo = async () => { + if (authenticated && role !== Roles.admin) { + try { + const userInfo = await getMemberAssociatedWithToken(); + setUserClassof(userInfo.classof); + } catch (error) { + // If we can't get user info, user will see regular registration logic + setUserClassof(undefined); + } + } + }; + + fetchUserInfo(); + }, [authenticated, role]); + useEffect(() => { // only fetch participants list when the list is actually used // the list/number of participants should only be displayed for admins and events with no cap @@ -425,21 +525,60 @@ export const EventInfo: React.FC<{ event: Event; role: RoleOptions }> = ({ ) : (
- {event.registrationOpeningDate ? ( -

- {' '} - {transformDate(new Date(event.registrationOpeningDate))} -

+ {event.registrationOpeningDate || + event.prioritizedRegistrationDate ? ( +
+ {/* Show prioritized registration date if user is eligible */} + {userClassof && + event.prioritizedRegistrationDate && + event.prioritizedYears?.includes(parseInt(userClassof)) ? ( +
+ {event.registrationOpeningDate && ( +

+ {transformDate( + new Date(event.registrationOpeningDate) + )} +

+ )} + + For årskull {event.prioritizedYears.join(', ')}:{' '} + {transformDate( + new Date(event.prioritizedRegistrationDate) + )} + +
+ ) : event.registrationOpeningDate ? ( +

+ {transformDate(new Date(event.registrationOpeningDate))} +

+ ) : ( +

Påmelding kommer

+ )} +
) : (

Påmelding kommer

)}
)} - {role === 'admin' && event.registrationOpeningDate && ( - - Åpner for alle{' '} - {transformDate(new Date(event.registrationOpeningDate))} - + {role === 'admin' && ( +
+ {event.prioritizedRegistrationDate && event.prioritizedYears && ( + + Åpner for årskull {event.prioritizedYears.join(', ')}{' '} + {transformDate(new Date(event.prioritizedRegistrationDate))} + + )} + {event.registrationOpeningDate && ( + + Åpner for alle{' '} + {transformDate(new Date(event.registrationOpeningDate))} + + )} +
)}

Sted

{event.address} diff --git a/src/components/molecules/forms/eventForm/EventForm.tsx b/src/components/molecules/forms/eventForm/EventForm.tsx index 305daa9f..12fa4465 100644 --- a/src/components/molecules/forms/eventForm/EventForm.tsx +++ b/src/components/molecules/forms/eventForm/EventForm.tsx @@ -11,6 +11,7 @@ import { maxParticipantsValidator, PNGImageValidator, eventTitleValidator, + prioritizedYearsValidator, } from 'utils/validators'; import { Text } from '@chakra-ui/react'; import styles from './eventForm.module.scss'; @@ -45,9 +46,10 @@ const EventForm = () => { address: addressValidator, price: priceValidator, maxParticipants: maxParticipantsValidator, + prioritizedYears: prioritizedYearsValidator, }; - // allows maxParticipants to be empty - const optionalKeys = ['maxParticipants']; + // allows maxParticipants and prioritied years to be empty + const optionalKeys = ['maxParticipants', 'prioritizedYears']; const submit = async () => { const emptyFields = emptyFieldsValidator({ @@ -57,6 +59,49 @@ const EventForm = () => { emptyFields ? setError('Alle feltene må fylles ut') : setError(undefined); + // Validate prioritized registration date comes before general registration date + if ( + fields['prioritizedRegisterDate']?.value && + fields['registerDate']?.value + ) { + const prioritizedDateTime = new Date( + fields['prioritizedRegisterDate']?.value + + ' ' + + (fields['prioritizedRegisterTime']?.value || '00:00') + ); + const generalDateTime = new Date( + fields['registerDate']?.value + + ' ' + + (fields['registerTime']?.value || '00:00') + ); + + if (prioritizedDateTime >= generalDateTime) { + setError( + 'Prioritert påmeldingsdato må være før den generelle påmeldingsdatoen' + ); + return; + } + } + + // Validate that prioritized years are provided if prioritized date is set + if ( + fields['prioritizedRegisterDate']?.value && + !fields['prioritizedYears']?.value?.trim() + ) { + setError('Årskull må spesifiseres når prioritert påmeldingsdato er satt'); + return; + } + + if ( + fields['prioritizedYears']?.value?.trim() && + !fields['prioritizedRegisterDate']?.value + ) { + setError( + 'Prioritert påmeldingsdato må settes når årskull er spesifisert' + ); + return; + } + if (hasErrors || emptyFields) { return; } @@ -68,6 +113,23 @@ const EventForm = () => { regDate = fields['registerDate']?.value + ' ' + fields['registerTime']?.value; } + let prioritizedRegDate = undefined; + if ( + fields['prioritizedRegisterDate']?.value && + fields['prioritizedRegisterTime']?.value + ) { + prioritizedRegDate = + fields['prioritizedRegisterDate']?.value + + ' ' + + fields['prioritizedRegisterTime']?.value; + } + let parsedPrioritizedYears = undefined; + if (fields['prioritizedYears']?.value?.trim()) { + parsedPrioritizedYears = fields['prioritizedYears'].value + .split(',') + .map((year: string) => parseInt(year.trim())) + .filter((year: number) => !isNaN(year)); + } const resp = await createEvent({ title: fields['title']?.value, description: fields['description']?.value, @@ -80,6 +142,8 @@ const EventForm = () => { public: publicEvent, bindingRegistration: bindingRegistration, registrationOpeningDate: regDate, + prioritizedRegistrationDate: prioritizedRegDate, + prioritizedYears: parsedPrioritizedYears, }); setEid(resp.eid); if (file) { @@ -199,23 +263,47 @@ const EventForm = () => { -
- - -
+ style={{ width: '100%', padding: '20px' }}> + + +
+ + + +
= ({ init }) => { type="number" onChange={onFieldChange} error={fields['classof'].error} + disabled={!!init?.classof} /> {init?.phone ? ( <> diff --git a/src/models/apiModels.ts b/src/models/apiModels.ts index 2cd10d79..743e27d8 100644 --- a/src/models/apiModels.ts +++ b/src/models/apiModels.ts @@ -87,6 +87,8 @@ export interface Event { transportation: boolean; public: boolean; registrationOpeningDate?: string; + prioritizedRegistrationDate?: string; + prioritizedYears?: number[]; confirmed?: boolean; } @@ -101,6 +103,8 @@ export type EventUpdate = Partial< | 'maxParticipants' | 'public' | 'confirmed' + | 'prioritizedRegistrationDate' + | 'prioritizedYears' > >; export type CreateEvent = Omit; @@ -193,3 +197,10 @@ export interface ProductSuggestion { timestamp: Date; username: string; } + +// Priority feature models +export interface Priority { + id: string; + value: number; + type: string; +} diff --git a/src/utils/validators.ts b/src/utils/validators.ts index 3eb9bf95..e5d5eb33 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -240,3 +240,33 @@ export const JobTitleValidator = (title: string) => { export const PNGImageValidator = (file: File) => { return file.type !== 'image/png' ? 'Kun PNG filer er støttet' : undefined; }; + +export const prioritizedYearsValidator = ( + years: string +): string[] | undefined => { + if (!years.trim()) { + return undefined; // Optional field + } + + const yearArray = years.split(','); + const errors: string[] = []; + + for (const year of yearArray) { + const trimmedYear = year.trim(); + if (!numberValidator(trimmedYear)) { + errors.push('Alle årskull må være tall'); + break; + } + if (trimmedYear.length !== 4) { + errors.push('Årskull må være på formen YYYY'); + break; + } + const currentYear = new Date().getFullYear(); + if (Number(trimmedYear) > currentYear || Number(trimmedYear) < 1968) { + errors.push('Ikke godkjent årstall'); + break; + } + } + + return errors.length > 0 ? errors : undefined; +}; From 40c3d94163e5d07c678bfa7d5709275adae6828d Mon Sep 17 00:00:00 2001 From: Marius Solaas Date: Sun, 19 Oct 2025 17:44:32 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor=20inline=20st?= =?UTF-8?q?yles=20to=20CSS=20modules=20and=20enhance=20TextField?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract inline styles to CSS modules in EventBody and EventForm - Add placeholder prop support to TextField component - Fix TextField label positioning with placeholder - Improve DropdownHeader className handling - Fix placeholder opacity visibility in text.scss --- .../dropdown/dropdownHeader/DropdownHeader.tsx | 11 +++++++++-- src/components/atoms/textfield/Textfield.tsx | 9 ++++++++- .../molecules/event/eventBody/EventBody.tsx | 12 +++++------- .../molecules/event/eventBody/eventBody.module.scss | 13 +++++++++++++ .../molecules/forms/eventForm/EventForm.tsx | 4 ++-- .../molecules/forms/eventForm/eventForm.module.scss | 5 +++++ src/styles/text.scss | 1 - 7 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/components/atoms/dropdown/dropdownHeader/DropdownHeader.tsx b/src/components/atoms/dropdown/dropdownHeader/DropdownHeader.tsx index edbcdd4b..e4eac6bd 100644 --- a/src/components/atoms/dropdown/dropdownHeader/DropdownHeader.tsx +++ b/src/components/atoms/dropdown/dropdownHeader/DropdownHeader.tsx @@ -7,14 +7,21 @@ interface Props extends React.HtmlHTMLAttributes { title: string; } -const DropdownHeader: React.FC = ({ title, children, ...rest }) => { +const DropdownHeader: React.FC = ({ + title, + children, + className, + ...rest +}) => { const [expanded, setExpanded] = useState(false); const onExpand = () => setExpanded(!expanded); return (
-
+

{title}

diff --git a/src/components/atoms/textfield/Textfield.tsx b/src/components/atoms/textfield/Textfield.tsx index f19085de..0b2ffeef 100644 --- a/src/components/atoms/textfield/Textfield.tsx +++ b/src/components/atoms/textfield/Textfield.tsx @@ -21,13 +21,19 @@ const TextField: React.FC = ({ value, defaultValue, type, + placeholder, ...rest }) => { const [isFocused, setIsFocused] = useState(false); const [defaultInput, setDefaultInput] = useState(defaultValue); const styleLabel = () => { return ( - value || defaultInput || isFocused || type === 'date' || type === 'time' + value || + defaultInput || + isFocused || + type === 'date' || + type === 'time' || + placeholder ); }; @@ -60,6 +66,7 @@ const TextField: React.FC = ({ defaultValue={defaultValue} value={value} type={type} + placeholder={placeholder} onFocus={(e) => { setIsFocused(true); onFocus && onFocus(e); diff --git a/src/components/molecules/event/eventBody/EventBody.tsx b/src/components/molecules/event/eventBody/EventBody.tsx index 85ed7d18..5b50c2cc 100644 --- a/src/components/molecules/event/eventBody/EventBody.tsx +++ b/src/components/molecules/event/eventBody/EventBody.tsx @@ -534,7 +534,7 @@ export const EventInfo: React.FC<{ event: Event; role: RoleOptions }> = ({ event.prioritizedYears?.includes(parseInt(userClassof)) ? (
{event.registrationOpeningDate && ( -

+

{transformDate( new Date(event.registrationOpeningDate) )} @@ -542,7 +542,7 @@ export const EventInfo: React.FC<{ event: Event; role: RoleOptions }> = ({ )} + className={styles.prioritizedInfo}> For årskull {event.prioritizedYears.join(', ')}:{' '} {transformDate( new Date(event.prioritizedRegistrationDate) @@ -550,7 +550,7 @@ export const EventInfo: React.FC<{ event: Event; role: RoleOptions }> = ({

) : event.registrationOpeningDate ? ( -

+

{transformDate(new Date(event.registrationOpeningDate))}

) : ( @@ -565,15 +565,13 @@ export const EventInfo: React.FC<{ event: Event; role: RoleOptions }> = ({ {role === 'admin' && (
{event.prioritizedRegistrationDate && event.prioritizedYears && ( - + Åpner for årskull {event.prioritizedYears.join(', ')}{' '} {transformDate(new Date(event.prioritizedRegistrationDate))} )} {event.registrationOpeningDate && ( - + Åpner for alle{' '} {transformDate(new Date(event.registrationOpeningDate))} diff --git a/src/components/molecules/event/eventBody/eventBody.module.scss b/src/components/molecules/event/eventBody/eventBody.module.scss index 7774ebe7..ab333fc5 100644 --- a/src/components/molecules/event/eventBody/eventBody.module.scss +++ b/src/components/molecules/event/eventBody/eventBody.module.scss @@ -120,6 +120,19 @@ input[type='time']::-webkit-calendar-picker-indicator { gap: 0.25rem; } +.registrationDate { + font-weight: lighter; +} + +.prioritizedInfo { + font-style: italic; + color: #4caf50; +} + +.adminInfo { + font-style: italic; +} + @mixin mobile_base { .contentContainer { display: inline; diff --git a/src/components/molecules/forms/eventForm/EventForm.tsx b/src/components/molecules/forms/eventForm/EventForm.tsx index 12fa4465..9c7841c3 100644 --- a/src/components/molecules/forms/eventForm/EventForm.tsx +++ b/src/components/molecules/forms/eventForm/EventForm.tsx @@ -263,7 +263,7 @@ const EventForm = () => { + className={styles.dropdownWithPadding}> { + className={styles.dropdownWithPadding}>