Skip to content

Commit

Permalink
feat: Post completion overlay (#1505)
Browse files Browse the repository at this point in the history
* chore: remove console logs

* fix: remove typesense indexing

Not sure why we index on the front end but this was removing tags from indexed posts and generally out of sync with builder

* feat: add completion overlay to posts

- add CTA to Cursor workshop for posts tagged with cursor
- pull tags from course builder

* chore: remove console.log

* design: stack CTA buttons on desktop

* Update src/pages/[post].tsx

* fix: pass tags correctly

* fix: update CTA heading
  • Loading branch information
zacjones93 authored Jan 31, 2025
1 parent b7892b4 commit f0d93bc
Show file tree
Hide file tree
Showing 4 changed files with 344 additions and 81 deletions.
144 changes: 144 additions & 0 deletions src/components/posts/video-player-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import Link from 'next/link'
import Image from 'next/image'

import {
useVideoPlayerOverlay,
type CompletedAction,
} from '@/hooks/mux/use-video-player-overlay'
import {Post} from '@/pages/[post]'
import Spinner from '@/spinner'
import {Button} from '@/ui/button'
import {ArrowRight} from 'lucide-react'
import {RefreshCw} from 'lucide-react'
import {Card, CardContent, CardTitle, CardHeader, CardFooter} from '@/ui/card'
import {motion} from 'framer-motion'

type VideoPlayerOverlayProps = {
resource: Post
}

const VideoPlayerOverlay: React.FC<VideoPlayerOverlayProps> = ({resource}) => {
const {state: overlayState, dispatch} = useVideoPlayerOverlay()

switch (overlayState.action?.type) {
case 'COMPLETED':
const {playerRef, cta} = overlayState.action as CompletedAction

if (cta === 'cursor_workshop') {
return (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
aria-live="polite"
className="z-40 bg-background/85 absolute left-0 top-0 flex h-full w-full flex-col items-center justify-center pb-6 backdrop-blur-md sm:pb-16 text-white"
>
<CursorCTAOverlay
signUpLink="/workshop/cursor"
onReplay={() => {
if (playerRef.current) {
playerRef.current.play()
}
dispatch({type: 'HIDDEN'})
}}
/>
</motion.div>
)
}

return (
<motion.div
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
aria-live="polite"
className="z-40 bg-background/85 absolute left-0 top-0 flex h-full w-full flex-col items-center justify-center pb-6 backdrop-blur-md sm:pb-16 text-white"
>
<div className="flex flex-col items-center justify-center gap-2 p-4">
<h2 className="text-lg sm:text-2xl font-bold mb-4 text-center">
{resource.fields.title}
</h2>
<Button
variant="outline"
onClick={() => {
if (playerRef.current) {
playerRef.current.play()
}
dispatch({type: 'HIDDEN'})
}}
className="border border-blue-600 hover:bg-blue-600"
>
<RefreshCw className="mr-2 h-4 w-4" />
Watch again
</Button>
</div>
</motion.div>
)
case 'LOADING':
return (
<div
aria-live="polite"
className="text-foreground absolute left-0 top-0 z-40 flex aspect-video h-full w-full flex-col items-center justify-center gap-10 bg-black/80 p-5 text-lg backdrop-blur-md"
>
<Spinner className="text-white" />
</div>
)
case 'HIDDEN':
return null
default:
return null
}
}

export default VideoPlayerOverlay

function CursorCTAOverlay({
signUpLink,
onReplay,
}: {
signUpLink: string
onReplay: () => void
}) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 p-4">
<Card className="w-full max-w-2xl border-none">
<CardContent className="text-center p-0 sm:p-6">
<div className="sm:px-8 flex flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Image
src="https://d2eip9sf3oo6c2.cloudfront.net/tags/images/000/001/411/full/cursor.png"
alt="Cursor"
width={80}
height={80}
className="hidden sm:block"
/>
<h2 className="text-md sm:text-2xl font-bold sm:mb-4 text-balance">
Take Cursor to the Next Level with Live Training
</h2>
</div>
<p className="text-center mb-2 sm:mb-6 text-muted-foreground text-balance">
John Lindquist is teaching a workshop on how to use Cursor to it's
fullest abilities. Get notified when the workshop is released.
</p>
</div>
</CardContent>
<CardFooter className="flex flex-row-reverse sm:flex-col gap-2 sm:gap-4 items-center justify-center p-0">
<Link href={signUpLink} className="">
<Button className="w-full max-w-xs bg-blue-500 hover:bg-blue-600">
Join Waitlist
<ArrowRight className="ml-2 h-5 w-5" />
</Button>
</Link>
<Button
variant="outline"
onClick={onReplay}
className="border border-blue-600 hover:bg-blue-600"
>
<RefreshCw className="mr-2 h-4 w-4" />
Watch again
</Button>
</CardFooter>
</Card>
</div>
)
}
95 changes: 95 additions & 0 deletions src/hooks/mux/use-video-player-overlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
'use client'

import React, {createContext, Reducer, useContext, useReducer} from 'react'
import type {MuxPlayerRefAttributes} from '@mux/mux-player-react'

type VideoPlayerOverlayState = {
action: VideoPlayerOverlayAction | null
}

export type CompletedAction = {
type: 'COMPLETED'
playerRef: React.RefObject<MuxPlayerRefAttributes | null>
cta?: string
}

export type VideoPlayerOverlayAction =
| CompletedAction
| {type: 'BLOCKED'}
| {type: 'HIDDEN'}
| {type: 'LOADING'}

const initialState: VideoPlayerOverlayState = {
action: {
type: 'HIDDEN',
},
}

const reducer: Reducer<VideoPlayerOverlayState, VideoPlayerOverlayAction> = (
state,
action,
) => {
switch (action.type) {
case 'COMPLETED':
// TODO: Track video completion
return {
...state,
action,
}
case 'LOADING':
console.log('loading')
return {
...state,
action,
}
case 'BLOCKED':
return {
...state,
action,
}
case 'HIDDEN': {
return {
...state,
action,
}
}
default:
return state
}
}

type VideoPlayerOverlayContextType = {
state: VideoPlayerOverlayState
dispatch: React.Dispatch<VideoPlayerOverlayAction>
}

export const VideoPlayerOverlayProvider: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const [state, dispatch] = useReducer(reducer, initialState)

const value = React.useMemo(() => ({state, dispatch}), [state, dispatch])

return (
<VideoPlayerOverlayContext.Provider value={value}>
{children}
</VideoPlayerOverlayContext.Provider>
)
}

const VideoPlayerOverlayContext = createContext<
VideoPlayerOverlayContextType | undefined
>(undefined)

export const useVideoPlayerOverlay = () => {
const context = useContext(VideoPlayerOverlayContext)
if (!context) {
throw new Error(
'useVideoPlayerContext must be used within a VideoPlayerProvider',
)
}
return {
state: context.state,
dispatch: context.dispatch,
}
}
36 changes: 36 additions & 0 deletions src/hooks/use-mux-player.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client'

import React, {createContext, useContext} from 'react'
import type {MuxPlayerRefAttributes} from '@mux/mux-player-react'

type MuxPlayerContextType = {
setMuxPlayerRef: React.Dispatch<
React.SetStateAction<React.RefObject<MuxPlayerRefAttributes | null> | null>
>
muxPlayerRef: React.RefObject<MuxPlayerRefAttributes | null> | null
}

export const MuxPlayerProvider: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const [muxPlayerRef, setMuxPlayerRef] =
React.useState<React.RefObject<MuxPlayerRefAttributes | null> | null>(null)

return (
<MuxPlayerContext.Provider value={{muxPlayerRef, setMuxPlayerRef}}>
{children}
</MuxPlayerContext.Provider>
)
}

const MuxPlayerContext = createContext<MuxPlayerContextType | undefined>(
undefined,
)

export const useMuxPlayer = () => {
const context = useContext(MuxPlayerContext)
if (!context) {
throw new Error('useMuxPlayer must be used within a MuxPlayerProvider')
}
return context
}
Loading

0 comments on commit f0d93bc

Please sign in to comment.