diff --git a/backend/samfundet/migrations/0006_event_general_link_event_lastfm_link_and_more.py b/backend/samfundet/migrations/0006_event_general_link_event_lastfm_link_and_more.py new file mode 100644 index 000000000..9b8285d3f --- /dev/null +++ b/backend/samfundet/migrations/0006_event_general_link_event_lastfm_link_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.11 on 2026-03-05 17:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('samfundet', '0005_medlemsinfo'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='general_link', + field=models.URLField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='lastfm_link', + field=models.URLField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='soundcloud_link', + field=models.URLField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='spotify_uri', + field=models.CharField(blank=True, max_length=200, null=True), + ), + migrations.AddField( + model_name='event', + name='vimeo_link', + field=models.URLField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='youtube_embed', + field=models.URLField(blank=True, null=True), + ), + migrations.AddField( + model_name='event', + name='youtube_link', + field=models.URLField(blank=True, null=True), + ), + ] diff --git a/backend/samfundet/models/event.py b/backend/samfundet/models/event.py index 6b60afce3..b764d0ef5 100644 --- a/backend/samfundet/models/event.py +++ b/backend/samfundet/models/event.py @@ -169,9 +169,17 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: email_contact = models.EmailField(max_length=200, blank=True, null=True) host_link = models.URLField(max_length=200, blank=True, null=True) - instagram_link = models.URLField(max_length=200, blank=True, null=True) + + spotify_uri = models.CharField(max_length=200, blank=True, null=True) + youtube_link = models.URLField(max_length=200, blank=True, null=True) + youtube_embed = models.URLField(max_length=200, blank=True, null=True) facebook_link = models.URLField(max_length=200, blank=True, null=True) + soundcloud_link = models.URLField(max_length=200, blank=True, null=True) + instagram_link = models.URLField(max_length=200, blank=True, null=True) x_link = models.URLField(max_length=200, blank=True, null=True) + lastfm_link = models.URLField(max_length=200, blank=True, null=True) + vimeo_link = models.URLField(max_length=200, blank=True, null=True) + general_link = models.URLField(max_length=200, blank=True, null=True) # ======================== # # Venue/Entrance # diff --git a/frontend/src/Pages/ComponentPage/ComponentPage.tsx b/frontend/src/Pages/ComponentPage/ComponentPage.tsx index 7250ffe1f..1a1d676b4 100644 --- a/frontend/src/Pages/ComponentPage/ComponentPage.tsx +++ b/frontend/src/Pages/ComponentPage/ComponentPage.tsx @@ -82,6 +82,16 @@ export function ComponentPage() { ...billig, } : undefined, + spotify_uri: '', + youtube_link: '', + youtube_embed: '', + facebook_link: '', + soundcloud_link: '', + instagram_link: '', + x_link: '', + lastfm_link: '', + vimeo_link: '', + general_link: '', ...override, }; } diff --git a/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.module.scss b/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.module.scss index 8369434ec..50d46dff6 100644 --- a/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.module.scss +++ b/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.module.scss @@ -87,3 +87,24 @@ padding-bottom: 3.75em; gap: 0.5em; } + +.socialMediaGrid { + display: grid; + gap: 2em; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + align-items: start; +} + +.socialMediaItem { + display: flex; + flex-direction: column; + gap: 0.25em; +} + +.socialMediaLabel { + font-weight: 600; +} + +.socialMediaInput { + height: 2.25em; +} diff --git a/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.tsx b/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.tsx index e0706e0da..62df5dbcf 100644 --- a/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.tsx +++ b/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorAdminPage.tsx @@ -28,6 +28,7 @@ import { EventPreviewCard } from './components/EventPreviewCard'; import { GraphicsStep } from './steps/GraphicsStep'; import { InfoStep } from './steps/InfoStep'; import { PaymentStep } from './steps/PaymentStep'; +import { SOCIAL_KEYS, SocialMediaStep } from './steps/SocialMediaStep'; import { SummaryStep } from './steps/SummaryStep'; import { TextStep } from './steps/TextStep'; @@ -67,10 +68,13 @@ export function EventCreatorAdminPage() { text: , info: , payment: , + socialmedia: , graphics: , summary: , }; + const hasSocialMediaErrors = SOCIAL_KEYS.some((name) => !!form.formState.errors[name]); + // Fetch event data using the event ID useEffect(() => { if (id) { @@ -96,7 +100,7 @@ export function EventCreatorAdminPage() { const formTabs: Tab[] = steps.map((step: EventCreatorStep) => { const custom = step.customIcon !== undefined; - const valid = step.validate(watchedValues) && !custom; + const valid = !custom && (step.key === 'socialmedia' ? !hasSocialMediaErrors : step.validate(watchedValues)); const visited = visitedTabs[step.key] === true && !custom; const error = !valid && visited && !custom; @@ -158,7 +162,9 @@ export function EventCreatorAdminPage() { } // Ready to save? - const allStepsComplete = steps.every((step) => step.validate(watchedValues)); + const allStepsComplete = steps.every((step) => + step.key === 'socialmedia' ? !hasSocialMediaErrors : step.validate(watchedValues), + ); // ================================== // // Navigation Logic // @@ -185,7 +191,7 @@ export function EventCreatorAdminPage() { ) : null; const onInvalid = (errors: FieldErrors) => { - toast.error(KEY.event_invalid_form_error); + toast.error(t(KEY.event_invalid_form_error)); const allVisited: Record = {}; for (const s of steps) allVisited[s.key] = true; setVisitedTabs(allVisited); diff --git a/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorSchema.ts b/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorSchema.ts index 6a9ca71a6..0890a91ed 100644 --- a/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorSchema.ts +++ b/frontend/src/PagesAdmin/EventCreatorAdminPage/EventCreatorSchema.ts @@ -8,14 +8,24 @@ import { EVENT_DESCRIPTION_SHORT, EVENT_DURATION, EVENT_END_DT, + EVENT_FACEBOOK_LINK, + EVENT_GENERAL_LINK, EVENT_HOST, + EVENT_INSTAGRAM_LINK, + EVENT_LASTFM_LINK, EVENT_LOCATION, EVENT_REGISTRATION_URL, + EVENT_SOUNDCLOUD_LINK, + EVENT_SPOTIFY_URI, EVENT_START_DT, EVENT_TICKET_TYPE, EVENT_TITLE, + EVENT_VIMEO_LINK, EVENT_VISIBILITY_FROM_DT, EVENT_VISIBILITY_TO_DT, + EVENT_X_LINK, + EVENT_YOUTUBE_EMBED, + EVENT_YOUTUBE_LINK, } from '~/schema/event'; import { OPTIONAL_IMAGE } from '~/schema/samfImage'; @@ -48,6 +58,17 @@ export const eventSchema = z.object({ custom_tickets: z.array(event_custom_ticket).optional(), registration_url: EVENT_REGISTRATION_URL, billig_id: EVENT_BILLIG_ID, + // Social media links + spotify_uri: EVENT_SPOTIFY_URI.optional(), + youtube_link: EVENT_YOUTUBE_LINK.optional(), + youtube_embed: EVENT_YOUTUBE_EMBED.optional(), + facebook_link: EVENT_FACEBOOK_LINK.optional(), + soundcloud_link: EVENT_SOUNDCLOUD_LINK.optional(), + instagram_link: EVENT_INSTAGRAM_LINK.optional(), + x_link: EVENT_X_LINK.optional(), + lastfm_link: EVENT_LASTFM_LINK.optional(), + vimeo_link: EVENT_VIMEO_LINK.optional(), + general_link: EVENT_GENERAL_LINK.optional(), // Graphics image: OPTIONAL_IMAGE, // Summary/Publication date diff --git a/frontend/src/PagesAdmin/EventCreatorAdminPage/hooks/useEventCreatorForm.ts b/frontend/src/PagesAdmin/EventCreatorAdminPage/hooks/useEventCreatorForm.ts index bc39fa4fd..a258815a8 100644 --- a/frontend/src/PagesAdmin/EventCreatorAdminPage/hooks/useEventCreatorForm.ts +++ b/frontend/src/PagesAdmin/EventCreatorAdminPage/hooks/useEventCreatorForm.ts @@ -45,6 +45,16 @@ export function useEventCreatorForm(params: { ticket_type: 'free', custom_tickets: [], billig_id: undefined, + spotify_uri: '', + youtube_link: '', + youtube_embed: '', + soundcloud_link: '', + instagram_link: '', + facebook_link: '', + x_link: '', + lastfm_link: '', + vimeo_link: '', + general_link: '', image: undefined, visibility_from_dt: '', visibility_to_dt: '', @@ -74,6 +84,16 @@ export function useEventCreatorForm(params: { ticket_type: event.ticket_type || 'free', custom_tickets: event.custom_tickets || [], billig_id: event.billig?.id, + spotify_uri: event.spotify_uri || '', + youtube_link: event.youtube_link || '', + youtube_embed: event.youtube_embed || '', + facebook_link: event.facebook_link || '', + soundcloud_link: event.soundcloud_link || '', + instagram_link: event.instagram_link || '', + x_link: event.x_link || '', + lastfm_link: event.lastfm_link || '', + vimeo_link: event.vimeo_link || '', + general_link: event.general_link || '', 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) : '', diff --git a/frontend/src/PagesAdmin/EventCreatorAdminPage/steps/SocialMediaStep.tsx b/frontend/src/PagesAdmin/EventCreatorAdminPage/steps/SocialMediaStep.tsx new file mode 100644 index 000000000..0bbdd4b3a --- /dev/null +++ b/frontend/src/PagesAdmin/EventCreatorAdminPage/steps/SocialMediaStep.tsx @@ -0,0 +1,91 @@ +import type { UseFormReturn } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { FormControl, FormField, FormItem, FormLabel, FormMessage, Input } from '~/Components'; +import { FormDescription } from '~/Components/Forms/Form'; +import { KEY } from '~/i18n/constants'; +import styles from '../EventCreatorAdminPage.module.scss'; +import type { EventFormType } from '../EventCreatorSchema'; +import type { FormType } from '../hooks/useEventCreatorForm'; + +type SocialLinkKey = Extract< + keyof EventFormType, + | 'spotify_uri' + | 'youtube_link' + | 'youtube_embed' + | 'facebook_link' + | 'soundcloud_link' + | 'instagram_link' + | 'x_link' + | 'lastfm_link' + | 'vimeo_link' + | 'general_link' +>; + +type Props = { + form: UseFormReturn; +}; + +export const SOCIAL_KEYS: readonly SocialLinkKey[] = [ + 'spotify_uri', + 'youtube_link', + 'youtube_embed', + 'facebook_link', + 'soundcloud_link', + 'instagram_link', + 'x_link', + 'lastfm_link', + 'vimeo_link', + 'general_link', +] as const; + +export function SocialMediaStep({ form }: Props) { + const { t } = useTranslation(); + + const SOCIAL_LABELS: Record = { + spotify_uri: 'Spotify URI', + youtube_link: `YouTube ${t(KEY.common_link)}`, + youtube_embed: 'YouTube embed', + facebook_link: `Facebook ${t(KEY.common_link)}`, + soundcloud_link: `SoundCloud ${t(KEY.common_link)}`, + instagram_link: `Instagram ${t(KEY.common_link)}`, + x_link: `X ${t(KEY.common_link)}`, + lastfm_link: `Last.fm ${t(KEY.common_link)}`, + vimeo_link: `Vimeo ${t(KEY.common_link)}`, + general_link: t(KEY.event_general_link), + }; + + const SOCIAL_MEDIA_HELP: Partial> = { + spotify_uri: t(KEY.event_spotify_uri_help), + youtube_link: t(KEY.event_youtube_link_help), + youtube_embed: t(KEY.event_youtube_embed_help), + }; + + return ( + <> +
+ {SOCIAL_KEYS.map((name) => ( + ( + + {SOCIAL_LABELS[name]} + + + + {SOCIAL_MEDIA_HELP[name] ? {SOCIAL_MEDIA_HELP[name]} : null} + + + )} + /> + ))} +
+ + ); +} diff --git a/frontend/src/PagesAdmin/EventCreatorAdminPage/steps/stepConfig.ts b/frontend/src/PagesAdmin/EventCreatorAdminPage/steps/stepConfig.ts index e5027cbde..a8eb5f3d7 100644 --- a/frontend/src/PagesAdmin/EventCreatorAdminPage/steps/stepConfig.ts +++ b/frontend/src/PagesAdmin/EventCreatorAdminPage/steps/stepConfig.ts @@ -1,6 +1,6 @@ import type { FormType } from '../hooks/useEventCreatorForm'; -export type StepKey = 'text' | 'info' | 'payment' | 'graphics' | 'summary'; +export type StepKey = 'text' | 'info' | 'payment' | 'socialmedia' | 'graphics' | 'summary'; export type EventCreatorStep = { key: StepKey; @@ -48,6 +48,13 @@ export const steps: EventCreatorStep[] = [ title_en: 'Payment/registration', validate: (d) => !!d.age_restriction && !!d.ticket_type, }, + // Social media links + { + key: 'socialmedia', + title_nb: 'Sosiale medier', + title_en: 'Social media', + validate: () => true, + }, { key: 'graphics', title_nb: 'Grafikk', diff --git a/frontend/src/dto.ts b/frontend/src/dto.ts index 41d555090..88221abde 100644 --- a/frontend/src/dto.ts +++ b/frontend/src/dto.ts @@ -188,6 +188,17 @@ export type EventDto = { image_url: string; capacity?: number; + + spotify_uri?: string; + youtube_link?: string; + youtube_embed?: string; + facebook_link?: string; + soundcloud_link?: string; + instagram_link?: string; + x_link?: string; + lastfm_link?: string; + vimeo_link?: string; + general_link?: string; }; export type EventWriteDto = { diff --git a/frontend/src/i18n/constants.ts b/frontend/src/i18n/constants.ts index bff73255a..f38f2337c 100644 --- a/frontend/src/i18n/constants.ts +++ b/frontend/src/i18n/constants.ts @@ -203,6 +203,7 @@ export const KEY = { common_available: 'common_available', common_comment: 'common_comment', common_capacity: 'common_capacity', + common_link: 'common_link', common_membership_number: 'common_membership_number', common_to_payment: 'common_to_payment', @@ -288,8 +289,15 @@ export const KEY = { // EventPage: event_registration_url: 'event_registration_url', + event_general_link: 'event_general_link', + event_spotify_uri_help: 'event_spotify_uri_help', + event_youtube_link_help: 'event_youtube_link_help', + event_youtube_embed_help: 'event_youtube_embed_help', event_add_ticket: 'event_add_ticket', event_invalid_form_error: 'event_invalid_form_error', + event_must_be_valid_url: 'event_must_be_valid_url', + event_must_be_valid_spotify_uri: 'event_must_be_valid_spotify_uri', + event_publication_date_required: 'event_publication_date_required', // Purchase Ticket Info: invalid_email_message: 'invalid_email_message', diff --git a/frontend/src/i18n/translations.ts b/frontend/src/i18n/translations.ts index b2824092c..502a45789 100644 --- a/frontend/src/i18n/translations.ts +++ b/frontend/src/i18n/translations.ts @@ -187,6 +187,7 @@ export const nb = prepareTranslations({ [KEY.common_available]: 'Tilgjengelig', [KEY.common_comment]: 'Kommentar', [KEY.common_capacity]: 'Kapasitet', + [KEY.common_link]: 'Lenke', [KEY.common_membership_number]: 'Medlemsnummer', [KEY.common_to_payment]: 'Til betaling', @@ -278,8 +279,16 @@ export const nb = prepareTranslations({ // EventPage: [KEY.event_registration_url]: 'Registreringslenke', + [KEY.event_general_link]: 'Generell lenke', + [KEY.event_spotify_uri_help]: + 'Legg til Spotify URI-en til en spilleliste, og en widget med denne listen vil dukke opp på arrangementsiden.', + [KEY.event_youtube_link_help]: 'Legg til YouTube-video som skal linkes til', + [KEY.event_youtube_embed_help]: 'Legg til YouTube-video som skal embeddes, så vil den vises på arrangementsiden.', [KEY.event_add_ticket]: 'Legg til billett', [KEY.event_invalid_form_error]: 'Skjemaet inneholder valideringsfeil. Vennligst sjekk de uthevede feltene.', + [KEY.event_must_be_valid_url]: 'Må være en gyldig URL', + [KEY.event_must_be_valid_spotify_uri]: 'Må være en gyldig Spotify URI', + [KEY.event_publication_date_required]: 'Publiseringsdato er påkrevd', // Event categories [KEY.event_category_art]: 'Kunst', @@ -894,6 +903,7 @@ export const en = prepareTranslations({ [KEY.common_available]: 'Available', [KEY.common_comment]: 'Comment', [KEY.common_capacity]: 'Capacity', + [KEY.common_link]: 'Link', [KEY.common_membership_number]: 'Membership number', [KEY.common_to_payment]: 'To payment', @@ -1005,8 +1015,16 @@ export const en = prepareTranslations({ // EventPage: [KEY.event_registration_url]: 'Registration URL', + [KEY.event_general_link]: 'General link', + [KEY.event_spotify_uri_help]: + 'Add the Spotify URI of a playlist, and a widget will appear on the event page with the chosen playlist.', + [KEY.event_youtube_link_help]: 'Add a link to a YouTube video', + [KEY.event_youtube_embed_help]: 'Add a link to a YouTube video to be embedded, and it will appear on the event page.', [KEY.event_add_ticket]: 'Add ticket', [KEY.event_invalid_form_error]: 'Form contains validation errors. Please check highlighted fields.', + [KEY.event_must_be_valid_url]: 'Must be a valid URL', + [KEY.event_must_be_valid_spotify_uri]: 'Must be a valid Spotify URI', + [KEY.event_publication_date_required]: 'Publication date is required', //Purchase Ticket Info: [KEY.invalid_email_message]: 'Invalid email format', diff --git a/frontend/src/schema/event.ts b/frontend/src/schema/event.ts index f513fd153..0f67bc885 100644 --- a/frontend/src/schema/event.ts +++ b/frontend/src/schema/event.ts @@ -3,26 +3,54 @@ import { KEY } from '~/i18n/constants'; import { EventAgeRestriction, EventCategory, EventTicketType } from '~/types'; import { zodEnum } from './utils'; +const validUrl = z + .string() + .trim() + .refine((val) => val === '' || /^https?:\/\//.test(val), { message: KEY.event_must_be_valid_url }); + +const validSpotifyUri = z + .string() + .trim() + .refine((val) => val === '' || /^spotify:(track|artist|album|playlist):[a-zA-Z0-9]{22}$/.test(val), { + message: KEY.event_must_be_valid_spotify_uri, + }); + +// text and description export const EVENT_TITLE = z.string().min(1, { message: KEY.event_form_title_required }); export const EVENT_DESCRIPTION_LONG = z.string().min(1, { message: KEY.event_form_description_long_required }); export const EVENT_DESCRIPTION_SHORT = z.string().min(1, { message: KEY.event_form_description_short_required }); +// Date and information export const EVENT_START_DT = z.string().min(1, { message: KEY.event_form_start_dt_required }); export const EVENT_DURATION = z.number().min(1, { message: KEY.event_form_duration_min }).optional(); export const EVENT_END_DT = z.string().optional(); +export const EVENT_CATEGORY = zodEnum(EventCategory, KEY.event_form_category_required); export const EVENT_HOST = z.string().min(1, { message: KEY.event_form_host_required }); export const EVENT_LOCATION = z.string().min(1, { message: KEY.event_form_location_required }); export const EVENT_CAPACITY = z.number().min(1, { message: KEY.event_form_capacity_min }).optional(); -export const EVENT_VISIBILITY_FROM_DT = z.string().min(1, { message: KEY.event_form_visibility_from_required }); -export const EVENT_VISIBILITY_TO_DT = z.string().optional(); -export const EVENT_PAID_OPTION = z.string().url().optional(); -export const EVENT_BILLIG_ID = z.number().optional(); - -export const EVENT_REGISTRATION_URL = z.string().url().optional(); -export const EVENT_HOST_LINK = z.string().url().optional(); -export const EVENT_INSTAGRAM_LINK = z.string().url().optional(); -export const EVENT_FACEBOOK_LINK = z.string().url().optional(); -export const EVENT_X_LINK = z.string().url().optional(); - -export const EVENT_CATEGORY = zodEnum(EventCategory, KEY.event_form_category_required); +// Payment/registration export const EVENT_AGE_RESTRICTION = zodEnum(EventAgeRestriction, KEY.event_form_age_restriction_required); export const EVENT_TICKET_TYPE = zodEnum(EventTicketType, KEY.event_form_ticket_type_required); +export const EVENT_CUSTOM_TICKET = z.object({ + id: z.number(), + name_nb: z.string().min(1), + name_en: z.string().min(1), + price: z.number().min(0), +}); +export const EVENT_REGISTRATION_URL = z.string().url().optional(); +export const EVENT_HOST_LINK = z.string().url().optional(); +export const EVENT_BILLIG_ID = z.number().optional(); +// Social media links +export const EVENT_SPOTIFY_URI = validSpotifyUri; +export const EVENT_YOUTUBE_LINK = validUrl; +export const EVENT_YOUTUBE_EMBED = validUrl; +export const EVENT_FACEBOOK_LINK = validUrl; +export const EVENT_SOUNDCLOUD_LINK = validUrl; +export const EVENT_INSTAGRAM_LINK = validUrl; +export const EVENT_X_LINK = validUrl; +export const EVENT_LASTFM_LINK = validUrl; +export const EVENT_VIMEO_LINK = validUrl; +export const EVENT_GENERAL_LINK = validUrl; +// Summary/Publication date +export const EVENT_VISIBILITY_FROM_DT = z.string().min(1, { message: KEY.event_publication_date_required }); +export const EVENT_VISIBILITY_TO_DT = z.string().optional(); +export const EVENT_PAID_OPTION = z.string().url().optional();