Skip to content

Commit

Permalink
recommendations
Browse files Browse the repository at this point in the history
  • Loading branch information
wslyvh committed Nov 2, 2024
1 parent ab750cd commit e6e97a0
Show file tree
Hide file tree
Showing 5 changed files with 250 additions and 59 deletions.
4 changes: 4 additions & 0 deletions devcon-api/src/clients/recommendation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export async function GetRecommendedSessions(id: string, includeFeatured?: boole
},
],
},
include: {
speakers: true,
slot_room: true,
},
orderBy: {
slot_start: 'asc',
},
Expand Down
13 changes: 11 additions & 2 deletions devcon-app/src/components/domain/app/dc7/dashboard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import PassportLogoBlack from 'assets/images/dc-7/passport-logo-black.png'
import { NotificationCard } from 'components/domain/app/dc7/profile/notifications'
import { PersonalizedSuggestions } from 'components/domain/app/dc7/sessions/recommendations'
import { useRecoilState, useRecoilValue } from 'recoil'
import { devaBotVisibleAtom, notificationsAtom, sessionsAtom, useSeenNotifications } from 'pages/_app'
import { devaBotVisibleAtom, notificationsAtom, sessionsAtom, speakersAtom, useSeenNotifications } from 'pages/_app'
import FoodIcon from 'assets/icons/food-beverage.svg'
import CityGuideIcon from 'assets/icons/city-guide.svg'
import VideoIcon from 'assets/icons/video-play.svg'
Expand All @@ -25,6 +25,8 @@ import { Link } from 'components/common/link'
import { TruncateMiddle } from 'utils/formatting'
import ChevronRight from 'assets/icons/chevron_right.svg'
import { FancyLoader } from 'lib/components/loader/loader'
import { RecommendedSpeakers } from '../speakers/recommendations'
import { useSpeakerData } from 'services/event-data'

export const cardClass =
'flex flex-col lg:border lg:border-solid lg:border-[#E4E6EB] rounded-3xl relative lg:bg-[#fbfbfb]'
Expand Down Expand Up @@ -157,6 +159,7 @@ const featuredClass =
export const Dashboard = () => {
const accountContext = useAccountContext()
const sessions = useRecoilValue(sessionsAtom)
const speakers = useSpeakerData()
const draggableLink = useDraggableLink()
const [_, setDevaBotVisible] = useRecoilState(devaBotVisibleAtom)
const { account, loading } = accountContext
Expand Down Expand Up @@ -291,7 +294,13 @@ export const Dashboard = () => {
</SwipeToScroll>
</div>
<div className="pb-4 mx-4 border-top"></div>
<PersonalizedSuggestions sessions={sessions || []} standalone />
<div>
<RecommendedSpeakers speakers={speakers ?? []} standalone />
</div>
<div className="pb-4 mx-4 mt-6 border-top"></div>
<div className="">
<PersonalizedSuggestions sessions={sessions || []} standalone />
</div>
</div>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useAccountContext } from 'context/account-context'
import { APP_CONFIG } from 'utils/config'
import { Separator } from 'lib/components/ui/separator'
import cn from 'classnames'
import { FancyLoader } from 'lib/components/loader/loader'

interface Props {
sessions: SessionType[]
Expand All @@ -32,7 +33,7 @@ export function PersonalizedSuggestions({ sessions, standalone }: Props) {
[sessions, account]
)

const { data: recommended } = useQuery({
const { data: recommended, isLoading } = useQuery({
queryKey: ['account', 'sessions', 'recommended', account?.id],
queryFn: async () => {
if (!account?.id) {
Expand Down Expand Up @@ -100,21 +101,16 @@ export function PersonalizedSuggestions({ sessions, standalone }: Props) {
name: 'Recommended',
list: recommended,
},
].map(({ name, list }) => {
const isEmpty = !list?.length
].map(({ name }) => {
return (
<div
key={name}
title={'Login for more personalized recommendations'}
className={cn(
'flex shrink-0 items-center justify-center align-middle rounded-full border bg-white border-solid border-transparent shadow px-4 py-1 select-none transition-all duration-300',
filter === name.toLowerCase() ? 'border-[#ac9fdf] !bg-[#EFEBFF]' : '',
isEmpty ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[#f8f7ff] cursor-pointer'
filter === name.toLowerCase() ? 'border-[#ac9fdf] !bg-[#EFEBFF]' : ''
)}
onClick={() => {
if (!isEmpty) {
setFilter(name.toLowerCase() as 'featured' | 'personal' | 'recommended')
}
setFilter(name.toLowerCase() as 'featured' | 'personal' | 'recommended')
}}
>
{name}
Expand All @@ -126,6 +122,31 @@ export function PersonalizedSuggestions({ sessions, standalone }: Props) {
)}

<div className={cn('overflow-hidden mb-3', standalone ? 'my-4' : '')}>
{filter === 'personal' && !sessionList?.length && (
<div className="ml-4 text-xs text-[#717784]">
<p>
<Link to="/account/profile" className="underline text-[#7d52f4]">
Complete your profile
</Link>{' '}
to see personalized recommendations.
</p>
</div>
)}
{filter === 'recommended' && isLoading && (
<div className="ml-4 flex items-center justify-center w-full">
<FancyLoader loading={isLoading} size={60} />
</div>
)}
{filter === 'recommended' && !isLoading && !sessionList?.length && (
<div className="ml-4 text-xs text-[#717784]">
<p>
<Link to="/account/profile" className="underline text-[#7d52f4]">
Complete your profile
</Link>{' '}
to see personalized recommendations.
</p>
</div>
)}
<SwipeToScroll scrollIndicatorDirections={{ right: true }}>
<div className="flex flex-row gap-3">
{sessionList?.map((session: SessionType, index: number) => (
Expand Down
70 changes: 22 additions & 48 deletions devcon-app/src/components/domain/app/dc7/speakers/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { ScrollUpComponent } from '../sessions'
import { Popup } from 'lib/components/pop-up'
import { useAccountContext } from 'context/account-context'
import moment from 'moment'
import { RecommendedSpeakers } from './recommendations'
// import { SessionFilterAdvanced } from '../sessions'

export const cardClass =
Expand Down Expand Up @@ -397,60 +398,33 @@ export const SpeakerList = ({ speakers }: { speakers: SpeakerType[] | null }) =>
setVisibleSpeakers(filteredSpeakers.slice(0, page * SPEAKERS_PER_PAGE))
}, [page, filteredSpeakers])

return (
<div data-type="speaker-list" className={cn(cardClass)}>
<SpeakerFilter filterOptions={filterOptions} />
const onSpeakerSelect = (e: any, speaker: SpeakerType) => {
const result = draggableLink.onClick(e)

<div className="flex flex-col gap-3 pb-4 px-4 font-semibold">Featured Speakers</div>
if (!result) return

<div className="overflow-hidden">
<SwipeToScroll scrollIndicatorDirections={{ right: true }}>
<div className="flex flex-row gap-3">
{featuredSpeakers.map((speaker, index) => (
<Link
to={`/speakers/${speaker.sourceId}`}
key={speaker.sourceId}
className={cn(
'flex flex-col items-center justify-center gap-2 rounded-xl bg-white border border-solid border-[#E1E4EA] p-2 shrink-0 cursor-pointer hover:border-[#ac9fdf] transition-all duration-300',
selectedSpeaker?.sourceId === speaker.sourceId ? 'border-[#ac9fdf] !bg-[#EFEBFF]' : '',
index === 0 ? 'ml-4' : ''
)}
{...draggableLink}
onClick={(e: any) => {
const result = draggableLink.onClick(e)
if (pathname === '/speakers' && isLargeScreen) e.preventDefault()

if (!result) return
if (isLargeScreen) {
if (selectedSpeaker?.sourceId === speaker.sourceId && pathname === '/speakers') {
setSelectedSpeaker(null)
} else {
setSelectedSpeaker(speaker)
}
}

if (pathname === '/speakers' && isLargeScreen) e.preventDefault()
setDevaBotVisible(false)
}

if (isLargeScreen) {
if (selectedSpeaker?.sourceId === speaker.sourceId && pathname === '/speakers') {
setSelectedSpeaker(null)
} else {
setSelectedSpeaker(speaker)
}
}
return (
<div data-type="speaker-list" className={cn(cardClass)}>
<SpeakerFilter filterOptions={filterOptions} />

setDevaBotVisible(false)
}}
>
<div className="relative rounded-full w-[80px] h-[80px]">
<Image
// @ts-ignore
src={speaker.avatar}
alt={speaker.name}
width={80}
height={80}
className="rounded-full w-full h-full mb-2 object-cover"
/>
<div className={cn('absolute inset-0 rounded-full', css['speaker-gradient'])} />
</div>
<p className="text-xs font-medium">{speaker.name}</p>
</Link>
))}
</div>
</SwipeToScroll>
</div>
<RecommendedSpeakers
speakers={speakers ?? []}
selectedSpeaker={selectedSpeaker}
onSpeakerSelect={onSpeakerSelect}
/>

<div data-type="speaker-prompts" className="flex gap-3 my-4 border-bottom mx-4 pb-4">
<StandalonePrompt className="w-full" onClick={() => setDevaBotVisible('Recommend speakers who know about ')}>
Expand Down
183 changes: 183 additions & 0 deletions devcon-app/src/components/domain/app/dc7/speakers/recommendations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import React, { useMemo, useState } from 'react'
import SwipeToScroll from 'lib/components/event-schedule/swipe-to-scroll'
import { Speaker as SpeakerType } from 'types/Speaker'
import { Session as SessionType } from 'types/Session'
import { Link } from 'components/common/link'
import Image from 'next/image'
import { useQuery } from '@tanstack/react-query'
import { useAccountContext } from 'context/account-context'
import { APP_CONFIG } from 'utils/config'
import { Separator } from 'lib/components/ui/separator'
import cn from 'classnames'
import moment from 'moment'
import { useDraggableLink } from 'lib/hooks/useDraggableLink'
import css from './speakers.module.scss'
import { FancyLoader } from 'lib/components/loader/loader'

interface Props {
speakers: SpeakerType[]
selectedSpeaker?: SpeakerType | null
standalone?: boolean
onSpeakerSelect?: (e: any, speaker: SpeakerType) => void
}

export function RecommendedSpeakers({ speakers, selectedSpeaker, standalone, onSpeakerSelect }: Props) {
const { account } = useAccountContext()
const [filter, setFilter] = useState<'featured' | 'social'>('featured')
const draggableLink = useDraggableLink()

const featuredSpeakers = useMemo(
() =>
speakers
?.filter(speaker =>
speaker.sessions?.some(session => session.featured && moment(session.slot_start).isAfter(moment()))
)
.sort(() => Math.random() - 0.5),
[speakers]
)

const { data: recommended, isLoading } = useQuery({
queryKey: ['account', 'speakers', 'recommended', account?.id],
queryFn: async () => {
if (!account?.id) {
console.log('Not logged in... No recommendations')
return []
}

try {
const response = await fetch(`${APP_CONFIG.API_BASE_URL}/account/speakers/recommended`, {
method: 'GET',
credentials: 'include',
})

const { data } = await response.json()
return data.sort(() => Math.random() - 0.5)
} catch (error) {
console.error('Error fetching recommended speakers', error)
return []
}
},
})

const speakerList = useMemo(() => {
if (filter === 'featured') return featuredSpeakers
if (filter === 'social') return recommended
return []
}, [filter, featuredSpeakers, recommended])

return (
<>
<div className="flex justify-between gap-3 pb-4 px-4 font-semibold">
Speaker Highlights{' '}
{standalone && (
<Link
to="/speakers"
className="shrink-0 select-none cursor-pointer mr-2 rounded-full bg-white border border-solid border-[#E1E4EA] px-3 py-1 text-xs flex items-center justify-center text-[#717784] hover:text-black transition-all duration-300"
>
<p>Go to Speakers</p>
</Link>
)}
</div>

{standalone && (
<SwipeToScroll scrollIndicatorDirections={{ right: true }}>
<div className="flex flex-row gap-3 flex-nowrap p-1 px-4 text-xs items-center">
<div
className={cn(
'flex shrink-0 items-center justify-center align-middle rounded-full border bg-white border-solid border-transparent shadow px-4 py-1 select-none transition-all duration-300',
filter === 'featured' ? 'border-[#ac9fdf] !bg-[#EFEBFF]' : ''
)}
onClick={() => setFilter('featured')}
>
Featured
</div>

<Separator orientation="vertical" className="h-6" />

{[
{
id: 'social',
name: 'Onchain Social',
},
].map(({ id, name }) => {
return (
<div
key={name}
className={cn(
'flex shrink-0 items-center justify-center align-middle rounded-full border bg-white border-solid border-transparent shadow px-4 py-1 select-none transition-all duration-300',
filter === id.toLowerCase() ? 'border-[#ac9fdf] !bg-[#EFEBFF]' : ''
)}
onClick={() => {
setFilter(id.toLowerCase() as 'featured' | 'social')
}}
>
{name}
</div>
)
})}
</div>
</SwipeToScroll>
)}

<div className={cn('overflow-hidden mb-3', standalone ? 'my-4' : '')}>
<SwipeToScroll scrollIndicatorDirections={{ right: true }}>
<div className="flex flex-row gap-3">
{filter === 'social' && isLoading && (
<div className="ml-4 flex items-center justify-center w-full">
<FancyLoader loading={isLoading} size={60} />
</div>
)}
{filter === 'social' && !isLoading && !speakerList?.length && (
<div className="ml-4 text-xs text-[#717784]">
<p>
<Link to="/account/wallets" className="underline text-[#7d52f4]">
Connect your wallet
</Link>{' '}
to include your onchain social graph. Your social connections are based on{' '}
<Link to="https://farcaster.xyz" target="_blank" className="underline text-[#7d52f4]">
Farcaster
</Link>
,{' '}
<Link to="https://lens.xyz" target="_blank" className="underline text-[#7d52f4]">
Lens
</Link>{' '}
and{' '}
<Link to="https://ethfollow.xyz" target="_blank" className="underline text-[#7d52f4]">
Ethereum Follow Protocol
</Link>
.
</p>
</div>
)}
{speakerList?.map((speaker: SpeakerType, index: number) => (
<Link
to={`/speakers/${speaker.sourceId}`}
key={speaker.sourceId}
className={cn(
'flex flex-col items-center justify-center gap-2 rounded-xl bg-white border border-solid border-[#E1E4EA] p-2 shrink-0 cursor-pointer hover:border-[#ac9fdf] transition-all duration-300',
selectedSpeaker?.sourceId === speaker.sourceId ? 'border-[#ac9fdf] !bg-[#EFEBFF]' : '',
index === 0 ? 'ml-4' : ''
)}
{...draggableLink}
onClick={(e: any) => onSpeakerSelect?.(e, speaker)}
>
<div className="relative rounded-full w-[80px] h-[80px]">
<Image
// @ts-ignore
src={speaker.avatar}
alt={speaker.name}
width={80}
height={80}
className="rounded-full w-full h-full mb-2 object-cover"
/>
<div className={cn('absolute inset-0 rounded-full', css['speaker-gradient'])} />
</div>
<p className="text-xs font-medium">{speaker.name}</p>
</Link>
))}
</div>
</SwipeToScroll>
</div>
</>
)
}

0 comments on commit e6e97a0

Please sign in to comment.