From fdeb74abb3ad5ff0beef2e1fed59b677f5585e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Goszczy=C5=84ski?= Date: Thu, 20 Mar 2025 15:57:22 +0100 Subject: [PATCH] add partymode part 1 --- apps/www/components/LaunchWeek/14/LwView.tsx | 10 +- .../LaunchWeek/14/hooks/use-conf-data.tsx | 11 +- .../LaunchWeek/14/hooks/use-partymode.ts | 59 ++++++-- .../LaunchWeek/14/hooks/use-registration.ts | 130 +++++++++--------- .../migrations/20250317183330_add_lw_14.sql | 1 - .../20250320114326_add_lw_14_broadcast.sql | 32 +++++ supabase/seed.sql | 2 + 7 files changed, 158 insertions(+), 87 deletions(-) delete mode 100644 supabase/migrations/20250317183330_add_lw_14.sql create mode 100644 supabase/migrations/20250320114326_add_lw_14_broadcast.sql diff --git a/apps/www/components/LaunchWeek/14/LwView.tsx b/apps/www/components/LaunchWeek/14/LwView.tsx index d496366a8f7..589b6d2136d 100644 --- a/apps/www/components/LaunchWeek/14/LwView.tsx +++ b/apps/www/components/LaunchWeek/14/LwView.tsx @@ -26,7 +26,7 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' const dates = [new Date('2025-03-31T07:00:00.000-07:00')] export const LwView = () => { - const partymode = usePartymode() + const partymode = usePartymode("on") const [state] = useConfData() const register = useRegistration() @@ -52,13 +52,13 @@ export const LwView = () => { CLAIM YOUR TICKET { partymode.toggle() }} > - Party mode: {partymode.state === 'on' ? 'ON' : 'OFF'} + Party mode: {partymode.state === 'joined' ? 'ON' : 'OFF'} @@ -73,13 +73,13 @@ export const LwView = () => {
{ partymode.toggle() }} > - Party mode: {partymode.state === 'on' ? 'ON' : 'OFF'} + Party mode: {partymode.state === 'joined' ? 'ON' : 'OFF'}
diff --git a/apps/www/components/LaunchWeek/14/hooks/use-conf-data.tsx b/apps/www/components/LaunchWeek/14/hooks/use-conf-data.tsx index 9d790b40b66..2045afe1d02 100644 --- a/apps/www/components/LaunchWeek/14/hooks/use-conf-data.tsx +++ b/apps/www/components/LaunchWeek/14/hooks/use-conf-data.tsx @@ -1,4 +1,4 @@ -import { Session, SupabaseClient } from '@supabase/supabase-js' +import { RealtimeChannel, Session, SupabaseClient } from '@supabase/supabase-js' import { useRouter } from 'next/router' import { createContext, Dispatch, useContext, useMemo, useReducer } from 'react' @@ -46,7 +46,7 @@ type LwAction = | { type: 'TICKET_LOADING_START' } | { type: 'TICKET_LOADING_SUCCESS' } | { type: 'TICKET_LOADING_ERROR'; payload?: Error } - | { type: 'PARTYMODE_ENABLE' } + | { type: 'PARTYMODE_ENABLE', payload: RealtimeChannel } | { type: 'PARTYMODE_DISABLE' } // Define state interface @@ -59,7 +59,7 @@ interface LwState { ticketLoadingState: 'unloaded' | 'loading' | 'error' | 'loaded' ticketVisibility: boolean claimFormState: 'initial' | 'visible' | 'hidden' - partyModeState: 'unloaded' | 'on' | 'off' + realtimeGaugesChannel: RealtimeChannel | null referal?: string } @@ -116,13 +116,13 @@ export const lwReducer = (state: LwState, action: LwAction): LwState => { case 'PARTYMODE_ENABLE': { return { ...state, - partyModeState: 'on', + realtimeGaugesChannel: action.payload, } } case 'PARTYMODE_DISABLE': { return { ...state, - partyModeState: 'off', + realtimeGaugesChannel: null, } } default: @@ -152,6 +152,7 @@ export const Lw14ConfDataProvider = ({ userTicketDataError: null, claimFormState: 'initial', partyModeState: 'unloaded', + realtimeGaugesChannel: null, ...initState, }) diff --git a/apps/www/components/LaunchWeek/14/hooks/use-partymode.ts b/apps/www/components/LaunchWeek/14/hooks/use-partymode.ts index f18addfb5a3..891caa31462 100644 --- a/apps/www/components/LaunchWeek/14/hooks/use-partymode.ts +++ b/apps/www/components/LaunchWeek/14/hooks/use-partymode.ts @@ -1,17 +1,54 @@ -import useLw14ConfData from "./use-conf-data" +import { useCallback, useEffect, useState } from 'react' +import useLw14ConfData from './use-conf-data' +import supabase from '~/lib/supabase' +import { REALTIME_CHANNEL_STATES, REALTIME_SUBSCRIBE_STATES } from '@supabase/supabase-js' -export const usePartymode = () => { +const LW14_TOPIC = 'lw14' +const GAUGES_UPDATES_EVENT = 'gauges-update' + +export const usePartymode = (defaultState: 'on' | 'off') => { const [state, dispatch] = useLw14ConfData() + const [shouldInitialize, setShouldInitialize] = useState(defaultState === 'on' && !state.realtimeGaugesChannel) + + const createChannelAndSubscribe = useCallback(() => { + const channel = supabase + .channel(LW14_TOPIC) + .on('broadcast', { event: GAUGES_UPDATES_EVENT }, (payload) => { + console.log('Channel update', payload) + }) + + channel.subscribe((status, error) => { + console.log('Channel status', status, error) + if (status === REALTIME_SUBSCRIBE_STATES.SUBSCRIBED) { + dispatch({ type: 'PARTYMODE_ENABLE', payload: channel }) + } + }) + }, [dispatch]) + + const toggle = useCallback(async () => { + if (state.realtimeGaugesChannel?.state === REALTIME_CHANNEL_STATES.joined) { + await state.realtimeGaugesChannel.unsubscribe() + dispatch({ type: 'PARTYMODE_DISABLE' }) + return + } + + if ( + state.realtimeGaugesChannel === null || + state.realtimeGaugesChannel.state === REALTIME_CHANNEL_STATES.closed + ) { + createChannelAndSubscribe() + } + }, [createChannelAndSubscribe, dispatch, state.realtimeGaugesChannel]) + + useEffect(() => { + if (shouldInitialize) { + setShouldInitialize(false) + createChannelAndSubscribe() + } + }, [createChannelAndSubscribe, shouldInitialize]) return { - state: state.partyModeState, - toggle: () => { - if(state.partyModeState === 'on') { - dispatch({ type: 'PARTYMODE_DISABLE' }) - } else { - dispatch({ type: 'PARTYMODE_ENABLE' }) - } - } + state: state.realtimeGaugesChannel?.state, + toggle, } } - diff --git a/apps/www/components/LaunchWeek/14/hooks/use-registration.ts b/apps/www/components/LaunchWeek/14/hooks/use-registration.ts index b93f7794c54..59dc150ef54 100644 --- a/apps/www/components/LaunchWeek/14/hooks/use-registration.ts +++ b/apps/www/components/LaunchWeek/14/hooks/use-registration.ts @@ -1,36 +1,54 @@ -import { useTheme } from 'next-themes' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { REALTIME_CHANNEL_STATES, RealtimeChannel, SupabaseClient } from '@supabase/supabase-js' -import { useRouter } from 'next/router' import useConfData from './use-conf-data' import { LW14_URL } from '~/lib/constants' import supabase from '../supabase' +function subscribeToTicketChanges( + username: string, + onChange: (payload: any) => void +): RealtimeChannel { + return supabase + .channel('changes') + .on( + 'postgres_changes', + { + event: 'UPDATE', + schema: 'public', + table: 'tickets', + filter: `username=eq.${username}`, + }, + onChange + ) + .subscribe() +} + interface RegistrationProps { onRegister?: () => void onError?: (error: any) => void } -export const useRegistration = (props?: RegistrationProps) => { - const { resolvedTheme } = useTheme() +export const useRegistration = ({ onError, onRegister }: RegistrationProps = {}) => { const [realtimeChannel, setRealtimeChannel] = useState(null) - const [{ userTicketData: userData, session, userTicketDataState }, dispatch] = useConfData() - const router = useRouter() + const [{ userTicketData: userData, session, userTicketDataState, referal }, dispatch] = + useConfData() + const sessionUser = session?.user + const callbacksRef = useRef({ onError, onRegister }) // Triggered on session - const fetchOrCreateUser = async () => { + const fetchOrCreateUser = useCallback(async () => { if (['loading', 'loaded'].includes(userTicketDataState)) return - if (!session?.user) { + if (!sessionUser) { console.warn('Cannot fetch user without session. Skipping...') return } - const username = session.user.user_metadata?.user_name as string | undefined - const name = session.user.user_metadata.full_name - const email = session.user.email - const userId = session.user.id + const username = sessionUser.user_metadata?.user_name as string | undefined + const name = sessionUser.user_metadata.full_name + const email = sessionUser.email + const userId = sessionUser.id if (!username) { throw new Error('Username is required') @@ -47,8 +65,7 @@ export const useRegistration = (props?: RegistrationProps) => { email, name, username, - metadata: { theme: resolvedTheme }, - referred_by: router.query?.referral ?? null, + referred_by: referal ?? null, }) .select() .single() @@ -56,7 +73,7 @@ export const useRegistration = (props?: RegistrationProps) => { // If error because of duplicate email, ignore and proceed, otherwise sign out. if (ticketInsertError && ticketInsertError?.code !== '23505') { dispatch({ type: 'USER_TICKET_FETCH_ERROR', payload: ticketInsertError }) - props?.onError?.(ticketInsertError) + callbacksRef.current.onError?.(ticketInsertError) return supabase.auth.signOut() } @@ -69,33 +86,36 @@ export const useRegistration = (props?: RegistrationProps) => { if (ticketsViewError) { dispatch({ type: 'USER_TICKET_FETCH_ERROR', payload: ticketsViewError }) - props?.onError?.(ticketsViewError) + callbacksRef.current.onError?.(ticketsViewError) return } dispatch({ type: 'USER_TICKET_FETCH_SUCCESS', payload: data }) await prefetchData(username) - - if (!realtimeChannel || realtimeChannel.state === REALTIME_CHANNEL_STATES.closed) { - const channel = subscribeToTicketChanges(username, (payload) => { - const platinum = !!payload.new.shared_on_twitter && !!payload.new.shared_on_linkedin - const secret = !!payload.new.game_won_at - dispatch({ - type: 'USER_TICKET_UPDATED', - payload: { - ...payload.new, - platinum, - secret, - }, - }) - }) - setRealtimeChannel(channel) - } } - props?.onRegister?.() - } + if ( + (!realtimeChannel || realtimeChannel.state === REALTIME_CHANNEL_STATES.closed) && + username + ) { + const channel = subscribeToTicketChanges(username, (payload) => { + const platinum = !!payload.new.shared_on_twitter && !!payload.new.shared_on_linkedin + const secret = !!payload.new.game_won_at + dispatch({ + type: 'USER_TICKET_UPDATED', + payload: { + ...payload.new, + platinum, + secret, + }, + }) + }) + setRealtimeChannel(channel) + } + + callbacksRef.current.onRegister?.() + }, [dispatch, realtimeChannel, referal, sessionUser, userData.id, userTicketDataState]) async function prefetchData(username: string) { // Prefetch GitHub avatar @@ -105,26 +125,7 @@ export const useRegistration = (props?: RegistrationProps) => { await fetch(`/api-v2/ticket-og?username=${username}`) } - function subscribeToTicketChanges( - username: string, - onChange: (payload: any) => void - ): RealtimeChannel { - return supabase - .channel('changes') - .on( - 'postgres_changes', - { - event: 'UPDATE', - schema: 'public', - table: 'tickets', - filter: `username=eq.${username}`, - }, - onChange - ) - .subscribe() - } - - async function handleGithubSignIn() { + const handleGithubSignIn = useCallback(async () => { const redirectTo = `${LW14_URL}${userData.username ? '?referral=' + userData.username : ''}` const response = await supabase?.auth.signInWithOAuth({ @@ -136,24 +137,20 @@ export const useRegistration = (props?: RegistrationProps) => { if (response) { if (response.error) { - props?.onError?.(response.error) + callbacksRef.current?.onError?.(response.error) return } } - } + }, [userData.username]) useEffect(() => { - if (!session) { - return - } - fetchOrCreateUser() return () => { // Cleanup realtime subscription on unmount realtimeChannel?.unsubscribe() } - }, [session]) + }, [fetchOrCreateUser, realtimeChannel]) useEffect(() => { supabase.auth.getSession().then(({ data }) => { @@ -169,11 +166,15 @@ export const useRegistration = (props?: RegistrationProps) => { return () => subscription.unsubscribe() }, [dispatch]) + useEffect(() => { + callbacksRef.current = { onError, onRegister } + }) + const upgradeTicket = async () => { if (userData.id) { - if(userData.secret) { + if (userData.secret) { console.log('User already has a secret ticket') - return + return } const { error } = await supabase @@ -188,7 +189,6 @@ export const useRegistration = (props?: RegistrationProps) => { // Trigger og-image ticket generation await fetch(`/api-v2/ticket-og?username=${userData.username}&secret=true`) - } else { console.warn('Cannot upgrade ticket without user data') } diff --git a/supabase/migrations/20250317183330_add_lw_14.sql b/supabase/migrations/20250317183330_add_lw_14.sql deleted file mode 100644 index 84a08daa309..00000000000 --- a/supabase/migrations/20250317183330_add_lw_14.sql +++ /dev/null @@ -1 +0,0 @@ -insert into public.launch_weeks (id) values ('lw14'); diff --git a/supabase/migrations/20250320114326_add_lw_14_broadcast.sql b/supabase/migrations/20250320114326_add_lw_14_broadcast.sql new file mode 100644 index 00000000000..2c27de66fdd --- /dev/null +++ b/supabase/migrations/20250320114326_add_lw_14_broadcast.sql @@ -0,0 +1,32 @@ +-- Example how to send message from DB to listening users +-- +-- select +-- realtime.send( +-- jsonb_build_object('hello', 'world'), -- JSONB Payload +-- 'event', -- Event name +-- 'topic', -- Topic +-- false -- Public / Private flag +-- ); + +CREATE OR REPLACE FUNCTION public.send_updated_stats_for_lw14() RETURNS trigger AS $$ +BEGIN +-- Based on ticket count send following message +-- { +-- "fuel": number (0.2-1) calculated as a limit function 0.2 + 1 / (1 + all tickets / 10) +-- "payload_value": each regular ticket gives $1 each platinum one $10 +-- } + PERFORM realtime.send( + jsonb_build_object('hello', 'world'), -- JSONB Payload + 'gauges-update', -- Event name + 'lw14', -- Topic + true -- Public / Private flag + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER broadcast_stats_for_lw14_tickets +AFTER INSERT OR UPDATE OR DELETE ON public.tickets +FOR EACH ROW +WHEN (NEW.launch_week = 'lw14') +EXECUTE FUNCTION public.send_updated_stats_for_lw14(); diff --git a/supabase/seed.sql b/supabase/seed.sql index c9380d1bfbc..702adcfcd90 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -4,3 +4,5 @@ values ('New York', 'USA', 'lw12', now(), true), ('London', 'UK', 'lw12', now(), true), ('Singapore', 'Singapore', 'lw12', now(), true); + +insert into public.launch_weeks (id) values ('lw14');