diff --git a/src/components/pages/lessons/overlay/confirm-membership.tsx b/src/components/pages/lessons/overlay/confirm-membership.tsx index 3c2d2c978..f78757e8d 100644 --- a/src/components/pages/lessons/overlay/confirm-membership.tsx +++ b/src/components/pages/lessons/overlay/confirm-membership.tsx @@ -16,7 +16,9 @@ type HeaderProps = { type ConfirmMembershipProps = { sessionId: string viewLesson: Function - lesson: LessonResource + lesson: { + slug: string + } } const Illustration = () => ( @@ -101,7 +103,9 @@ const ExistingMemberConfirmation: React.FC< React.PropsWithChildren<{ session: any viewLesson: Function - lesson: LessonResource + lesson: { + slug: string + } }> > = ({session, viewLesson, lesson}) => { return ( @@ -142,10 +146,8 @@ const NewMemberConfirmation: React.FC< React.PropsWithChildren<{ session: any currentState: any - viewLesson: Function - lesson: LessonResource }> -> = ({session, currentState, viewLesson, lesson}) => { +> = ({session, currentState}) => { return (
Thank you so much for joining egghead! } @@ -269,12 +271,7 @@ const ConfirmMembership: React.FC< viewLesson={cleanUrlAndViewLesson} /> ) : ( - + )} ) : ( diff --git a/src/components/pages/lessons/overlay/go-pro-cta-overlay.tsx b/src/components/pages/lessons/overlay/go-pro-cta-overlay.tsx index 863e77410..607aabf1f 100644 --- a/src/components/pages/lessons/overlay/go-pro-cta-overlay.tsx +++ b/src/components/pages/lessons/overlay/go-pro-cta-overlay.tsx @@ -22,9 +22,15 @@ import ConfirmMembership from './confirm-membership' import {useRouter} from 'next/router' import noop from '@/utils/noop' import OverlayWrapper from '@/components/pages/lessons/overlay/wrapper' +import {usePathname} from 'next/navigation' type JoinCTAProps = { - lesson: LessonResource + lesson: { + slug: string + collection?: { + title: string + } + } viewLesson?: Function } @@ -35,9 +41,8 @@ type FormikValues = { const GoProCtaOverlay: FunctionComponent< React.PropsWithChildren > = ({lesson}) => { - // useRouter's `asPath` can include query params, so using - // `window.location.pathname` instead. - const cleanPath = window?.location?.pathname + const pathname = usePathname() + const {collection} = lesson const {viewer, authToken} = useViewer() const {state, send, priceId, quantity, availableCoupons, currentPlan} = @@ -165,7 +170,7 @@ const GoProCtaOverlay: FunctionComponent< authToken, quantity, coupon: state.context.couponToApply?.couponCode, - successPath: cleanPath, + successPath: pathname ?? undefined, }) leaveSpinningForRedirect = true @@ -186,8 +191,8 @@ const GoProCtaOverlay: FunctionComponent< } switch (true) { - case !isEmpty(lesson.collection): - primaryCtaText = `Level up with ${lesson.collection.title} right now.` + case !isEmpty(collection): + primaryCtaText = `Level up with ${collection?.title} right now.` break default: primaryCtaText = 'Ready to take your career to the next level?' diff --git a/src/pages/[post].tsx b/src/pages/[post].tsx index 5a0ebdd02..14870a88d 100644 --- a/src/pages/[post].tsx +++ b/src/pages/[post].tsx @@ -26,6 +26,39 @@ import CopyToClipboard from '@/components/copy-resource' import {track} from '@/utils/analytics' import {LikeButton} from '@/components/like-button' import BlueskyLink from '@/components/share-bluesky' +import {z} from 'zod' +import GoProCtaOverlay from '@/components/pages/lessons/overlay/go-pro-cta-overlay' + +export const FieldsSchema = z.object({ + body: z.string().optional(), + slug: z.string().optional(), + state: z.string().optional(), + title: z.string().optional(), + access: z.string().optional(), + github: z.string().optional(), + gitpod: z.string().optional(), + postType: z.string().optional(), + visibility: z.string().optional(), + description: z.string().optional(), + eggheadLessonId: z.number().optional(), +}) +export type Fields = z.infer + +export const PostSchema = z.object({ + id: z.string().optional(), + type: z.string().optional(), + createdById: z.string().optional(), + fields: FieldsSchema, + createdAt: z.date(), + updatedAt: z.date(), + deletedAt: z.date().nullish(), + currentVersionId: z.string().optional(), + organizationId: z.string().nullish(), + createdByOrganizationMembershipId: z.string().nullish(), + name: z.string().optional(), + image: z.string().optional(), +}) +export type Post = z.infer const access: ConnectionOptions = { uri: process.env.COURSE_BUILDER_DATABASE_URL, @@ -74,49 +107,112 @@ SELECT * } } -export const getStaticProps: GetServerSideProps = async function ({params}) { - if (!params?.post) { +interface ParsedSlug { + hashFromSlug: string + originalSlug: string +} + +function parseSlugForHash(rawSlug: string | string[]): ParsedSlug { + if (!rawSlug) { + throw new Error('Slug is required') + } + + const slug = String(rawSlug) + + // Try to get hash from tilde-separated slug first + const tildeSegments = slug.split('~') + if (tildeSegments.length > 1) { return { - notFound: true, + hashFromSlug: tildeSegments[tildeSegments.length - 1], + originalSlug: slug, } } - let hashFromSlug: string - let splitOnTilde = String(params.post).split('~') + // Fallback to dash-separated slug + const dashSegments = slug.split('-') + if (dashSegments.length === 0) { + throw new Error('Invalid slug format') + } - if (splitOnTilde.length > 1) { - hashFromSlug = splitOnTilde[splitOnTilde.length - 1] - } else { - let splitOnDash = String(params.post).split('-') - hashFromSlug = splitOnDash[splitOnDash.length - 1] + return { + hashFromSlug: dashSegments[dashSegments.length - 1], + originalSlug: slug, } +} + +interface PostQueryResult { + videoResource: RowDataPacket + post: Post +} + +async function getPost(slug: string) { + const {hashFromSlug, originalSlug} = parseSlugForHash(slug) const conn = await mysql.createConnection(access) - const [videoResourceRows] = await conn.execute(` -SELECT * - FROM egghead_ContentResource cr_lesson - JOIN egghead_ContentResourceResource crr ON cr_lesson.id = crr.resourceOfId - JOIN egghead_ContentResource cr_video ON crr.resourceId = cr_video.id - WHERE (cr_lesson.id = '${params.post}' OR JSON_UNQUOTE(JSON_EXTRACT(cr_lesson.fields, '$.slug')) = '${params.post}' OR cr_lesson.id LIKE '%${hashFromSlug}' OR JSON_UNQUOTE(JSON_EXTRACT(cr_lesson.fields, '$.slug')) LIKE '%${hashFromSlug}') - AND cr_video.type = 'videoResource' - LIMIT 1`) - const [postRows] = await conn.execute(` -SELECT cr_lesson.*, egh_user.name, egh_user.image - FROM egghead_ContentResource cr_lesson - LEFT JOIN egghead_User egh_user ON cr_lesson.createdById = egh_user.id - WHERE (cr_lesson.id = '${params.post}' OR JSON_UNQUOTE(JSON_EXTRACT(cr_lesson.fields, '$.slug')) = '${params.post}' OR cr_lesson.id LIKE '%${hashFromSlug}' OR JSON_UNQUOTE(JSON_EXTRACT(cr_lesson.fields, '$.slug')) LIKE '%${hashFromSlug}') - LIMIT 1`) - await conn.end() - const videoResource = videoResourceRows[0] - const post = postRows[0] + try { + // Get video resource + const [videoResourceRows] = await conn.execute( + ` + SELECT * + FROM egghead_ContentResource cr_lesson + JOIN egghead_ContentResourceResource crr ON cr_lesson.id = crr.resourceOfId + JOIN egghead_ContentResource cr_video ON crr.resourceId = cr_video.id + WHERE (cr_lesson.id = ? OR JSON_UNQUOTE(JSON_EXTRACT(cr_lesson.fields, '$.slug')) = ? OR cr_lesson.id LIKE ? OR JSON_UNQUOTE(JSON_EXTRACT(cr_lesson.fields, '$.slug')) LIKE ?) + AND cr_video.type = 'videoResource' + LIMIT 1 + `, + [slug, slug, `%${hashFromSlug}`, `%${hashFromSlug}`], + ) + + // Get post data + const [postRows] = await conn.execute( + ` + SELECT cr_lesson.*, egh_user.name, egh_user.image + FROM egghead_ContentResource cr_lesson + LEFT JOIN egghead_User egh_user ON cr_lesson.createdById = egh_user.id + WHERE (cr_lesson.id = ? OR JSON_UNQUOTE(JSON_EXTRACT(cr_lesson.fields, '$.slug')) = ? OR cr_lesson.id LIKE ? OR JSON_UNQUOTE(JSON_EXTRACT(cr_lesson.fields, '$.slug')) LIKE ?) + LIMIT 1 + `, + [slug, slug, `%${hashFromSlug}`, `%${hashFromSlug}`], + ) + + const videoResource = videoResourceRows[0] + const postRow = postRows[0] + + const postData = PostSchema.safeParse(postRow) + if (!postData.success) { + throw new Error('Invalid post data') + } + + return { + videoResource, + post: postData.data, + } + } catch (error) { + console.error(error) + return null + } finally { + await conn.end() + } +} + +export const getStaticProps: GetServerSideProps = async function ({params}) { + if (!params?.post) { + return { + notFound: true, + } + } + + const result = await getPost(params.post as string) - if (!post) { + if (!result) { return { notFound: true, } } + const {post, videoResource} = result const lesson = await fetch( `${process.env.NEXT_PUBLIC_AUTH_DOMAIN}/api/v1/lessons/${post.fields.eggheadLessonId}`, ).then((res) => res.json()) @@ -170,7 +266,7 @@ SELECT cr_lesson.*, egh_user.name, egh_user.image .catch((err) => {}) } - const mdxSource = await serializeMDX(post.fields.body, { + const mdxSource = await serializeMDX(post.fields?.body ?? '', { useShikiTwoslash: true, syntaxHighlighterOptions: { authorization: process.env.SHIKI_AUTH_TOKEN!, @@ -226,17 +322,7 @@ function InstructorProfile({ }) { const content = (
- {instructor?.avatar_url ? ( - {instructor.full_name} - ) : ( - - )} + {instructor?.avatar_url ? null : }
Instructor @@ -265,7 +351,7 @@ export default function PostPage({ tags, }: { mdxSource: any - post: any + post: Post instructor: { full_name: string avatar_url: string @@ -275,7 +361,7 @@ export default function PostPage({ tags: any }) { const imageParams = new URLSearchParams() - imageParams.set('title', post.fields.title) + imageParams.set('title', post.fields?.title ?? '') return (
@@ -307,6 +393,7 @@ export default function PostPage({ )}
@@ -324,7 +411,7 @@ export default function PostPage({
- +
{post.fields.github && ( @@ -343,7 +430,7 @@ export default function PostPage({ Code )} - +
@@ -435,10 +522,12 @@ function PostPlayer({ playbackId, eggheadLessonId, playerProps = defaultPlayerProps, + post, }: { playbackId: string eggheadLessonId?: number | null playerProps?: MuxPlayerProps + post: Post }) { const [writingProgress, setWritingProgress] = React.useState(false) const {mutate: markLessonComplete} = @@ -447,6 +536,12 @@ function PostPlayer({ const {mutateAsync: addProgressToLesson} = trpc.progress.addProgressToLesson.useMutation() + const {data: viewer} = trpc.user.current.useQuery() + + const isPro = post.fields.access === 'pro' + const canView = + !isPro || (isPro && Boolean(viewer) && Boolean(viewer?.is_pro)) + async function writeProgressToLesson({ currentTime, lessonId, @@ -465,28 +560,34 @@ function PostPlayer({ } return ( - { - if (eggheadLessonId) { - markLessonComplete({ - lessonId: eggheadLessonId, - }) - } - }} - onTimeUpdate={async (e: any) => { - const muxPlayer = (e?.currentTarget as MuxPlayerElement) || null - if (!muxPlayer || writingProgress) return - setWritingProgress(true) - await writeProgressToLesson({ - currentTime: muxPlayer.currentTime, - lessonId: eggheadLessonId, - }) - setWritingProgress(false) - }} - className="relative z-10 flex items-center max-h-[calc(100vh-240px)] h-full bg-black justify-center" - /> + <> + {canView ? ( + { + if (eggheadLessonId) { + markLessonComplete({ + lessonId: eggheadLessonId, + }) + } + }} + onTimeUpdate={async (e: any) => { + const muxPlayer = (e?.currentTarget as MuxPlayerElement) || null + if (!muxPlayer || writingProgress) return + setWritingProgress(true) + await writeProgressToLesson({ + currentTime: muxPlayer.currentTime, + lessonId: eggheadLessonId, + }) + setWritingProgress(false) + }} + className="relative z-10 flex items-center max-h-[calc(100vh-240px)] h-full bg-black justify-center" + /> + ) : ( + + )} + ) }