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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

@use 'src/constants' as *;

@use 'src/styles/colors' as c;

.header {
font-size: 2em;
font-weight: 700;
Expand Down Expand Up @@ -87,3 +89,84 @@
padding-bottom: 3.75em;
gap: 0.5em;
}

.template_search_wrapper {
flex: 0 0 50%;
width: 50%;

@include for-tablet-down {
flex: 0 0 100%;
width: 100%;
}
}

.create_from_existing_event {
position: relative;
}

.search_results {
position: absolute;
top: calc(100% + 0.4rem);
width: 100%;
z-index: 20;
border: 1px solid c.$gray-200;
border-radius: 6px;
background: white;
max-height: 220px;
overflow-y: auto;
box-shadow: 0 4px 12px rgb(0 0 0 / 12%);
}

.search_result_item {
width: 100%;
text-align: left;
padding: 0.75rem 1rem;
border: none;
background: white;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.2rem;
}

.search_result_item:not(:last-child) {
border-bottom: 1px solid c.$gray-100;
}

.search_result_item:hover {
background: c.$white-950
}

.search_result_title {
font-weight: 600;
}

.search_result_meta {
font-size: 0.9rem;
color: c.$gray-600;
}

.selected_event_info {
margin-top: 0.4rem;
padding: 0.55rem 0.75rem;
border: 1px solid c.$green-100;
border-radius: 6px;
background: c.$green-200;
display: flex;
align-items: center;
gap: 0.5rem;
box-sizing: border-box;
}

.selected_event_text {
font-size: 0.9rem;
color: c.$gray-950;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.selected_event_icon {
color: $green;
flex-shrink: 0;
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,14 @@ export function EventCreatorAdminPage() {
});

const stepComponentMap: Record<StepKey, ReactElement> = {
text: <TextStep form={form} />,
text: (
<TextStep
form={form}
defaultCategory={eventCategoryOptions[0]?.value ?? EventCategory.ART}
defaultLocation={locationOptions[0]?.value ?? ''}
isEditMode={id !== undefined}
/>
),
info: <InfoStep form={form} eventCategoryOptions={eventCategoryOptions} locationOptions={locationOptions} />,
payment: <PaymentStep form={form} ageLimitOptions={ageLimitOptions} />,
graphics: <GraphicsStep form={form} />,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Icon } from '@iconify/react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InputField } from '~/Components/InputField/InputField';
import type { EventDto } from '~/dto';
import { KEY } from '~/i18n/constants';
import { dbT } from '~/utils';
import styles from '../EventCreatorAdminPage.module.scss';

type EventTemplateSearchProp = {
events: EventDto[];
onSelectEvent: (event: EventDto) => void;
};

export function EventTemplateSearch({ events, onSelectEvent }: EventTemplateSearchProp) {
const { t, i18n } = useTranslation();
const [query, setQuery] = useState('');
const [selectedEvent, SetSelectedEvent] = useState<EventDto | null>(null);
const filteredEvents = useMemo(() => {
const normalizedSearch = query.trim().toLowerCase();
if (normalizedSearch === '') return [];
const keywords = normalizedSearch.split(/\s+/);
return events.filter((event) => {
const title = (dbT(event, 'title', i18n.language) as string)?.toLowerCase() ?? '';
return keywords.every((kw) => title.includes(kw));
});
}, [events, query, i18n.language]);
const showResults = query.trim() !== '' && selectedEvent === null && filteredEvents.length > 0;
// TODO: add translations

function handleChange(value: string) {
setQuery(value);

const selectedTitle = selectedEvent ? (dbT(selectedEvent, 'title', i18n.language) as string) ?? '' : '';
if (value !== selectedTitle) {
SetSelectedEvent(null);
}
}

return (
<div className={styles.create_from_existing_event}>
<InputField
icon="mdi:search"
onChange={handleChange}
value={query}
placeholder={t(KEY.event_search_for_an_existing_event)}
/>
{selectedEvent && (
<div className={styles.selected_event_info}>
<Icon icon="material-symbols:check-circle" className={styles.selected_event_icon} />
<span className={styles.selected_event_text}>
{t(KEY.event_selected_existing_event)}: <strong>{dbT(selectedEvent, 'title', i18n.language)}</strong>{' '}
{new Intl.DateTimeFormat('nb-NO', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(new Date(selectedEvent.start_dt))}
</span>
</div>
)}
{showResults && (
<div className={styles.search_results}>
{filteredEvents.map((e) => (
<button
key={e.id}
type="button"
className={styles.search_result_item}
onClick={() => {
onSelectEvent(e);
SetSelectedEvent(e);
setQuery((dbT(e, 'title', i18n.language) as string) ?? '');
}}
>
<span className={styles.search_result_title}>{dbT(e, 'title', i18n.language)}</span>
<span className={styles.search_result_meta}>
{new Date(e.start_dt).toLocaleDateString(i18n.language)}
</span>
</button>
))}
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type { z } from 'zod';

import type { EventDto, EventWriteDto } from '~/dto';
import type { EventCategoryValue } from '~/types';
import { utcTimestampToLocal } from '~/utils';
import { eventSchema } from '../EventCreatorSchema';
import { mapEventToFormValues } from '../utils';

export type FormType = z.infer<typeof eventSchema>;

Expand Down Expand Up @@ -54,30 +54,11 @@ export function useEventCreatorForm(params: {
const resetValues = useMemo<FormType | undefined>(() => {
if (!event?.id) return undefined;

const duration = computeDurationMinutes(event.start_dt, event.end_dt);

return {
title_nb: event.title_nb || '',
title_en: event.title_en || '',
description_long_nb: event.description_long_nb || '',
description_long_en: event.description_long_en || '',
description_short_nb: event.description_short_nb || '',
description_short_en: event.description_short_en || '',
start_dt: event.start_dt ? utcTimestampToLocal(event.start_dt, false) : '',
duration: duration || undefined,
end_dt: event.end_dt ? utcTimestampToLocal(event.end_dt, false) : '',
category: event.category || defaultCategory,
host: event.host || '',
location: event.location || defaultLocation,
capacity: event.capacity || undefined,
age_restriction: event.age_restriction || 'none',
ticket_type: event.ticket_type || 'free',
custom_tickets: event.custom_tickets || [],
billig_id: event.billig?.id,
image: event.image ?? undefined,
visibility_from_dt: event.visibility_from_dt ? utcTimestampToLocal(event.visibility_from_dt, false) : '',
visibility_to_dt: event.visibility_to_dt ? utcTimestampToLocal(event.visibility_to_dt, false) : '',
};
return mapEventToFormValues({
event,
defaultCategory,
defaultLocation,
});
}, [event, defaultCategory, defaultLocation]);

useEffect(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,52 @@
import { useQuery } from '@tanstack/react-query';
import type { UseFormReturn } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { FormControl, FormField, FormItem, FormLabel, FormMessage, Input, Textarea } from '~/Components';
import { getEvents } from '~/api';
import type { EventDto } from '~/dto';
import { KEY } from '~/i18n/constants';
import type { EventCategoryValue } from '~/types';
import styles from '../EventCreatorAdminPage.module.scss';
import { EventTemplateSearch } from '../components/EventTemplateSearch';
import type { FormType } from '../hooks/useEventCreatorForm';
import { mapEventToFormValues } from '../utils';

export function TextStep({ form }: { form: UseFormReturn<FormType> }) {
type Props = {
form: UseFormReturn<FormType>;
defaultCategory: EventCategoryValue;
defaultLocation: string;
isEditMode: boolean;
};

export function TextStep({ form, defaultCategory, defaultLocation, isEditMode }: Props) {
const { t } = useTranslation();
const { data: events = [] } = useQuery({
queryKey: ['events'],
queryFn: getEvents,
enabled: !isEditMode,
});

function handleSelectedEvent(selectedEvent: EventDto) {
form.reset(
mapEventToFormValues({
event: selectedEvent,
defaultCategory,
defaultLocation,
forTemplate: true,
}),
);
}

return (
<>
{!isEditMode && (
<div className={styles.input_row}>
<div className={styles.template_search_wrapper}>
<label className={styles.form_label}>{t(KEY.event_create_from_existing_event)}</label>
<EventTemplateSearch events={events} onSelectEvent={handleSelectedEvent} />
</div>
</div>
)}
<div className={styles.input_row}>
<FormField
key="title_nb"
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/PagesAdmin/EventCreatorAdminPage/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { EventDto } from '~/dto';
import type { EventCategoryValue } from '~/types';
import { utcTimestampToLocal } from '~/utils';
import type { FormType } from './hooks/useEventCreatorForm';

function computeDurationMinutes(startIso?: string, endIso?: string) {
if (!startIso || !endIso) return 0;
return Math.round((new Date(endIso).getTime() - new Date(startIso).getTime()) / 60000);
}

export function mapEventToFormValues(params: {
event: Partial<EventDto>;
defaultCategory: EventCategoryValue;
defaultLocation: string;
forTemplate?: boolean;
}): FormType {
const { event, defaultCategory, defaultLocation, forTemplate = false } = params;

const duration = computeDurationMinutes(event.start_dt, event.end_dt);

return {
title_nb: event.title_nb || '',
title_en: event.title_en || '',
description_long_nb: event.description_long_nb || '',
description_long_en: event.description_long_en || '',
description_short_nb: event.description_short_nb || '',
description_short_en: event.description_short_en || '',

start_dt: forTemplate ? '' : event.start_dt ? utcTimestampToLocal(event.start_dt, false) : '',
duration: forTemplate ? undefined : duration || undefined,
end_dt: forTemplate ? '' : event.end_dt ? utcTimestampToLocal(event.end_dt, false) : '',

category: event.category || defaultCategory,
host: event.host || '',
location: event.location || defaultLocation,
capacity: event.capacity || undefined,
age_restriction: event.age_restriction || 'none',
ticket_type: event.ticket_type || 'free',
custom_tickets: event.custom_tickets || [],
billig_id: event.billig?.id,
image: event.image ?? undefined,

visibility_from_dt: forTemplate
? ''
: event.visibility_from_dt
? utcTimestampToLocal(event.visibility_from_dt, false)
: '',
visibility_to_dt: forTemplate
? ''
: event.visibility_to_dt
? utcTimestampToLocal(event.visibility_to_dt, false)
: '',
};
}
3 changes: 3 additions & 0 deletions frontend/src/i18n/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ export const KEY = {
event_registration_url: 'event_registration_url',
event_add_ticket: 'event_add_ticket',
event_invalid_form_error: 'event_invalid_form_error',
event_create_from_existing_event: 'event_create_from_existing_event',
event_search_for_an_existing_event: 'event_search_for_an_existing_event',
event_selected_existing_event: 'event_selected_existing_event',

// Purchase Ticket Info:
invalid_email_message: 'invalid_email_message',
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,9 @@ export const nb = prepareTranslations({
[KEY.event_registration_url]: 'Registreringslenke',
[KEY.event_add_ticket]: 'Legg til billett',
[KEY.event_invalid_form_error]: 'Skjemaet inneholder valideringsfeil. Vennligst sjekk de uthevede feltene.',
[KEY.event_create_from_existing_event]: 'Opprett fra eksisterende arrangement',
[KEY.event_search_for_an_existing_event]: 'Søk etter et eksisterende arrangement',
[KEY.event_selected_existing_event]: 'Valgte eksisterende arrangement',

// Event categories
[KEY.event_category_art]: 'Kunst',
Expand Down Expand Up @@ -1007,6 +1010,9 @@ export const en = prepareTranslations({
[KEY.event_registration_url]: 'Registration URL',
[KEY.event_add_ticket]: 'Add ticket',
[KEY.event_invalid_form_error]: 'Form contains validation errors. Please check highlighted fields.',
[KEY.event_create_from_existing_event]: 'Create from existing event',
[KEY.event_search_for_an_existing_event]: 'Search for an existing event',
[KEY.event_selected_existing_event]: 'Selected existing event',

//Purchase Ticket Info:
[KEY.invalid_email_message]: 'Invalid email format',
Expand Down
Loading