From 77a526e00958d9e2afe192d1ce7a96f492644d42 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 9 Jun 2026 12:30:37 -0700 Subject: [PATCH 1/3] feat(contest): add permalink field to Event model for event_routes support Add permalink?: string to the SDK-generated Event type and common Event model so contest pages can use title-based slugs from event_routes once the API starts returning them. - SDK Event: add permalink field + EventFromJSONTyped/EventToJSON wiring - common Event model: add permalink?: string (forward-compatible optional) - contestPage() helper: pass through permalinks that already contain a contest segment (event-routes slugs) unchanged; keep the existing track-permalink injection for backward compat - Desktop + mobile ContestPage: prefer contest.permalink over track.permalink when setting canonicalUrl (falls back gracefully when absent) - Share flow: thread eventPermalink through ShareModalRequest, ShareContestContent, shareContest action, watchShareContest saga, useShareContent hook, and mobile ShareDrawer utils so sharing a contest uses the event-routes slug when available - mobile getContestRoute: accept optional contestPermalink, use it directly when present - route.test.ts: add passthrough tests for the new contestPage behavior Co-Authored-By: Claude Sonnet 4.6 (1M context) --- packages/common/src/hooks/useShareContent.ts | 8 ++++++++ packages/common/src/models/Event.ts | 7 +++++-- packages/common/src/store/social/tracks/actions.ts | 6 +++++- packages/common/src/store/ui/share-modal/types.ts | 6 +++++- .../mobile/src/components/share-drawer/utils.ts | 10 +++++----- .../src/screens/contest-screen/ContestScreen.tsx | 3 ++- packages/mobile/src/utils/routes.tsx | 11 ++++++++--- .../src/sdk/api/generated/default/models/Event.ts | 9 +++++++++ .../web/src/common/store/social/tracks/sagas.ts | 5 +++-- .../web/src/components/share-modal/ShareModal.tsx | 2 +- packages/web/src/components/share-modal/utils.ts | 6 ++++-- .../contest-page/components/desktop/ContestPage.tsx | 7 ++++--- .../contest-page/components/mobile/ContestPage.tsx | 5 +++-- packages/web/src/utils/route.test.ts | 13 +++++++++++++ packages/web/src/utils/route.ts | 8 ++++++-- 15 files changed, 81 insertions(+), 25 deletions(-) diff --git a/packages/common/src/hooks/useShareContent.ts b/packages/common/src/hooks/useShareContent.ts index 948019f6a43..ca6a8efff6a 100644 --- a/packages/common/src/hooks/useShareContent.ts +++ b/packages/common/src/hooks/useShareContent.ts @@ -30,6 +30,14 @@ export const useShareContent = ( if (request.type === 'track' || request.type === 'contest') { if (!track || !trackArtist) return null + if (request.type === 'contest') { + return { + type: 'contest', + track, + artist: trackArtist, + ...(request.eventPermalink ? { eventPermalink: request.eventPermalink } : {}) + } + } return { type: request.type, track, artist: trackArtist } } diff --git a/packages/common/src/models/Event.ts b/packages/common/src/models/Event.ts index 03cfb54432c..dc3f78f2bbc 100644 --- a/packages/common/src/models/Event.ts +++ b/packages/common/src/models/Event.ts @@ -1,8 +1,6 @@ import { Event as EventSDK } from '@audius/sdk' import type { OverrideProperties } from 'type-fest' - import { Nullable } from '../utils/typeUtils' - import { ID } from './Identifiers' export type Event = OverrideProperties< @@ -11,5 +9,10 @@ export type Event = OverrideProperties< eventId: ID userId: ID entityId: Nullable + /** Canonical contest permalink (e.g. /{handle}/contest-title). + * Set once the API returns a permalink field from event_routes. + * Optional - consumers fall back to deriving the URL from the + * associated track's permalink when this is absent. */ + permalink?: string } > diff --git a/packages/common/src/store/social/tracks/actions.ts b/packages/common/src/store/social/tracks/actions.ts index 6cdeda8f60d..d9e9b4c02a4 100644 --- a/packages/common/src/store/social/tracks/actions.ts +++ b/packages/common/src/store/social/tracks/actions.ts @@ -129,5 +129,9 @@ export const shareTrack = createCustomAction( */ export const shareContest = createCustomAction( SHARE_CONTEST, - (trackId: ID, source: ShareSource) => ({ trackId, source }) + (trackId: ID, source: ShareSource, eventPermalink?: string) => ({ + trackId, + source, + eventPermalink + }) ) diff --git a/packages/common/src/store/ui/share-modal/types.ts b/packages/common/src/store/ui/share-modal/types.ts index 3cfc2191785..f6fbee106f4 100644 --- a/packages/common/src/store/ui/share-modal/types.ts +++ b/packages/common/src/store/ui/share-modal/types.ts @@ -16,11 +16,15 @@ type ShareTrackContent = { * Contest shares use the same underlying data as a track share (the * contest is keyed off a parent track) but link to the contest page * (`{trackPermalink}/contest`) instead of the track itself. + * When the event has its own permalink from event_routes, that value is + * passed as `eventPermalink` and used directly. */ type ShareContestContent = { type: 'contest' track: Track artist: User + /** Canonical contest permalink from event_routes, if available. */ + eventPermalink?: string } type ShareProfileContent = { @@ -55,7 +59,7 @@ export type ShareContent = export type ShareModalRequest = | { type: 'track'; trackId: ID } - | { type: 'contest'; trackId: ID } + | { type: 'contest'; trackId: ID; eventPermalink?: string } | { type: 'profile'; profileId: ID } | { type: 'collection'; collectionId: ID } diff --git a/packages/mobile/src/components/share-drawer/utils.ts b/packages/mobile/src/components/share-drawer/utils.ts index d99f824b6c1..18186aa7e97 100644 --- a/packages/mobile/src/components/share-drawer/utils.ts +++ b/packages/mobile/src/components/share-drawer/utils.ts @@ -17,11 +17,11 @@ export const getContentUrl = (content: ShareContent) => { return getTrackRoute(track, true) } case 'contest': { - // Contest shares point at the parent track's contest page — - // `{permalink}/contest` — so sharing copies the contest URL - // rather than the underlying track. - const { track } = content - return getContestRoute(track, true) + // Contest shares point at the contest page. When the event has its + // own permalink from event_routes, use it directly; otherwise derive + // from the parent track's permalink. + const { track, eventPermalink } = content + return getContestRoute({ permalink: track.permalink, contestPermalink: eventPermalink }, true) } case 'profile': { const { profile } = content diff --git a/packages/mobile/src/screens/contest-screen/ContestScreen.tsx b/packages/mobile/src/screens/contest-screen/ContestScreen.tsx index db947e450ae..89507fb6e00 100644 --- a/packages/mobile/src/screens/contest-screen/ContestScreen.tsx +++ b/packages/mobile/src/screens/contest-screen/ContestScreen.tsx @@ -216,7 +216,8 @@ export const ContestScreen = () => { shareModalUIActions.requestOpen({ type: 'contest', trackId, - source: ShareSource.PAGE + source: ShareSource.PAGE, + ...(contest?.permalink ? { eventPermalink: contest.permalink } : {}) }) ) }, [dispatch, trackId]) diff --git a/packages/mobile/src/utils/routes.tsx b/packages/mobile/src/utils/routes.tsx index c87f7e961ff..000411ff510 100644 --- a/packages/mobile/src/utils/routes.tsx +++ b/packages/mobile/src/utils/routes.tsx @@ -16,11 +16,16 @@ export const getTrackRoute = ( } export const getContestRoute = ( - track: { permalink: string }, + track: { permalink: string; contestPermalink?: string }, fullUrl = false ) => { - // Permalink shape: `/{handle}/{slug}` → contest URL is - // `/{handle}/contest/{slug}`. Mirror the web `contestPage` helper. + // If the event has its own permalink from event_routes, use it directly. + if (track.contestPermalink) { + const route = track.contestPermalink + return fullUrl ? `${AUDIUS_URL}${route}` : route + } + // Fallback: derive contest URL from the track permalink. + // `/{handle}/{slug}` → `/{handle}/contest/{slug}`. const [, handle, ...rest] = track.permalink.split('/') const route = `/${handle}/contest/${rest.join('/')}` return fullUrl ? `${AUDIUS_URL}${route}` : route diff --git a/packages/sdk/src/sdk/api/generated/default/models/Event.ts b/packages/sdk/src/sdk/api/generated/default/models/Event.ts index 8846fdd120d..b47b96337a4 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/Event.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/Event.ts @@ -79,6 +79,13 @@ export interface Event { * @memberof Event */ eventData: object; + /** + * Canonical contest permalink derived from event_routes. + * Optional - absent until the API begins returning this field. + * @type {string} + * @memberof Event + */ + permalink?: string; } @@ -138,6 +145,7 @@ export function EventFromJSONTyped(json: any, ignoreDiscriminator: boolean): Eve 'createdAt': json['created_at'], 'updatedAt': json['updated_at'], 'eventData': json['event_data'], + 'permalink': !exists(json, 'permalink') ? undefined : json['permalink'], }; } @@ -160,6 +168,7 @@ export function EventToJSON(value?: Event | null): any { 'created_at': value.createdAt, 'updated_at': value.updatedAt, 'event_data': value.eventData, + 'permalink': value.permalink, }; } diff --git a/packages/web/src/common/store/social/tracks/sagas.ts b/packages/web/src/common/store/social/tracks/sagas.ts index bb3a3545268..e65e7ff92a3 100644 --- a/packages/web/src/common/store/social/tracks/sagas.ts +++ b/packages/web/src/common/store/social/tracks/sagas.ts @@ -807,7 +807,7 @@ function* watchShareContest() { yield* takeEvery( socialActions.SHARE_CONTEST, function* (action: ReturnType) { - const { trackId } = action + const { trackId, eventPermalink } = action const track = yield* queryTrack(trackId) if (!track) return @@ -815,7 +815,8 @@ function* watchShareContest() { const user = yield* queryUser(track.owner_id) if (!user) return - const link = fullContestPage(track.permalink) + // Prefer event_routes permalink; fall back to deriving from track permalink. + const link = eventPermalink ? fullContestPage(eventPermalink) : fullContestPage(track.permalink) const share = yield* getContext('share') share(link, formatShareText(track.title, user.name)) diff --git a/packages/web/src/components/share-modal/ShareModal.tsx b/packages/web/src/components/share-modal/ShareModal.tsx index 2c5ba880753..b0d8954121a 100644 --- a/packages/web/src/components/share-modal/ShareModal.tsx +++ b/packages/web/src/components/share-modal/ShareModal.tsx @@ -94,7 +94,7 @@ export const ShareModal = NiceModal.create(() => { // Contest copy-link uses the dedicated `shareContest` saga, // which writes the contest URL (`{permalink}/contest`) to // the clipboard rather than the track permalink. - dispatch(shareContest(content.track.track_id, source)) + dispatch(shareContest(content.track.track_id, source, content.eventPermalink)) break case 'profile': dispatch(shareUser(content.profile.user_id, source)) diff --git a/packages/web/src/components/share-modal/utils.ts b/packages/web/src/components/share-modal/utils.ts index e25db6ab855..221b917ac36 100644 --- a/packages/web/src/components/share-modal/utils.ts +++ b/packages/web/src/components/share-modal/utils.ts @@ -44,10 +44,12 @@ export const getXShareText = async ( case 'contest': { const { track: { title, permalink, track_id }, - artist + artist, + eventPermalink } = content xText = messageConfig.contestShareText(title, getXShareHandle(artist)) - link = fullContestPage(permalink) + // Prefer event_routes permalink; fall back to deriving from track permalink. + link = eventPermalink ? fullContestPage(eventPermalink) : fullContestPage(permalink) // Contest shares route through the parent track's analytics // entry — there's no dedicated 'contest' event kind yet and // the parent track is what gets credited for engagement. diff --git a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx index a3de06d3467..7f2077a9e9f 100644 --- a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx +++ b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx @@ -405,7 +405,8 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { shareModalUIActions.requestOpen({ type: 'contest', trackId, - source: ShareSource.PAGE + source: ShareSource.PAGE, + ...(contest?.permalink ? { eventPermalink: contest.permalink } : {}) }) ) }, [dispatch, trackId]) @@ -490,7 +491,7 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { return ( { return ( {/* Content is centered to MAX_CONTENT_WIDTH and sits on the diff --git a/packages/web/src/pages/contest-page/components/mobile/ContestPage.tsx b/packages/web/src/pages/contest-page/components/mobile/ContestPage.tsx index 0c7ca34206e..ee893d9daca 100644 --- a/packages/web/src/pages/contest-page/components/mobile/ContestPage.tsx +++ b/packages/web/src/pages/contest-page/components/mobile/ContestPage.tsx @@ -300,7 +300,8 @@ const ContestPage = ({ shareModalUIActions.requestOpen({ type: 'contest', trackId, - source: ShareSource.PAGE + source: ShareSource.PAGE, + ...(contest?.permalink ? { eventPermalink: contest.permalink } : {}) }) ) }, [dispatch, trackId]) @@ -505,7 +506,7 @@ const ContestPage = ({ return ( {/* Top section: hero banner + meta (title, CTA, deadline, diff --git a/packages/web/src/utils/route.test.ts b/packages/web/src/utils/route.test.ts index b3dd8561535..47de2d86455 100644 --- a/packages/web/src/utils/route.test.ts +++ b/packages/web/src/utils/route.test.ts @@ -40,3 +40,16 @@ describe('CONTEST_PAGE route pattern', () => { expect(CONTEST_PAGE).not.toBe(TRACK_REMIXES_PAGE) }) }) + +describe('contestPage with event permalink passthrough', () => { + it('returns an event-routes permalink unchanged when it already contains contest', () => { + // event_routes produces slugs like /Protohype/best-remix-contest + // which already contain a 'contest' segment -- return as-is. + expect(contestPage('/Protohype/contest/best-remix-contest')).toBe( + '/Protohype/contest/best-remix-contest' + ) + }) + it('still rewrites a plain track permalink when no contest segment exists', () => { + expect(contestPage('/Artist/my-track')).toBe('/Artist/contest/my-track') + }) +}) diff --git a/packages/web/src/utils/route.ts b/packages/web/src/utils/route.ts index 27a166f9173..d7679709683 100644 --- a/packages/web/src/utils/route.ts +++ b/packages/web/src/utils/route.ts @@ -65,9 +65,13 @@ export const fullPickWinnersPage = (permalink: string) => { } export const contestPage = (permalink: string) => { - // Permalink shape: `/{handle}/{slug}`. Contest URL injects the literal + // If permalink already contains a "contest" segment (i.e. it came from + // event_routes rather than track_routes) return it as-is. + const parts = permalink.split('/') + if (parts.includes('contest')) return permalink + // Track permalink shape: `/{handle}/{slug}`. Inject the literal // "contest" segment between handle and slug → `/{handle}/contest/{slug}`. - const [, handle, ...rest] = permalink.split('/') + const [, handle, ...rest] = parts return `/${handle}/contest/${rest.join('/')}` } export const fullContestPage = (permalink: string) => { From 56e0af0844bf4ee4ebc610c99cd5308c3c539533 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 9 Jun 2026 18:42:30 -0700 Subject: [PATCH 2/3] fix(contest): add contest.permalink to useCallback dep arrays --- .../src/pages/contest-page/components/desktop/ContestPage.tsx | 4 ++-- .../src/pages/contest-page/components/mobile/ContestPage.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx index 7f2077a9e9f..03b5bf07119 100644 --- a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx +++ b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx @@ -306,7 +306,7 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { if (trackId) { dispatch(fetchTrackSucceeded({ trackId })) } - }, [dispatch, trackId]) + }, [contest?.permalink, dispatch, trackId]) useEffect(() => { return function cleanup() { @@ -409,7 +409,7 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { ...(contest?.permalink ? { eventPermalink: contest.permalink } : {}) }) ) - }, [dispatch, trackId]) + }, [contest?.permalink, dispatch, trackId]) const renderActions = useCallback(() => { if (!eventId) return null diff --git a/packages/web/src/pages/contest-page/components/mobile/ContestPage.tsx b/packages/web/src/pages/contest-page/components/mobile/ContestPage.tsx index ee893d9daca..4becfe417ef 100644 --- a/packages/web/src/pages/contest-page/components/mobile/ContestPage.tsx +++ b/packages/web/src/pages/contest-page/components/mobile/ContestPage.tsx @@ -304,7 +304,7 @@ const ContestPage = ({ ...(contest?.permalink ? { eventPermalink: contest.permalink } : {}) }) ) - }, [dispatch, trackId]) + }, [contest?.permalink, dispatch, trackId]) const { imageUrl: trackCoverArtUrl } = useTrackCoverArt({ trackId, @@ -393,7 +393,7 @@ const ContestPage = ({ if (trackId) { dispatch(remixesPageActions.fetchTrackSucceeded({ trackId })) } - }, [dispatch, trackId]) + }, [contest?.permalink, dispatch, trackId]) useEffect(() => { return function cleanup() { From 0db5f73682969c9b26131432c44a4c59833407d6 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Tue, 9 Jun 2026 18:47:22 -0700 Subject: [PATCH 3/3] fix(contest): prettier formatting in share-modal and sagas --- packages/web/src/common/store/social/tracks/sagas.ts | 4 +++- packages/web/src/components/share-modal/ShareModal.tsx | 4 +++- packages/web/src/components/share-modal/utils.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/web/src/common/store/social/tracks/sagas.ts b/packages/web/src/common/store/social/tracks/sagas.ts index e65e7ff92a3..cd708b8e3f8 100644 --- a/packages/web/src/common/store/social/tracks/sagas.ts +++ b/packages/web/src/common/store/social/tracks/sagas.ts @@ -816,7 +816,9 @@ function* watchShareContest() { if (!user) return // Prefer event_routes permalink; fall back to deriving from track permalink. - const link = eventPermalink ? fullContestPage(eventPermalink) : fullContestPage(track.permalink) + const link = eventPermalink + ? fullContestPage(eventPermalink) + : fullContestPage(track.permalink) const share = yield* getContext('share') share(link, formatShareText(track.title, user.name)) diff --git a/packages/web/src/components/share-modal/ShareModal.tsx b/packages/web/src/components/share-modal/ShareModal.tsx index b0d8954121a..ae70abf85b2 100644 --- a/packages/web/src/components/share-modal/ShareModal.tsx +++ b/packages/web/src/components/share-modal/ShareModal.tsx @@ -94,7 +94,9 @@ export const ShareModal = NiceModal.create(() => { // Contest copy-link uses the dedicated `shareContest` saga, // which writes the contest URL (`{permalink}/contest`) to // the clipboard rather than the track permalink. - dispatch(shareContest(content.track.track_id, source, content.eventPermalink)) + dispatch( + shareContest(content.track.track_id, source, content.eventPermalink) + ) break case 'profile': dispatch(shareUser(content.profile.user_id, source)) diff --git a/packages/web/src/components/share-modal/utils.ts b/packages/web/src/components/share-modal/utils.ts index 221b917ac36..83e32a6e155 100644 --- a/packages/web/src/components/share-modal/utils.ts +++ b/packages/web/src/components/share-modal/utils.ts @@ -49,7 +49,9 @@ export const getXShareText = async ( } = content xText = messageConfig.contestShareText(title, getXShareHandle(artist)) // Prefer event_routes permalink; fall back to deriving from track permalink. - link = eventPermalink ? fullContestPage(eventPermalink) : fullContestPage(permalink) + link = eventPermalink + ? fullContestPage(eventPermalink) + : fullContestPage(permalink) // Contest shares route through the parent track's analytics // entry — there's no dedicated 'contest' event kind yet and // the parent track is what gets credited for engagement.